mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-10 09:57:01 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
288634c774 | ||
|
|
066fcfc8c2 | ||
|
|
f88852a353 | ||
|
|
0a0a56e653 | ||
|
|
4ddd502a60 | ||
|
|
406d1956b8 | ||
|
|
66ab5a05df | ||
|
|
8e1bfb8af2 | ||
|
|
fc363cc9c5 | ||
|
|
806ec1aeab | ||
|
|
3c505db9d0 | ||
|
|
5fd181d9c0 | ||
|
|
70206c61ef | ||
|
|
f92b059aac | ||
|
|
5dbb6107dd | ||
|
|
dccca92928 | ||
|
|
005d74bfde | ||
|
|
aeb2ac687d | ||
|
|
108b8ef4ac | ||
|
|
2762741226 | ||
|
|
3ffa16234f | ||
|
|
3bcc0c19ce | ||
|
|
a3c62de42f |
+44
-2
@@ -6,14 +6,56 @@
|
||||
<a href="CHANGELOG_EN.md">English</a>
|
||||
</p>
|
||||
|
||||
## v1.11.0(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
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Frontend| 在随机子域名允许范围内新增普通、随机和自定义子域名模式选择(issue #1108)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Frontend| 将邮箱主页的账户设置、删除账户等表述更正为邮箱地址操作,并明确发信权限与额度按当前邮箱地址独立管理
|
||||
|
||||
### Improvements
|
||||
|
||||
- docs: |发送邮件| 补充用户账号、邮箱地址和发信权限的概念区别及按当前地址申请权限的操作说明
|
||||
|
||||
- fix: |Worker| 地址活跃时间保活增加 1 天写入窗口,用户设置和邮箱访问不再重复更新近期活跃地址,降低 D1 写入量(issue #1103)
|
||||
|
||||
- feat: |用户系统| 用户绑定地址列表改用服务端分页,并仅在第一页查询总数;用户邮件列表改用 JOIN、删除改用 `EXISTS` 在数据库侧校验地址归属,避免为大用户加载全部绑定地址(issue #1103)
|
||||
@@ -204,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
|
||||
|
||||
|
||||
+44
-2
@@ -6,14 +6,56 @@
|
||||
<a href="CHANGELOG_EN.md">English</a>
|
||||
</p>
|
||||
|
||||
## v1.11.0(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
|
||||
|
||||
### Features
|
||||
|
||||
- feat: |Frontend| Add Normal, Random, and Custom subdomain mode selection within the random-subdomain scope (issue #1108)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- fix: |Frontend| Relabel mailbox settings and deletion actions as email-address operations, and clarify that send permission and balance are managed independently for the current email address
|
||||
|
||||
### Improvements
|
||||
|
||||
- docs: |Send Mail| Document the differences between user accounts, email addresses, and send permission, including how to request permission for the currently selected address
|
||||
|
||||
- fix: |Worker| Throttle address-activity touches to one write per day so user settings and mailbox access do not repeatedly update recently active addresses, reducing D1 writes (issue #1103)
|
||||
|
||||
- feat: |User| Add server-side pagination for bound addresses, with totals queried only on the first page; validate user-mail list ownership with a JOIN and delete ownership with `EXISTS` instead of loading every bound address for large users (issue #1103)
|
||||
@@ -204,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) => {
|
||||
try {
|
||||
const event = JSON.parse(data.toString());
|
||||
if (event.Type === 'new' && predicate(event.Data)) {
|
||||
clearTimeout(timer);
|
||||
ws.close();
|
||||
if (!settled) { settled = true; resolve(event.Data); }
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
// Mailpit may batch multiple newline-separated events in one message.
|
||||
for (const line of data.toString().split('\n')) {
|
||||
if (settled) return;
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
if (event.Type === 'new' && predicate(event.Data)) {
|
||||
clearTimeout(timer);
|
||||
settled = true;
|
||||
ws.close();
|
||||
resolve(event.Data);
|
||||
return;
|
||||
}
|
||||
} catch { /* ignore malformed events without dropping the remaining ones */ }
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
|
||||
@@ -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
|
||||
@@ -7,22 +7,29 @@ keep_vars = true
|
||||
[vars]
|
||||
PREFIX = "TMP"
|
||||
DEFAULT_DOMAINS = []
|
||||
DOMAINS = ["TEST.EXAMPLE.COM"]
|
||||
DOMAINS = ["TEST.EXAMPLE.COM", "MANUAL.EXAMPLE.COM"]
|
||||
RANDOM_SUBDOMAIN_DOMAINS = ["TEST.EXAMPLE.COM"]
|
||||
CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST = true
|
||||
USER_ROLES = [
|
||||
{ domains = ["TEST.EXAMPLE.COM"], role = "case-role", prefix = "ROLE" },
|
||||
{ domains = [], role = "empty-role", prefix = "EMPTY" },
|
||||
]
|
||||
JWT_SECRET = "e2e-test-secret-key"
|
||||
TELEGRAM_BOT_TOKEN = "e2e-telegram-test-token"
|
||||
BLACK_LIST = ""
|
||||
ENABLE_USER_CREATE_EMAIL = true
|
||||
ENABLE_USER_DELETE_EMAIL = true
|
||||
ENABLE_MAIL_READ_STATUS = true
|
||||
ENABLE_REDEEM_CODE = true
|
||||
REDEEM_CODE_URL = "https://example.com/redeem-codes"
|
||||
ENABLE_AUTO_REPLY = true
|
||||
DEFAULT_SEND_BALANCE = 10
|
||||
NO_LIMIT_SEND_ROLE = "case-role"
|
||||
ENABLE_ADDRESS_PASSWORD = true
|
||||
DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||
ADMIN_USER_ROLE = "admin"
|
||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||
ENABLE_WEBHOOK = true
|
||||
E2E_TEST_MODE = true
|
||||
CLEANUP_BATCH_SIZE = 10
|
||||
SMTP_CONFIG = """
|
||||
{"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}}
|
||||
@@ -36,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
+46
-64
@@ -6,14 +6,14 @@
|
||||
"": {
|
||||
"name": "cloudflare-temp-email-e2e",
|
||||
"dependencies": {
|
||||
"imapflow": "^1.6.5",
|
||||
"nodemailer": "^9.0.4"
|
||||
"imapflow": "^1.7.8",
|
||||
"nodemailer": "^10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.62.1",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"ws": "^8.21.2"
|
||||
"hono": "^4.13.7",
|
||||
"ws": "^8.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
@@ -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.14",
|
||||
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.14.tgz",
|
||||
"integrity": "sha512-rz0FQOhN3Vq1XrSeSSa9+dPcaFbBxmQPjiZm6zS9oxdVHV7rOWIAYX3yP2YAUf0qBncY8CI+NogzPCmMVrMXcw==",
|
||||
"version": "5.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.16.tgz",
|
||||
"integrity": "sha512-zQ9iXvlT3Wi/hazeC1MdI4rQc1UJwJ6IQ6QzSZ5KDxLZZWQSazWLOzImLFluXadKShJ9WJvI1xH+AyVS8b9azg==",
|
||||
"license": "(MIT OR EUPL-1.1+)",
|
||||
"dependencies": {
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.4.1",
|
||||
"libmime": "5.4.3",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
@@ -89,27 +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.6.5",
|
||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.6.5.tgz",
|
||||
"integrity": "sha512-5XgLcfl6blDju2SP7TgTv4WIlTt7zxXYcfjznJFmfFrogHR2XzHGlS9s2wdqxv9ZJ5CPaRnig7KaK7qNoJPmlg==",
|
||||
"version": "1.7.8",
|
||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.7.8.tgz",
|
||||
"integrity": "sha512-dJoCIdZOJh26Rn2PdwEzwj0bRDgGBxxX38pio534FagIHVuR2l0SAfLr6sJo32YfuJHiSs/U2ntNDHnMg3/Hlg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@zone-eu/mailsplit": "5.4.14",
|
||||
"encoding-japanese": "2.2.0",
|
||||
"@zone-eu/mailsplit": "5.4.16",
|
||||
"encoding-japanese": "2.3.0",
|
||||
"iconv-lite": "0.7.3",
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.4.1",
|
||||
"libmime": "5.4.3",
|
||||
"libqp": "2.1.1",
|
||||
"pino": "10.3.1",
|
||||
"socks": "2.8.9"
|
||||
@@ -160,12 +145,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/libmime": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.1.tgz",
|
||||
"integrity": "sha512-0wHGhsofo9IdQPenr3BBHXuxcwMq4atFUTsZ9Ogc1OvI5h4rUdDIrBQEN9JHjCXfDMrE59LUMJWsTD82wTYk8A==",
|
||||
"version": "5.4.3",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.3.tgz",
|
||||
"integrity": "sha512-di9BoDabBUMqjeD/wGj+hHpSgdqAph5ui7w6OdY6NpzU6O6VFLQsMOg9tqCjm/zf9OHzAM9EZxSOF7uIb8O8Hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encoding-japanese": "2.2.0",
|
||||
"encoding-japanese": "2.3.0",
|
||||
"iconv-lite": "0.7.3",
|
||||
"libbase64": "1.3.0",
|
||||
"libqp": "2.1.1"
|
||||
@@ -178,12 +163,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.0.4",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.4.tgz",
|
||||
"integrity": "sha512-LmJNRVRtfSCULxcZpy0Cpg4WWenlUZ9+zbmTO+S7v9wD6XreYLjXRFtDjtV/4F0HT5p1GyZfA0Ux/myxHb18CQ==",
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.0.tgz",
|
||||
"integrity": "sha512-wdv+hXBg0iIPIOf108FaZyTxqjQpCvQeAJt9pHHTooPy/y3vQtI9eiImM9AIVKR3e/0FeMA1oueNfBjFJcaK+Q==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/on-exit-leak-free": {
|
||||
@@ -233,28 +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": {
|
||||
@@ -378,9 +360,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.2",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz",
|
||||
"integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==",
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
||||
+6
-5
@@ -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",
|
||||
"ws": "^8.21.2"
|
||||
"hono": "^4.13.7",
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"imapflow": "^1.6.5",
|
||||
"nodemailer": "^9.0.4"
|
||||
"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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { WORKER_URL } from '../../fixtures/test-helpers';
|
||||
|
||||
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
|
||||
|
||||
test.describe('Admin Config and D1 Storage', () => {
|
||||
test('reports database size in the database status response', async ({ request }) => {
|
||||
const response = await request.get(`${WORKER_URL}/admin/db_version`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
|
||||
expect(response.ok()).toBe(true);
|
||||
const body = await response.json();
|
||||
expect(body.database_size).toEqual(expect.any(Number));
|
||||
expect(body.database_size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('saves and retrieves a namespaced config value', async ({ request }) => {
|
||||
const saveResponse = await request.post(`${WORKER_URL}/admin/config`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { key: 'd1_storage_plan', value: 'free' },
|
||||
});
|
||||
|
||||
expect(saveResponse.ok()).toBe(true);
|
||||
expect(await saveResponse.json()).toEqual({
|
||||
success: true,
|
||||
key: 'd1_storage_plan',
|
||||
value: 'free',
|
||||
});
|
||||
|
||||
const getResponse = await request.get(`${WORKER_URL}/admin/config/d1_storage_plan`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
|
||||
expect(getResponse.ok()).toBe(true);
|
||||
expect(await getResponse.json()).toEqual({
|
||||
key: 'd1_storage_plan',
|
||||
value: 'free',
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps config values isolated from internal settings', async ({ request }) => {
|
||||
const versionBeforeResponse = await request.get(`${WORKER_URL}/admin/db_version`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
const versionBefore = (await versionBeforeResponse.json()).current_db_version;
|
||||
|
||||
const saveResponse = await request.post(`${WORKER_URL}/admin/config`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { key: 'db_version', value: 'shadow-version' },
|
||||
});
|
||||
expect(saveResponse.ok()).toBe(true);
|
||||
|
||||
const versionAfterResponse = await request.get(`${WORKER_URL}/admin/db_version`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
});
|
||||
expect((await versionAfterResponse.json()).current_db_version).toBe(versionBefore);
|
||||
});
|
||||
|
||||
test('rejects invalid keys and non-string values', async ({ request }) => {
|
||||
const invalidKeyResponse = await request.post(`${WORKER_URL}/admin/config`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { key: 'invalid key', value: 'value' },
|
||||
});
|
||||
expect(invalidKeyResponse.status()).toBe(400);
|
||||
|
||||
const invalidValueResponse = await request.post(`${WORKER_URL}/admin/config`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { key: 'valid_key', value: 1 },
|
||||
});
|
||||
expect(invalidValueResponse.status()).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { TEST_DOMAIN, WORKER_URL, WORKER_URL_ENV_OFF, WORKER_URL_SUBDOMAIN } from '../../fixtures/test-helpers';
|
||||
|
||||
const SUBDOMAIN = `team.${TEST_DOMAIN}`;
|
||||
const NESTED_SUBDOMAIN = `deep.team.${TEST_DOMAIN}`;
|
||||
const MIXED_CASE_SUBDOMAIN = `TeAm.${TEST_DOMAIN.toUpperCase()}`;
|
||||
const INVALID_LOOKALIKE_DOMAIN = `bad${TEST_DOMAIN}`;
|
||||
const INVALID_EMPTY_PREFIX_DOMAIN = `.${TEST_DOMAIN}`;
|
||||
const INVALID_EMPTY_LABEL_DOMAIN = `a..b.${TEST_DOMAIN}`;
|
||||
const INVALID_OVERLONG_DOMAIN = `${'a.'.repeat(119)}${TEST_DOMAIN}`;
|
||||
const MANUAL_BASE_DOMAIN = 'manual.example.com';
|
||||
const SUBDOMAIN = `team.${MANUAL_BASE_DOMAIN}`;
|
||||
const NESTED_SUBDOMAIN = `deep.team.${MANUAL_BASE_DOMAIN}`;
|
||||
const MIXED_CASE_SUBDOMAIN = `TeAm.${MANUAL_BASE_DOMAIN.toUpperCase()}`;
|
||||
const INVALID_LOOKALIKE_DOMAIN = `bad${MANUAL_BASE_DOMAIN}`;
|
||||
const INVALID_EMPTY_PREFIX_DOMAIN = `.${MANUAL_BASE_DOMAIN}`;
|
||||
const INVALID_EMPTY_LABEL_DOMAIN = `a..b.${MANUAL_BASE_DOMAIN}`;
|
||||
const INVALID_OVERLONG_DOMAIN = `${'a.'.repeat(119)}${MANUAL_BASE_DOMAIN}`;
|
||||
const RANDOM_SUBDOMAIN = `team.${TEST_DOMAIN}`;
|
||||
const CREATE_ADDRESS_WORKER_URL = WORKER_URL_SUBDOMAIN || WORKER_URL;
|
||||
let originalCreateAddressStoredEnabled: boolean | undefined;
|
||||
let originalEnvOffStoredEnabled: boolean | undefined;
|
||||
@@ -119,16 +121,16 @@ test.describe('Create Address Subdomain Match', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('persisted false still keeps exact match only', async ({ request }) => {
|
||||
test('random subdomain scope allows a manually entered subdomain', async ({ request }) => {
|
||||
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, false);
|
||||
|
||||
const uniqueName = `subdomain-default-${Date.now()}`;
|
||||
const res = await request.post(`${CREATE_ADDRESS_WORKER_URL}/admin/new_address`, {
|
||||
data: { name: uniqueName, domain: SUBDOMAIN },
|
||||
data: { name: uniqueName, domain: RANDOM_SUBDOMAIN },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBe(false);
|
||||
expect(await res.text()).toContain('Invalid domain');
|
||||
expect(res.ok()).toBe(true);
|
||||
expect((await res.json()).address).toContain(`@${RANDOM_SUBDOMAIN}`);
|
||||
});
|
||||
|
||||
test('admin switch enables suffix subdomain match for both admin and user create APIs', async ({ request }) => {
|
||||
@@ -184,13 +186,43 @@ test.describe('Create Address Subdomain Match', () => {
|
||||
expect(await invalidOverlongRes.text()).toContain('Invalid domain');
|
||||
});
|
||||
|
||||
test('deleted random subdomain address can be recreated manually', async ({ request }) => {
|
||||
await saveSubdomainMatchSetting(request, CREATE_ADDRESS_WORKER_URL, false);
|
||||
|
||||
const name = `subreuse${Date.now()}`;
|
||||
const firstCreate = await request.post(`${CREATE_ADDRESS_WORKER_URL}/api/new_address`, {
|
||||
data: { name, domain: TEST_DOMAIN, enableRandomSubdomain: true },
|
||||
});
|
||||
expect(firstCreate.ok()).toBe(true);
|
||||
const firstAddress = await firstCreate.json();
|
||||
const generatedDomain = firstAddress.address.split('@')[1];
|
||||
expect(generatedDomain).not.toBe(TEST_DOMAIN);
|
||||
|
||||
const firstDelete = await request.delete(`${CREATE_ADDRESS_WORKER_URL}/api/delete_address`, {
|
||||
headers: { Authorization: `Bearer ${firstAddress.jwt}` },
|
||||
});
|
||||
expect(firstDelete.ok()).toBe(true);
|
||||
|
||||
const secondCreate = await request.post(`${CREATE_ADDRESS_WORKER_URL}/api/new_address`, {
|
||||
data: { name, domain: generatedDomain },
|
||||
});
|
||||
expect(secondCreate.ok()).toBe(true);
|
||||
const secondAddress = await secondCreate.json();
|
||||
expect(secondAddress.address).toBe(firstAddress.address);
|
||||
|
||||
const secondDelete = await request.delete(`${CREATE_ADDRESS_WORKER_URL}/api/delete_address`, {
|
||||
headers: { Authorization: `Bearer ${secondAddress.jwt}` },
|
||||
});
|
||||
expect(secondDelete.ok()).toBe(true);
|
||||
});
|
||||
|
||||
test('env false works as hard kill switch even if admin setting is enabled', async ({ request }) => {
|
||||
test.skip(!WORKER_URL_ENV_OFF, 'WORKER_URL_ENV_OFF is not configured');
|
||||
|
||||
await saveSubdomainMatchSetting(request, WORKER_URL_ENV_OFF, true);
|
||||
|
||||
const res = await request.post(`${WORKER_URL_ENV_OFF}/admin/new_address`, {
|
||||
data: { name: `subdomain-env-off-${Date.now()}`, domain: SUBDOMAIN },
|
||||
data: { name: `subdomain-env-off-${Date.now()}`, domain: RANDOM_SUBDOMAIN },
|
||||
});
|
||||
expect(res.ok()).toBe(false);
|
||||
expect(await res.text()).toContain('Invalid domain');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { FRONTEND_URL, TEST_DOMAIN, deleteAddress } from '../../fixtures/test-helpers';
|
||||
|
||||
test('create an address with a custom subdomain from the UI', async ({ page, request }) => {
|
||||
let jwt: string | undefined;
|
||||
|
||||
try {
|
||||
await page.goto(`${FRONTEND_URL}/en/`);
|
||||
await page.getByRole('button', { name: 'Create Email Address' }).click();
|
||||
|
||||
const name = `subui${Date.now()}`;
|
||||
const createForm = page.locator('.n-tab-pane:visible form');
|
||||
await createForm.locator('.n-input-group .n-input input').fill(name);
|
||||
|
||||
const domainSelect = createForm.locator('.n-input-group .n-select');
|
||||
await expect(domainSelect.locator('input')).toHaveCount(0);
|
||||
|
||||
const normalSubdomain = createForm.getByRole('radio', { name: 'Normal Domain' });
|
||||
const randomSubdomain = createForm.getByRole('radio', { name: 'Use Random Subdomain' });
|
||||
const customSubdomain = createForm.getByRole('radio', { name: 'Use Custom Subdomain' });
|
||||
|
||||
await expect(normalSubdomain).toBeChecked();
|
||||
await createForm.getByText('Use Random Subdomain', { exact: true }).click();
|
||||
await expect(normalSubdomain).not.toBeChecked();
|
||||
await expect(randomSubdomain).toBeChecked();
|
||||
await expect(customSubdomain).not.toBeChecked();
|
||||
|
||||
await createForm.getByText('Use Custom Subdomain', { exact: true }).click();
|
||||
await expect(randomSubdomain).not.toBeChecked();
|
||||
await expect(customSubdomain).toBeChecked();
|
||||
|
||||
await createForm.getByText('Use Random Subdomain', { exact: true }).click();
|
||||
await expect(randomSubdomain).toBeChecked();
|
||||
await expect(customSubdomain).not.toBeChecked();
|
||||
|
||||
await createForm.getByText('Use Custom Subdomain', { exact: true }).click();
|
||||
await createForm.locator('.n-input-group:visible .n-input input').last().fill('team');
|
||||
|
||||
await createForm.getByRole('button', { name: 'Create Email Address' }).click();
|
||||
|
||||
const domain = `team.${TEST_DOMAIN}`;
|
||||
const address = `tmp${name}@${domain}`;
|
||||
await expect(page.locator('code').getByText(address, { exact: true })).toBeVisible();
|
||||
await page.waitForFunction(() => Boolean(localStorage.getItem('jwt')));
|
||||
jwt = await page.evaluate(() => localStorage.getItem('jwt') || undefined);
|
||||
expect(jwt).toBeTruthy();
|
||||
} finally {
|
||||
if (jwt) await deleteAddress(request, jwt);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { FRONTEND_URL, WORKER_URL } from '../../fixtures/test-helpers';
|
||||
|
||||
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
|
||||
|
||||
test('persists the selected D1 plan and restores it after reload', async ({ page, request }) => {
|
||||
const seedResponse = await request.post(`${WORKER_URL}/admin/config`, {
|
||||
headers: ADMIN_HEADERS,
|
||||
data: { key: 'd1_storage_plan', value: 'free' },
|
||||
});
|
||||
expect(seedResponse.ok()).toBe(true);
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('adminAuth', 'e2e-admin-pass');
|
||||
sessionStorage.setItem('adminTab', 'qucickSetup');
|
||||
});
|
||||
await page.goto(`${FRONTEND_URL}/en/admin`);
|
||||
|
||||
const storagePanel = page.locator('.storage-panel');
|
||||
const planSelect = storagePanel.locator('.plan-select .n-select');
|
||||
|
||||
await expect(storagePanel.getByText('Current Database Size', { exact: true })).toBeVisible();
|
||||
await expect(storagePanel.getByText('Database Capacity Limit', { exact: true })).toBeVisible();
|
||||
await expect(storagePanel.getByText('Capacity Usage', { exact: true })).toBeVisible();
|
||||
await expect(planSelect).toContainText('Free');
|
||||
|
||||
await planSelect.click();
|
||||
await page.locator('.n-base-select-option').filter({ hasText: 'Workers Paid' }).click();
|
||||
|
||||
await expect(page.getByText('Workers plan saved')).toBeVisible();
|
||||
await expect(storagePanel).toContainText('10.0 GB');
|
||||
|
||||
await page.reload();
|
||||
|
||||
await expect(planSelect).toContainText('Workers Paid');
|
||||
await expect(storagePanel).toContainText('10.0 GB');
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -62,8 +62,8 @@ test.describe('Passkey Browser Flow', () => {
|
||||
// Wait for user settings to load (shows user email)
|
||||
await expect(page.getByText(TEST_USER_EMAIL)).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// === Step 2: Click "User Account Settings" tab ===
|
||||
await page.getByText('User Account Settings').click();
|
||||
// === Step 2: Click "User Settings" tab ===
|
||||
await page.getByText('User Settings').click();
|
||||
|
||||
// === Step 3: Create a passkey ===
|
||||
await page.getByRole('button', { name: 'Create Passkey' }).click();
|
||||
|
||||
@@ -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.0",
|
||||
"version": "1.12.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -28,16 +28,16 @@
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"axios": "^1.19.0",
|
||||
"dompurify": "^3.4.13",
|
||||
"axios": "^1.20.0",
|
||||
"dompurify": "^3.4.15",
|
||||
"jszip": "^3.10.1",
|
||||
"mail-parser-wasm": "^0.2.2",
|
||||
"naive-ui": "^2.44.1",
|
||||
"postal-mime": "^2.7.5",
|
||||
"naive-ui": "^2.45.3",
|
||||
"postal-mime": "^2.7.6",
|
||||
"vooks": "^0.2.12",
|
||||
"vue": "^3.5.41",
|
||||
"vue": "^3.5.42",
|
||||
"vue-clipboard3": "^2.0.0",
|
||||
"vue-i18n": "^11.4.8",
|
||||
"vue-i18n": "^11.4.10",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -50,10 +50,10 @@
|
||||
"vite": "^7.3.6",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vite-plugin-wasm": "^3.6.0",
|
||||
"vitest": "^4.1.10",
|
||||
"vitest": "^4.1.11",
|
||||
"workbox-build": "^7.4.1",
|
||||
"workbox-window": "^7.4.1",
|
||||
"wrangler": "^4.119.0"
|
||||
"wrangler": "^4.129.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||
}
|
||||
|
||||
Generated
+791
-523
File diff suppressed because it is too large
Load Diff
+12
-10
@@ -3,7 +3,7 @@ import {
|
||||
darkTheme,
|
||||
} from 'naive-ui'
|
||||
import { computed, onMounted, watchEffect } from 'vue'
|
||||
import { useScript } from '@unhead/vue'
|
||||
import { useHead } from '@unhead/vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalState } from './store'
|
||||
import { useIsMobile } from './utils/composables'
|
||||
@@ -12,12 +12,13 @@ import Footer from './views/Footer.vue';
|
||||
import { api } from './api'
|
||||
import { getNaiveLocaleConfig } from './i18n/naive-locale'
|
||||
import { DEFAULT_LOCALE, isSupportedLocale } from './i18n/utils'
|
||||
import { APP_CONFIG } from './config'
|
||||
|
||||
const {
|
||||
isDark, loading, useSideMargin, telegramApp, isTelegram
|
||||
} = useGlobalState()
|
||||
const adClient = import.meta.env.VITE_GOOGLE_AD_CLIENT;
|
||||
const adSlot = import.meta.env.VITE_GOOGLE_AD_SLOT;
|
||||
const adClient = APP_CONFIG.GOOGLE_AD_CLIENT;
|
||||
const adSlot = APP_CONFIG.GOOGLE_AD_SLOT;
|
||||
const { locale } = useI18n({ useScope: 'global' });
|
||||
const theme = computed(() => isDark.value ? darkTheme : null)
|
||||
const localeConfig = computed(() => getNaiveLocaleConfig(isSupportedLocale(locale.value) ? locale.value : DEFAULT_LOCALE))
|
||||
@@ -31,12 +32,13 @@ watchEffect(() => {
|
||||
document.documentElement.lang = isSupportedLocale(locale.value) ? locale.value : DEFAULT_LOCALE
|
||||
})
|
||||
|
||||
// Load Google Ad script at top level (not inside onMounted)
|
||||
if (showAd.value) {
|
||||
useScript({
|
||||
src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`,
|
||||
async: true,
|
||||
crossorigin: "anonymous",
|
||||
useHead({
|
||||
script: [{
|
||||
src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`,
|
||||
async: true,
|
||||
crossorigin: 'anonymous',
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -47,7 +49,7 @@ onMounted(async () => {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
const token = import.meta.env.VITE_CF_WEB_ANALY_TOKEN;
|
||||
const token = APP_CONFIG.CF_WEB_ANALY_TOKEN;
|
||||
|
||||
const exist = document.querySelector('script[src="https://static.cloudflareinsights.com/beacon.min.js"]') !== null
|
||||
if (token && !exist) {
|
||||
@@ -66,7 +68,7 @@ onMounted(async () => {
|
||||
|
||||
|
||||
// check if telegram is enabled
|
||||
const enableTelegram = import.meta.env.VITE_IS_TELEGRAM;
|
||||
const enableTelegram = APP_CONFIG.IS_TELEGRAM;
|
||||
if (
|
||||
(typeof enableTelegram === 'boolean' && enableTelegram === true)
|
||||
||
|
||||
|
||||
@@ -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,14 +1,44 @@
|
||||
export const deMessages = {
|
||||
"views.index.SendMail.balanceUnavailable": "Kein Sendeguthaben für diese Adresse",
|
||||
"views.index.SendMail.composeMail": "E-Mail verfassen",
|
||||
"views.index.SendMail.contentPlaceholder": "Nachricht schreiben...",
|
||||
"views.index.SendMail.draftSaved": "Entwurf in diesem Browser gespeichert",
|
||||
"views.index.SendMail.recipientAddress": "Empfängeradresse",
|
||||
"views.index.SendMail.recipientName": "Empfängername (optional)",
|
||||
"views.index.SendMail.senderAddress": "Absenderadresse",
|
||||
"views.index.SendMail.senderName": "Absendername (optional)",
|
||||
"views.admin.SendMail.adminComposeTip": "Von einer konfigurierten E-Mail-Adresse senden",
|
||||
"views.admin.SendMail.composeMail": "E-Mail verfassen",
|
||||
"views.admin.SendMail.contentPlaceholder": "Nachricht schreiben...",
|
||||
"views.admin.SendMail.draftSaved": "Entwurf in diesem Browser gespeichert",
|
||||
"views.admin.SendMail.recipientAddress": "Empfängeradresse",
|
||||
"views.admin.SendMail.recipientName": "Empfängername (optional)",
|
||||
"views.admin.SendMail.senderAddress": "Absenderadresse",
|
||||
"views.admin.SendMail.senderName": "Absendername (optional)",
|
||||
"views.admin.DatabaseManager.current_database_size": "Aktuelle Datenbankgröße",
|
||||
"views.admin.DatabaseManager.free_plan": "Free",
|
||||
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
|
||||
"views.admin.DatabaseManager.plan": "Workers-Tarif",
|
||||
"views.admin.DatabaseManager.plan_placeholder": "Cloudflare-Workers-Tarif auswählen",
|
||||
"views.admin.DatabaseManager.planSaved": "Workers-Tarif gespeichert",
|
||||
"views.admin.DatabaseManager.single_database_limit": "Datenbank-Kapazitätslimit",
|
||||
"views.admin.DatabaseManager.storage_description": "Vergleiche die aktuelle Datenbankgröße mit den Tariflimits.",
|
||||
"views.admin.DatabaseManager.storage_tip": "Die Auslastung wird anhand der aktuellen Datenbankgröße und des gewählten Tariflimits berechnet.",
|
||||
"views.admin.DatabaseManager.storage_title": "D1-Speicherkapazität",
|
||||
"views.admin.DatabaseManager.storage_usage": "Kapazitätsauslastung",
|
||||
"views.admin.DatabaseManager.unavailable": "Nicht verfügbar",
|
||||
"views.index.SimpleIndex.mailCount": "{current} / {total} E-Mails",
|
||||
"views.admin.Statistics.activeAddressCount30days": "Aktive Adressanzahl in 30 Tagen",
|
||||
"views.admin.Statistics.activeAddressCount7days": "Aktive Adressanzahl in 7 Tagen",
|
||||
"views.Index.about": "Über",
|
||||
"views.Admin.about": "Über",
|
||||
"views.Header.accessHeader": "Zugangspasswort",
|
||||
"views.Admin.account": "Konto",
|
||||
"views.Index.accountSettings": "Kontoeinstellungen",
|
||||
"views.Admin.account_settings": "Kontoeinstellungen",
|
||||
"views.index.SimpleIndex.accountSettings": "Kontoeinstellungen",
|
||||
"views.Header.admin": "Admin-Konsole",
|
||||
"views.Admin.mailbox_management": "Postfachverwaltung",
|
||||
"views.Admin.mailbox_list": "Postfächer",
|
||||
"views.Index.mailboxSettings": "Postfacheinstellungen",
|
||||
"views.Admin.mailbox_settings": "Globale Postfacheinstellungen",
|
||||
"views.index.SimpleIndex.mailboxSettings": "Postfacheinstellungen",
|
||||
"views.index.Attachment.action": "Aktion",
|
||||
"views.admin.SenderAccess.action": "Aktion",
|
||||
"views.user.UserSettings.actions": "Aktionen",
|
||||
@@ -22,7 +52,7 @@ export const deMessages = {
|
||||
"views.admin.Maintenance.customSqlTip": "Füge benutzerdefinierte DELETE-SQL-Anweisungen für die geplante Bereinigung hinzu. Pro Eintrag ist nur eine DELETE-Anweisung erlaubt.",
|
||||
"views.admin.Maintenance.addCustomSql": "Benutzerdefinierte SQL hinzufügen",
|
||||
"views.admin.UserOauth2Settings.addOauth2": "Oauth2 hinzufügen",
|
||||
"components.AddressSelect.address": "Adresse",
|
||||
"components.AddressSelect.selectMailbox": "Postfach auswählen",
|
||||
"views.user.AddressManagement.address": "Adresse",
|
||||
"views.index.TelegramAddress.address": "Adresse",
|
||||
"views.admin.SenderAccess.address": "Adresse",
|
||||
@@ -58,10 +88,10 @@ export const deMessages = {
|
||||
"views.admin.Account.clearSentItemsTip": "Möchtest du wirklich die gesendeten Elemente dieser E-Mail-Adresse leeren?",
|
||||
"views.admin.Account.multiDeleteTip": "Möchtest du wirklich die ausgewählten Adressen löschen?",
|
||||
"views.index.Attachment.deleteConfirm": "Möchtest du wirklich diesen Anhang löschen?",
|
||||
"views.admin.Account.deleteTip": "Möchtest du wirklich diese E-Mail löschen?",
|
||||
"views.admin.Account.deleteTip": "Möchtest du wirklich dieses Postfach löschen?",
|
||||
"views.admin.SenderAccess.deleteTip": "Möchtest du dies wirklich löschen?",
|
||||
"views.index.AccountSettings.deleteAccountConfirm": "Möchtest du wirklich dein Konto und alle zugehörigen E-Mails löschen?",
|
||||
"views.index.AccountSettings.logoutConfirm": "Möchtest du dich wirklich abmelden?",
|
||||
"views.index.AccountSettings.deleteAccountConfirm": "Möchtest du diese E-Mail-Adresse und alle zugehörigen E-Mails wirklich löschen? Dein Benutzerkonto wird dabei nicht gelöscht.",
|
||||
"views.index.AccountSettings.logoutConfirm": "Möchtest du dich wirklich von der aktuellen E-Mail-Adresse abmelden?",
|
||||
"components.MailBox.deleteMailTip": "Möchtest du die E-Mail wirklich löschen?",
|
||||
"components.MailContentRenderer.deleteMailTip": "Möchtest du die E-Mail wirklich löschen?",
|
||||
"components.SendBox.deleteMailTip": "Möchtest du die E-Mail wirklich löschen?",
|
||||
@@ -95,10 +125,10 @@ export const deMessages = {
|
||||
"views.admin.AccountSettings.cancel": "Abbrechen",
|
||||
"components.MailBox.cancelMultiAction": "Mehrfachaktion abbrechen",
|
||||
"components.SendBox.cancelMultiAction": "Mehrfachaktion abbrechen",
|
||||
"views.user.AddressManagement.changeMailAddress": "Adresse wechseln",
|
||||
"views.user.AddressManagement.openMailbox": "Postfach öffnen",
|
||||
"views.index.TelegramAddress.changeMailAddress": "Mailadresse ändern",
|
||||
"views.index.LocalAddress.changeMailAddress": "Mailadresse ändern",
|
||||
"views.index.AccountSettings.changePassword": "Passwort ändern",
|
||||
"views.index.AccountSettings.changePassword": "Adresspasswort ändern",
|
||||
"views.admin.UserManagement.changeRole": "Ändern Rolle",
|
||||
"components.SendBox.showCode": "Ansicht des Originalcodes umschalten",
|
||||
"views.admin.Telegram.status": "Status prüfen",
|
||||
@@ -134,8 +164,8 @@ export const deMessages = {
|
||||
"views.index.SimpleIndex.copyAddress": "Kopieren",
|
||||
"components.AiExtractInfo.copyFailed": "Kopieren fehlgeschlagen",
|
||||
"views.Footer.copyright": "Urheberrecht",
|
||||
"views.Admin.account_create": "Konto erstellen",
|
||||
"views.admin.CreateAccount.creatNewEmail": "Neue E-Mail erstellen",
|
||||
"views.Admin.mailbox_create": "E-Mail-Adresse erstellen",
|
||||
"views.admin.CreateAccount.createEmailAddress": "E-Mail-Adresse erstellen",
|
||||
"views.common.Login.getNewEmail": "Neue E-Mail erstellen",
|
||||
"views.user.AddressManagement.create_or_bind": "Erstellen oder verknüpfen",
|
||||
"views.index.LocalAddress.create_or_bind": "Erstellen oder verknüpfen",
|
||||
@@ -170,8 +200,8 @@ export const deMessages = {
|
||||
"views.admin.UserManagement.delete": "Löschen",
|
||||
"views.admin.AccountSettings.delete_rule": "Löschen",
|
||||
"views.admin.Maintenance.deleteCustomSql": "Löschen",
|
||||
"views.index.AccountSettings.deleteAccount": "Konto löschen",
|
||||
"views.admin.Account.deleteAccount": "Konto löschen",
|
||||
"views.index.AccountSettings.deleteMailbox": "Postfach löschen",
|
||||
"views.admin.Account.deleteMailbox": "Postfach löschen",
|
||||
"views.user.UserSettings.deletePasskey": "Passkey löschen",
|
||||
"views.admin.AccountSettings.delete_success": "Erfolgreich gelöscht",
|
||||
"views.admin.UserManagement.deleteUser": "Benutzer löschen",
|
||||
@@ -242,7 +272,7 @@ export const deMessages = {
|
||||
"views.admin.Telegram.globalMailPushList": "Globale Mail-Push-Chat-ID-Liste",
|
||||
"views.common.Appearance.globalTabplacement": "Globale Tab-Position",
|
||||
"views.common.Login.help": "Hilfe",
|
||||
"views.Header.home": "Startseite",
|
||||
"views.Header.mailbox": "Postfach",
|
||||
"views.index.SendMail.html": "HTML",
|
||||
"views.admin.SendMail.html": "HTML",
|
||||
"views.admin.UserOauth2Settings.icon": "Icon (SVG, bitte nur aus vertrauenswürdiger Quelle)",
|
||||
@@ -282,7 +312,7 @@ export const deMessages = {
|
||||
"views.user.UserSettings.logout": "Abmelden",
|
||||
"views.user.BindAddress.logout": "Abmelden",
|
||||
"views.Admin.logout": "Abmelden",
|
||||
"views.index.AccountSettings.logout": "Abmelden",
|
||||
"views.index.AccountSettings.logout": "Von aktueller E-Mail-Adresse abmelden",
|
||||
"views.Admin.logoutSuccess": "Erfolgreich abgemeldet",
|
||||
"views.admin.UserOauth2Settings.mailAllowList": "E-Mail-Adressfreigabeliste",
|
||||
"views.admin.UserSettings.mailAllowList": "E-Mail-Adressfreigabeliste",
|
||||
@@ -291,7 +321,7 @@ export const deMessages = {
|
||||
"views.admin.Account.addressCredential": "E-Mail-Adresszugangsdaten",
|
||||
"views.admin.CreateAccount.addressCredential": "E-Mail-Adresszugangsdaten",
|
||||
"views.index.AddressBar.fetchAddressError": "Die E-Mail-Zugangsdaten sind ungültig oder das Konto existiert nicht. Es kann auch ein Netzwerkproblem sein. Bitte später erneut versuchen.",
|
||||
"views.Index.mailbox": "Posteingang",
|
||||
"views.Index.inbox": "Posteingang",
|
||||
"views.User.user_mail_box_tab": "Posteingang",
|
||||
"views.user.AddressManagement.mail_count": "E-Mail-Anzahl",
|
||||
"views.admin.Account.mail_count": "E-Mail-Anzahl",
|
||||
@@ -321,11 +351,11 @@ export const deMessages = {
|
||||
"views.admin.Account.multiClearInbox": "Mehrere Posteingänge leeren",
|
||||
"views.admin.Account.multiClearSentItems": "Mehrere gesendete Elemente leeren",
|
||||
"views.admin.Account.multiDelete": "Mehrfach löschen",
|
||||
"views.user.AddressManagement.name": "Name",
|
||||
"views.user.AddressManagement.emailAddress": "E-Mail-Adresse",
|
||||
"views.index.AutoReply.name": "Name",
|
||||
"views.admin.Account.name": "Name",
|
||||
"views.admin.Account.emailAddress": "E-Mail-Adresse",
|
||||
"views.admin.UserOauth2Settings.name": "Name",
|
||||
"views.admin.UserAddressManagement.name": "Name",
|
||||
"views.admin.UserAddressManagement.emailAddress": "E-Mail-Adresse",
|
||||
"views.admin.Maintenance.sqlName": "Name",
|
||||
"views.index.AccountSettings.newPassword": "Neues Passwort",
|
||||
"views.admin.Account.newPassword": "Neues Passwort",
|
||||
@@ -334,7 +364,8 @@ export const deMessages = {
|
||||
"views.admin.AccountSettings.noLimitSendAddressList": "Adressliste ohne Guthabenlimit",
|
||||
"views.index.SimpleIndex.noMails": "Keine E-Mails gefunden",
|
||||
"views.admin.RoleAddressConfig.noRolesAvailable": "In der Systemkonfiguration sind keine Rollen verfügbar",
|
||||
"views.index.SendMail.requestAccessTip": "Noch kein Sendeguthaben vorhanden. Wenn der Administrator ein Standardguthaben aktiviert hat, wird es automatisch zugewiesen; andernfalls Zugriff anfordern oder den Administrator kontaktieren.",
|
||||
"views.index.SendMail.requestAccessTip": "Sendeberechtigung und Guthaben werden für jede E-Mail-Adresse separat verwaltet. Die aktuelle Adresse {address} hat kein verfügbares Guthaben. Fordere die Berechtigung für diese Adresse an oder kontaktiere den Administrator.",
|
||||
"views.index.SendMail.requestSuccess": "Sendeberechtigung für die aktuelle Adresse angefordert",
|
||||
"components.SendBox.emptySent": "Keine gesendeten E-Mails",
|
||||
"views.admin.RoleAddressConfig.notConfigured": "Nicht konfiguriert (globale Einstellungen verwenden)",
|
||||
"views.Admin.userOauth2Settings": "OAuth2-Einstellungen",
|
||||
@@ -373,6 +404,7 @@ export const deMessages = {
|
||||
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Bitte den neuen Passkey-Namen eingeben",
|
||||
"views.user.UserSettings.passkeyNamePlaceholder": "Bitte den Passkey-Namen eingeben oder leer lassen, um einen zufälligen zu erzeugen",
|
||||
"views.admin.CreateAccount.fillInAllFields": "Bitte alle Felder ausfüllen",
|
||||
"views.admin.CreateAccount.generateName": "Zufälligen Namen erzeugen",
|
||||
"views.admin.SendBox.queryTip": "Bitte die abzufragende Adresse eingeben; leer lassen für alle",
|
||||
"views.user.UserLogin.pleaseInputCode": "Bitte den Code eingeben",
|
||||
"views.admin.UserManagement.pleaseInput": "Bitte alle erforderlichen Informationen eingeben",
|
||||
@@ -465,7 +497,7 @@ export const deMessages = {
|
||||
"views.admin.UserAddressManagement.send_count": "Sendeanzahl",
|
||||
"views.Index.sendmail": "E-Mail senden",
|
||||
"views.Admin.sendMail": "E-Mail senden",
|
||||
"views.index.SendMail.send_balance": "Verbleibendes Sendeguthaben",
|
||||
"views.index.SendMail.send_balance": "Sendeguthaben der aktuellen Adresse",
|
||||
"views.admin.Statistics.sendMailCount": "Anzahl gesendeter E-Mails",
|
||||
"views.admin.AccountSettings.send_mail_limit": "Sende-Limit",
|
||||
"views.user.UserLogin.sendVerificationCode": "Bestätigungscode senden",
|
||||
@@ -526,7 +558,7 @@ export const deMessages = {
|
||||
"views.index.LocalAddress.tip": "Diese Adressen werden in deinem Browser gespeichert und können verloren gehen, wenn du den Browser-Cache leerst.",
|
||||
"views.admin.UserOauth2Settings.tip": "Drittanbieter-Login verwendet automatisch die E-Mail-Adresse des Benutzers zur Registrierung eines Kontos (dieselbe E-Mail gilt als dasselbe Konto). Das Konto entspricht dem regulär registrierten Konto, und das Passwort kann auch über „Passwort vergessen“ gesetzt werden.",
|
||||
"views.admin.AccountSettings.send_mail_limit_tip": "Dies gilt für alle Sendekanäle. Verwende -1 für unbegrenzt und 0, um das Senden zu blockieren.",
|
||||
"views.admin.AccountSettings.create_address_subdomain_match_note": "Dies unterscheidet sich von RANDOM_SUBDOMAIN_DOMAINS: Dieser Schalter erlaubt API-Aufrufern, benutzerdefinierte Subdomains direkt anzugeben, während die zufällige Subdomain nur bei der Erstellung automatisch erzeugt wird.",
|
||||
"views.admin.AccountSettings.create_address_subdomain_match_note": "RANDOM_SUBDOMAIN_DOMAINS erlaubt bereits zufällige oder manuelle Subdomains für gelistete Basisdomains. Dieser Schalter erlaubt API-Aufrufern zusätzlich Subdomains unter anderen erlaubten Basisdomains.",
|
||||
"views.index.SendMail.tooLarge": "Datei zu groß; bitte eine Datei unter 1 MB hochladen.",
|
||||
"views.admin.SendMail.tooLarge": "Datei zu groß; bitte eine Datei unter 1 MB hochladen.",
|
||||
"views.common.Appearance.top": "oben",
|
||||
@@ -555,13 +587,13 @@ export const deMessages = {
|
||||
"views.admin.UserOauth2Settings.userEmailFormatTip": "Regex verwenden, um die E-Mail umzuwandeln. Beispiel: ^(.+)@old\\.com$ mit $1@new.com",
|
||||
"views.common.Appearance.useSimpleIndex": "Einfachen Index verwenden",
|
||||
"views.common.Appearance.useUTCDate": "UTC-Datum verwenden",
|
||||
"views.Header.user": "Benutzer",
|
||||
"views.Header.userCenter": "Benutzerbereich",
|
||||
"views.Admin.user": "Benutzer",
|
||||
"components.AddressSelect.userAddresses": "Benutzeradressen",
|
||||
"views.Admin.loginViaUserAdmin": "Benutzer-Admin-Berechtigung",
|
||||
"components.AddressSelect.linkedMailboxes": "Verknüpfte Postfächer",
|
||||
"views.Admin.loginViaUserAdmin": "Benutzerkonto (Administratorrolle)",
|
||||
"views.admin.Statistics.userCount": "Benutzeranzahl",
|
||||
"views.admin.UserManagement.user_email": "Benutzer-E-Mail",
|
||||
"views.index.AddressBar.userLogin": "Benutzeranmeldung",
|
||||
"views.index.AddressBar.userCenter": "Benutzerbereich",
|
||||
"views.Admin.user_management": "Benutzerverwaltung",
|
||||
"views.Admin.user_settings": "Benutzereinstellungen",
|
||||
"views.User.user_settings": "Benutzereinstellungen",
|
||||
@@ -582,6 +614,10 @@ export const deMessages = {
|
||||
"views.admin.AiExtractSettings.disabledTip": "Wenn deaktiviert, verarbeitet die KI-Extraktion alle E-Mail-Adressen",
|
||||
"views.admin.AiExtractSettings.enableAllowListTip": "Wenn aktiviert, verarbeitet die KI-Extraktion nur E-Mails an Adressen auf der Freigabeliste",
|
||||
"views.admin.CreateAccount.randomSubdomainTip": "Wenn aktiviert, verwendet die erstellte Adresse eine zufällige Subdomain. Nur für den Empfang empfohlen. Erfordert einen Wildcard-MX-DNS-Eintrag auf der Basisdomain — siehe die Dokumentation zu zufälligen Subdomains.",
|
||||
"views.admin.CreateAccount.enableCustomSubdomain": "Benutzerdefinierte Subdomain verwenden",
|
||||
"views.admin.CreateAccount.normalSubdomain": "Normale Domain",
|
||||
"views.common.Login.enableCustomSubdomain": "Benutzerdefinierte Subdomain verwenden",
|
||||
"views.common.Login.normalSubdomain": "Normale Domain",
|
||||
"views.common.Login.randomSubdomainTip": "Wenn aktiviert, verwendet die erstellte Adresse eine zufällige Subdomain. Nur für den Empfang empfohlen. Erfordert einen Wildcard-MX-DNS-Eintrag auf der Basisdomain — siehe die Dokumentation zu zufälligen Subdomains.",
|
||||
"views.admin.AiExtractSettings.allowListTip": "Der Platzhalter * passt auf beliebige Zeichen; z. B. passt *{'@'}example.com auf alle Adressen der Domain example.com",
|
||||
"views.Admin.workerconfig": "Worker-Konfiguration",
|
||||
@@ -617,5 +653,15 @@ export const deMessages = {
|
||||
"components.AddressCredentialModal.starttls": "STARTTLS",
|
||||
"components.AddressCredentialModal.tip": "Verwende diese Zugangsdaten nur mit Clients und Agents, denen du vertraust.",
|
||||
"components.AddressCredentialModal.title": "Adresszugangsdaten & Verbindungsmethoden",
|
||||
"components.AddressCredentialModal.username": "Benutzername"
|
||||
"components.AddressCredentialModal.username": "Benutzername",
|
||||
"views.User.send_mail": "E-Mail senden",
|
||||
"views.user.UserSendBox.noAddress": "Wähle eine verknüpfte E-Mail-Adresse aus",
|
||||
"views.user.UserSendBox.sendbox": "Gesendet",
|
||||
"components.MailContentRenderer.markAsRead": "Als gelesen markieren",
|
||||
"components.MailContentRenderer.markAsUnread": "Als ungelesen markieren",
|
||||
"components.MailContentRenderer.loadRemoteImages": "Bilder laden",
|
||||
"components.MailContentRenderer.remoteImagesBlocked": "{count} externe Ressourcen wurden zum Schutz deiner Privatsphäre blockiert",
|
||||
"views.user.AddressManagement.itemCount": "Gesamt",
|
||||
"views.index.SimpleIndex.readStatusUpdated": "Lesestatus aktualisiert",
|
||||
"views.common.Appearance.autoLoadRemoteImages": "Externe Bilder im E-Mail-Inhalt automatisch laden"
|
||||
}
|
||||
|
||||
@@ -1,14 +1,44 @@
|
||||
export const esMessages = {
|
||||
"views.index.SendMail.balanceUnavailable": "No hay saldo de envío para esta dirección",
|
||||
"views.index.SendMail.composeMail": "Redactar correo",
|
||||
"views.index.SendMail.contentPlaceholder": "Escribe tu mensaje...",
|
||||
"views.index.SendMail.draftSaved": "Borrador guardado en este navegador",
|
||||
"views.index.SendMail.recipientAddress": "Dirección del destinatario",
|
||||
"views.index.SendMail.recipientName": "Nombre del destinatario (opcional)",
|
||||
"views.index.SendMail.senderAddress": "Dirección del remitente",
|
||||
"views.index.SendMail.senderName": "Nombre del remitente (opcional)",
|
||||
"views.admin.SendMail.adminComposeTip": "Enviar desde una dirección de correo configurada",
|
||||
"views.admin.SendMail.composeMail": "Redactar correo",
|
||||
"views.admin.SendMail.contentPlaceholder": "Escribe tu mensaje...",
|
||||
"views.admin.SendMail.draftSaved": "Borrador guardado en este navegador",
|
||||
"views.admin.SendMail.recipientAddress": "Dirección del destinatario",
|
||||
"views.admin.SendMail.recipientName": "Nombre del destinatario (opcional)",
|
||||
"views.admin.SendMail.senderAddress": "Dirección del remitente",
|
||||
"views.admin.SendMail.senderName": "Nombre del remitente (opcional)",
|
||||
"views.admin.DatabaseManager.current_database_size": "Tamaño actual de la base de datos",
|
||||
"views.admin.DatabaseManager.free_plan": "Free",
|
||||
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
|
||||
"views.admin.DatabaseManager.plan": "Plan de Workers",
|
||||
"views.admin.DatabaseManager.plan_placeholder": "Selecciona tu plan de Cloudflare Workers",
|
||||
"views.admin.DatabaseManager.planSaved": "Plan de Workers guardado",
|
||||
"views.admin.DatabaseManager.single_database_limit": "Límite de capacidad de la base de datos",
|
||||
"views.admin.DatabaseManager.storage_description": "Compara el tamaño actual de la base de datos con los límites de tu plan.",
|
||||
"views.admin.DatabaseManager.storage_tip": "El uso se calcula con el tamaño actual de la base de datos y el límite del plan seleccionado.",
|
||||
"views.admin.DatabaseManager.storage_title": "Capacidad de almacenamiento D1",
|
||||
"views.admin.DatabaseManager.storage_usage": "Uso de capacidad",
|
||||
"views.admin.DatabaseManager.unavailable": "No disponible",
|
||||
"views.index.SimpleIndex.mailCount": "{current} / {total} correos",
|
||||
"views.admin.Statistics.activeAddressCount30days": "Cantidad de direcciones activas en 30 días",
|
||||
"views.admin.Statistics.activeAddressCount7days": "Cantidad de direcciones activas en 7 días",
|
||||
"views.Index.about": "Acerca de",
|
||||
"views.Admin.about": "Acerca de",
|
||||
"views.Header.accessHeader": "Contraseña de acceso",
|
||||
"views.Admin.account": "Cuenta",
|
||||
"views.Index.accountSettings": "Configuración de la cuenta",
|
||||
"views.Admin.account_settings": "Configuración de la cuenta",
|
||||
"views.index.SimpleIndex.accountSettings": "Configuración de la cuenta",
|
||||
"views.Header.admin": "Consola de administración",
|
||||
"views.Admin.mailbox_management": "Gestión de buzones",
|
||||
"views.Admin.mailbox_list": "Buzones",
|
||||
"views.Index.mailboxSettings": "Configuración del buzón",
|
||||
"views.Admin.mailbox_settings": "Configuración global de buzones",
|
||||
"views.index.SimpleIndex.mailboxSettings": "Configuración del buzón",
|
||||
"views.index.Attachment.action": "Acción",
|
||||
"views.admin.SenderAccess.action": "Acción",
|
||||
"views.user.UserSettings.actions": "Acciones",
|
||||
@@ -22,7 +52,7 @@ export const esMessages = {
|
||||
"views.admin.Maintenance.customSqlTip": "Añade sentencias SQL DELETE personalizadas para la limpieza programada. Solo se permite una sentencia DELETE por entrada.",
|
||||
"views.admin.Maintenance.addCustomSql": "Añadir SQL personalizada",
|
||||
"views.admin.UserOauth2Settings.addOauth2": "Añadir OAuth2",
|
||||
"components.AddressSelect.address": "Dirección",
|
||||
"components.AddressSelect.selectMailbox": "Seleccionar buzón",
|
||||
"views.user.AddressManagement.address": "Dirección",
|
||||
"views.index.TelegramAddress.address": "Dirección",
|
||||
"views.admin.SenderAccess.address": "Dirección",
|
||||
@@ -58,10 +88,10 @@ export const esMessages = {
|
||||
"views.admin.Account.clearSentItemsTip": "¿Seguro que quieres vaciar los enviados de este correo?",
|
||||
"views.admin.Account.multiDeleteTip": "¿Seguro que quieres eliminar las direcciones seleccionadas?",
|
||||
"views.index.Attachment.deleteConfirm": "¿Seguro que quieres eliminar este adjunto?",
|
||||
"views.admin.Account.deleteTip": "¿Seguro que quieres eliminar este correo?",
|
||||
"views.admin.Account.deleteTip": "¿Seguro que quieres eliminar este buzón?",
|
||||
"views.admin.SenderAccess.deleteTip": "¿Seguro que quieres eliminar esto?",
|
||||
"views.index.AccountSettings.deleteAccountConfirm": "¿Seguro que quieres eliminar tu cuenta y todos sus correos?",
|
||||
"views.index.AccountSettings.logoutConfirm": "¿Seguro que quieres cerrar sesión?",
|
||||
"views.index.AccountSettings.deleteAccountConfirm": "¿Seguro que quieres eliminar esta dirección de correo y todos sus correos? Esto no elimina tu cuenta de usuario.",
|
||||
"views.index.AccountSettings.logoutConfirm": "¿Seguro que quieres cerrar la sesión de la dirección de correo actual?",
|
||||
"components.MailBox.deleteMailTip": "¿Seguro que quieres eliminar el correo?",
|
||||
"components.MailContentRenderer.deleteMailTip": "¿Seguro que quieres eliminar el correo?",
|
||||
"components.SendBox.deleteMailTip": "¿Seguro que quieres eliminar el correo?",
|
||||
@@ -95,10 +125,10 @@ export const esMessages = {
|
||||
"views.admin.AccountSettings.cancel": "Cancelar",
|
||||
"components.MailBox.cancelMultiAction": "Cancelar selección múltiple",
|
||||
"components.SendBox.cancelMultiAction": "Cancelar selección múltiple",
|
||||
"views.user.AddressManagement.changeMailAddress": "Cambiar dirección",
|
||||
"views.user.AddressManagement.openMailbox": "Abrir buzón",
|
||||
"views.index.TelegramAddress.changeMailAddress": "Cambiar dirección de correo",
|
||||
"views.index.LocalAddress.changeMailAddress": "Cambiar dirección de correo",
|
||||
"views.index.AccountSettings.changePassword": "Cambiar contraseña",
|
||||
"views.index.AccountSettings.changePassword": "Cambiar contraseña de la dirección",
|
||||
"views.admin.UserManagement.changeRole": "Cambiar Rol",
|
||||
"components.SendBox.showCode": "Cambiar vista del código original",
|
||||
"views.admin.Telegram.status": "Ver estado",
|
||||
@@ -134,8 +164,8 @@ export const esMessages = {
|
||||
"views.index.SimpleIndex.copyAddress": "Copiar",
|
||||
"components.AiExtractInfo.copyFailed": "Error al copiar",
|
||||
"views.Footer.copyright": "Derechos de autor",
|
||||
"views.Admin.account_create": "Crear cuenta",
|
||||
"views.admin.CreateAccount.creatNewEmail": "Crear nuevo correo",
|
||||
"views.Admin.mailbox_create": "Crear dirección de correo",
|
||||
"views.admin.CreateAccount.createEmailAddress": "Crear dirección de correo",
|
||||
"views.common.Login.getNewEmail": "Crear nuevo correo",
|
||||
"views.user.AddressManagement.create_or_bind": "Crear o vincular",
|
||||
"views.index.LocalAddress.create_or_bind": "Crear o vincular",
|
||||
@@ -170,8 +200,8 @@ export const esMessages = {
|
||||
"views.admin.UserManagement.delete": "Eliminar",
|
||||
"views.admin.AccountSettings.delete_rule": "Eliminar",
|
||||
"views.admin.Maintenance.deleteCustomSql": "Eliminar",
|
||||
"views.index.AccountSettings.deleteAccount": "Eliminar cuenta",
|
||||
"views.admin.Account.deleteAccount": "Eliminar cuenta",
|
||||
"views.index.AccountSettings.deleteMailbox": "Eliminar buzón",
|
||||
"views.admin.Account.deleteMailbox": "Eliminar buzón",
|
||||
"views.user.UserSettings.deletePasskey": "Eliminar passkey",
|
||||
"views.admin.AccountSettings.delete_success": "Eliminado correctamente",
|
||||
"views.admin.UserManagement.deleteUser": "Eliminar usuario",
|
||||
@@ -242,7 +272,7 @@ export const esMessages = {
|
||||
"views.admin.Telegram.globalMailPushList": "Lista global de chat ID para envío de correos",
|
||||
"views.common.Appearance.globalTabplacement": "Posición global de pestañas",
|
||||
"views.common.Login.help": "Ayuda",
|
||||
"views.Header.home": "Inicio",
|
||||
"views.Header.mailbox": "Buzón",
|
||||
"views.index.SendMail.html": "HTML",
|
||||
"views.admin.SendMail.html": "HTML",
|
||||
"views.admin.UserOauth2Settings.icon": "Icono (SVG, asegúrate de que la fuente sea fiable)",
|
||||
@@ -282,7 +312,7 @@ export const esMessages = {
|
||||
"views.user.UserSettings.logout": "Cerrar sesión",
|
||||
"views.user.BindAddress.logout": "Cerrar sesión",
|
||||
"views.Admin.logout": "Cerrar sesión",
|
||||
"views.index.AccountSettings.logout": "Cerrar sesión",
|
||||
"views.index.AccountSettings.logout": "Cerrar sesión de la dirección de correo actual",
|
||||
"views.Admin.logoutSuccess": "Sesión cerrada correctamente",
|
||||
"views.admin.UserOauth2Settings.mailAllowList": "Lista blanca de direcciones de correo",
|
||||
"views.admin.UserSettings.mailAllowList": "Lista blanca de direcciones de correo",
|
||||
@@ -291,7 +321,7 @@ export const esMessages = {
|
||||
"views.admin.Account.addressCredential": "Credencial de dirección de correo",
|
||||
"views.admin.CreateAccount.addressCredential": "Credencial de dirección de correo",
|
||||
"views.index.AddressBar.fetchAddressError": "La credencial del correo no es válida o la cuenta no existe; también puede ser un problema de red. Inténtalo más tarde.",
|
||||
"views.Index.mailbox": "Bandeja de entrada",
|
||||
"views.Index.inbox": "Bandeja de entrada",
|
||||
"views.User.user_mail_box_tab": "Bandeja de entrada",
|
||||
"views.user.AddressManagement.mail_count": "Cantidad de correos",
|
||||
"views.admin.Account.mail_count": "Cantidad de correos",
|
||||
@@ -321,11 +351,11 @@ export const esMessages = {
|
||||
"views.admin.Account.multiClearInbox": "Vaciar varias bandejas",
|
||||
"views.admin.Account.multiClearSentItems": "Vaciar varios enviados",
|
||||
"views.admin.Account.multiDelete": "Eliminar en lote",
|
||||
"views.user.AddressManagement.name": "Nombre",
|
||||
"views.user.AddressManagement.emailAddress": "Dirección de correo",
|
||||
"views.index.AutoReply.name": "Nombre",
|
||||
"views.admin.Account.name": "Nombre",
|
||||
"views.admin.Account.emailAddress": "Dirección de correo",
|
||||
"views.admin.UserOauth2Settings.name": "Nombre",
|
||||
"views.admin.UserAddressManagement.name": "Nombre",
|
||||
"views.admin.UserAddressManagement.emailAddress": "Dirección de correo",
|
||||
"views.admin.Maintenance.sqlName": "Nombre",
|
||||
"views.index.AccountSettings.newPassword": "Nueva contraseña",
|
||||
"views.admin.Account.newPassword": "Nueva contraseña",
|
||||
@@ -334,7 +364,8 @@ export const esMessages = {
|
||||
"views.admin.AccountSettings.noLimitSendAddressList": "Lista de direcciones sin límite de saldo",
|
||||
"views.index.SimpleIndex.noMails": "No se encontraron correos",
|
||||
"views.admin.RoleAddressConfig.noRolesAvailable": "No hay roles disponibles en la configuración del sistema",
|
||||
"views.index.SendMail.requestAccessTip": "Todavía no hay saldo de envío. Si el administrador activó un saldo por defecto, se asignará automáticamente; si no, solicita acceso o contacta con el administrador.",
|
||||
"views.index.SendMail.requestAccessTip": "El permiso y el saldo de envío se gestionan por separado para cada dirección. La dirección actual, {address}, no tiene saldo disponible. Solicita permiso para esta dirección o contacta con el administrador.",
|
||||
"views.index.SendMail.requestSuccess": "Permiso de envío solicitado para la dirección actual",
|
||||
"components.SendBox.emptySent": "No hay correos enviados",
|
||||
"views.admin.RoleAddressConfig.notConfigured": "No configurado (usar configuración global)",
|
||||
"views.Admin.userOauth2Settings": "Configuración de OAuth2",
|
||||
@@ -373,6 +404,7 @@ export const esMessages = {
|
||||
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Introduce el nuevo nombre de la passkey",
|
||||
"views.user.UserSettings.passkeyNamePlaceholder": "Introduce el nombre de la passkey o déjalo vacío para generarlo aleatoriamente",
|
||||
"views.admin.CreateAccount.fillInAllFields": "Rellena todos los campos",
|
||||
"views.admin.CreateAccount.generateName": "Generar nombre aleatorio",
|
||||
"views.admin.SendBox.queryTip": "Introduce la dirección a consultar; vacío para todas",
|
||||
"views.user.UserLogin.pleaseInputCode": "Introduce el código",
|
||||
"views.admin.UserManagement.pleaseInput": "Introduce la información completa",
|
||||
@@ -465,7 +497,7 @@ export const esMessages = {
|
||||
"views.admin.UserAddressManagement.send_count": "Cantidad enviada",
|
||||
"views.Index.sendmail": "Enviar correo",
|
||||
"views.Admin.sendMail": "Enviar correo",
|
||||
"views.index.SendMail.send_balance": "Saldo restante de envío",
|
||||
"views.index.SendMail.send_balance": "Saldo de envío de la dirección actual",
|
||||
"views.admin.Statistics.sendMailCount": "Cantidad de correos enviados",
|
||||
"views.admin.AccountSettings.send_mail_limit": "Límite de envío",
|
||||
"views.user.UserLogin.sendVerificationCode": "Enviar código de verificación",
|
||||
@@ -526,7 +558,7 @@ export const esMessages = {
|
||||
"views.index.LocalAddress.tip": "Estas direcciones se guardan en tu navegador y podrían perderse si borras la caché.",
|
||||
"views.admin.UserOauth2Settings.tip": "El inicio de sesión de terceros usará automáticamente el correo del usuario para registrar una cuenta (el mismo correo se considera la misma cuenta). También puedes establecer la contraseña con “olvidé mi contraseña”.",
|
||||
"views.admin.AccountSettings.send_mail_limit_tip": "Se aplica a todos los canales de envío. Usa -1 para ilimitado y 0 para bloquear el envío.",
|
||||
"views.admin.AccountSettings.create_address_subdomain_match_note": "Esto es diferente de RANDOM_SUBDOMAIN_DOMAINS: este interruptor permite indicar subdominios personalizados; el subdominio aleatorio solo genera uno al crear.",
|
||||
"views.admin.AccountSettings.create_address_subdomain_match_note": "RANDOM_SUBDOMAIN_DOMAINS ya permite subdominios aleatorios o manuales en los dominios base listados. Este interruptor permite además que la API especifique subdominios en otros dominios base permitidos.",
|
||||
"views.index.SendMail.tooLarge": "Archivo demasiado grande; sube uno de menos de 1 MB.",
|
||||
"views.admin.SendMail.tooLarge": "Archivo demasiado grande; sube uno de menos de 1 MB.",
|
||||
"views.common.Appearance.top": "arriba",
|
||||
@@ -555,13 +587,13 @@ export const esMessages = {
|
||||
"views.admin.UserOauth2Settings.userEmailFormatTip": "Usa regex para transformar el correo. Ejemplo: ^(.+)@old\\.com$ con $1@new.com",
|
||||
"views.common.Appearance.useSimpleIndex": "Usar índice simple",
|
||||
"views.common.Appearance.useUTCDate": "Usar fecha UTC",
|
||||
"views.Header.user": "Usuario",
|
||||
"views.Header.userCenter": "Centro de usuario",
|
||||
"views.Admin.user": "Usuario",
|
||||
"components.AddressSelect.userAddresses": "Direcciones del usuario",
|
||||
"views.Admin.loginViaUserAdmin": "Permiso de administrador del usuario",
|
||||
"components.AddressSelect.linkedMailboxes": "Buzones vinculados",
|
||||
"views.Admin.loginViaUserAdmin": "Cuenta de usuario (rol de administrador)",
|
||||
"views.admin.Statistics.userCount": "Cantidad de usuarios",
|
||||
"views.admin.UserManagement.user_email": "Correo del usuario",
|
||||
"views.index.AddressBar.userLogin": "Inicio de sesión de usuario",
|
||||
"views.index.AddressBar.userCenter": "Centro de usuario",
|
||||
"views.Admin.user_management": "Gestión de usuarios",
|
||||
"views.Admin.user_settings": "Configuración de usuario",
|
||||
"views.User.user_settings": "Configuración de usuario",
|
||||
@@ -582,6 +614,10 @@ export const esMessages = {
|
||||
"views.admin.AiExtractSettings.disabledTip": "Si está desactivado, la extracción IA procesará todas las direcciones",
|
||||
"views.admin.AiExtractSettings.enableAllowListTip": "Si está activado, la extracción IA solo procesará correos enviados a direcciones permitidas",
|
||||
"views.admin.CreateAccount.randomSubdomainTip": "Si está activado, la dirección creada usará un subdominio aleatorio. Recomendado solo para recibir. Requiere un registro MX comodín en el DNS del dominio base — consulta la documentación de subdominios aleatorios.",
|
||||
"views.admin.CreateAccount.enableCustomSubdomain": "Usar subdominio personalizado",
|
||||
"views.admin.CreateAccount.normalSubdomain": "Dominio normal",
|
||||
"views.common.Login.enableCustomSubdomain": "Usar subdominio personalizado",
|
||||
"views.common.Login.normalSubdomain": "Dominio normal",
|
||||
"views.common.Login.randomSubdomainTip": "Si está activado, la dirección creada usará un subdominio aleatorio. Recomendado solo para recibir. Requiere un registro MX comodín en el DNS del dominio base — consulta la documentación de subdominios aleatorios.",
|
||||
"views.admin.AiExtractSettings.allowListTip": "El comodín * coincide con cualquier carácter; p. ej., *{'@'}example.com coincide con todas las direcciones del dominio example.com",
|
||||
"views.Admin.workerconfig": "Configuración del Worker",
|
||||
@@ -617,5 +653,15 @@ export const esMessages = {
|
||||
"components.AddressCredentialModal.starttls": "STARTTLS",
|
||||
"components.AddressCredentialModal.tip": "Usa estas credenciales solo con clientes y agentes de confianza.",
|
||||
"components.AddressCredentialModal.title": "Credenciales de dirección y métodos de conexión",
|
||||
"components.AddressCredentialModal.username": "Usuario"
|
||||
"components.AddressCredentialModal.username": "Usuario",
|
||||
"views.User.send_mail": "Enviar correo",
|
||||
"views.user.UserSendBox.noAddress": "Selecciona una dirección de correo vinculada",
|
||||
"views.user.UserSendBox.sendbox": "Enviados",
|
||||
"components.MailContentRenderer.markAsRead": "Marcar como leído",
|
||||
"components.MailContentRenderer.markAsUnread": "Marcar como no leído",
|
||||
"components.MailContentRenderer.loadRemoteImages": "Cargar imágenes",
|
||||
"components.MailContentRenderer.remoteImagesBlocked": "Se bloquearon {count} recursos remotos para proteger tu privacidad",
|
||||
"views.user.AddressManagement.itemCount": "Total",
|
||||
"views.index.SimpleIndex.readStatusUpdated": "Estado de lectura actualizado",
|
||||
"views.common.Appearance.autoLoadRemoteImages": "Cargar automáticamente las imágenes externas del correo"
|
||||
}
|
||||
|
||||
@@ -1,14 +1,44 @@
|
||||
export const jaMessages = {
|
||||
"views.index.SendMail.balanceUnavailable": "このアドレスには送信残高がありません",
|
||||
"views.index.SendMail.composeMail": "メールを作成",
|
||||
"views.index.SendMail.contentPlaceholder": "メッセージを入力...",
|
||||
"views.index.SendMail.draftSaved": "下書きはこのブラウザーに保存されています",
|
||||
"views.index.SendMail.recipientAddress": "宛先メールアドレス",
|
||||
"views.index.SendMail.recipientName": "宛先名(任意)",
|
||||
"views.index.SendMail.senderAddress": "送信元メールアドレス",
|
||||
"views.index.SendMail.senderName": "送信者名(任意)",
|
||||
"views.admin.SendMail.adminComposeTip": "設定済みのメールアドレスから送信",
|
||||
"views.admin.SendMail.composeMail": "メールを作成",
|
||||
"views.admin.SendMail.contentPlaceholder": "メッセージを入力...",
|
||||
"views.admin.SendMail.draftSaved": "下書きはこのブラウザーに保存されています",
|
||||
"views.admin.SendMail.recipientAddress": "宛先メールアドレス",
|
||||
"views.admin.SendMail.recipientName": "宛先名(任意)",
|
||||
"views.admin.SendMail.senderAddress": "送信元メールアドレス",
|
||||
"views.admin.SendMail.senderName": "送信者名(任意)",
|
||||
"views.admin.DatabaseManager.current_database_size": "現在のデータベースサイズ",
|
||||
"views.admin.DatabaseManager.free_plan": "Free",
|
||||
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
|
||||
"views.admin.DatabaseManager.plan": "Workers プラン",
|
||||
"views.admin.DatabaseManager.plan_placeholder": "Cloudflare Workers プランを選択",
|
||||
"views.admin.DatabaseManager.planSaved": "Workers プランを保存しました",
|
||||
"views.admin.DatabaseManager.single_database_limit": "データベース容量上限",
|
||||
"views.admin.DatabaseManager.storage_description": "現在のデータベースサイズとプラン上限を比較します。",
|
||||
"views.admin.DatabaseManager.storage_tip": "使用率は現在のデータベースサイズと選択したプラン上限から計算されます。",
|
||||
"views.admin.DatabaseManager.storage_title": "D1 ストレージ容量",
|
||||
"views.admin.DatabaseManager.storage_usage": "容量使用率",
|
||||
"views.admin.DatabaseManager.unavailable": "利用不可",
|
||||
"views.index.SimpleIndex.mailCount": "{current} / {total} 件のメール",
|
||||
"views.admin.Statistics.activeAddressCount30days": "30日間のアクティブなアドレス数",
|
||||
"views.admin.Statistics.activeAddressCount7days": "7日間のアクティブなアドレス数",
|
||||
"views.Index.about": "概要",
|
||||
"views.Admin.about": "概要",
|
||||
"views.Header.accessHeader": "アクセス用パスワード",
|
||||
"views.Admin.account": "アカウント",
|
||||
"views.Index.accountSettings": "アカウント設定",
|
||||
"views.Admin.account_settings": "アカウント設定",
|
||||
"views.index.SimpleIndex.accountSettings": "アカウント設定",
|
||||
"views.Header.admin": "管理画面",
|
||||
"views.Admin.mailbox_management": "メールボックス管理",
|
||||
"views.Admin.mailbox_list": "メールボックス一覧",
|
||||
"views.Index.mailboxSettings": "メールボックス設定",
|
||||
"views.Admin.mailbox_settings": "メールボックスのグローバル設定",
|
||||
"views.index.SimpleIndex.mailboxSettings": "メールボックス設定",
|
||||
"views.index.Attachment.action": "操作",
|
||||
"views.admin.SenderAccess.action": "操作",
|
||||
"views.user.UserSettings.actions": "操作",
|
||||
@@ -22,7 +52,7 @@ export const jaMessages = {
|
||||
"views.admin.Maintenance.customSqlTip": "定期クリーンアップ用にカスタム DELETE SQL を追加します。各項目には 1 つの DELETE 文のみ使用できます。",
|
||||
"views.admin.Maintenance.addCustomSql": "カスタム SQL を追加",
|
||||
"views.admin.UserOauth2Settings.addOauth2": "Oauth2 を追加",
|
||||
"components.AddressSelect.address": "アドレス",
|
||||
"components.AddressSelect.selectMailbox": "メールボックスを選択",
|
||||
"views.user.AddressManagement.address": "アドレス",
|
||||
"views.index.TelegramAddress.address": "アドレス",
|
||||
"views.admin.SenderAccess.address": "アドレス",
|
||||
@@ -58,10 +88,10 @@ export const jaMessages = {
|
||||
"views.admin.Account.clearSentItemsTip": "このメールアドレスの送信済みを削除してもよろしいですか?",
|
||||
"views.admin.Account.multiDeleteTip": "選択したアドレスを削除してもよろしいですか?",
|
||||
"views.index.Attachment.deleteConfirm": "この添付ファイルを削除してもよろしいですか?",
|
||||
"views.admin.Account.deleteTip": "このメールを削除してもよろしいですか?",
|
||||
"views.admin.Account.deleteTip": "このメールボックスを削除してもよろしいですか?",
|
||||
"views.admin.SenderAccess.deleteTip": "これを削除してもよろしいですか?",
|
||||
"views.index.AccountSettings.deleteAccountConfirm": "このアカウントと関連メールをすべて削除してもよろしいですか?",
|
||||
"views.index.AccountSettings.logoutConfirm": "ログアウトしてもよろしいですか?",
|
||||
"views.index.AccountSettings.deleteAccountConfirm": "このメールアドレスと関連するすべてのメールを削除しますか?ユーザーアカウントは削除されません。",
|
||||
"views.index.AccountSettings.logoutConfirm": "現在のメールアドレスからログアウトしますか?",
|
||||
"components.MailBox.deleteMailTip": "メールを削除してもよろしいですか?",
|
||||
"components.MailContentRenderer.deleteMailTip": "メールを削除してもよろしいですか?",
|
||||
"components.SendBox.deleteMailTip": "メールを削除してもよろしいですか?",
|
||||
@@ -95,10 +125,10 @@ export const jaMessages = {
|
||||
"views.admin.AccountSettings.cancel": "キャンセル",
|
||||
"components.MailBox.cancelMultiAction": "複数選択をキャンセル",
|
||||
"components.SendBox.cancelMultiAction": "複数選択をキャンセル",
|
||||
"views.user.AddressManagement.changeMailAddress": "アドレスを変更",
|
||||
"views.user.AddressManagement.openMailbox": "メールボックスを開く",
|
||||
"views.index.TelegramAddress.changeMailAddress": "メールアドレスを変更",
|
||||
"views.index.LocalAddress.changeMailAddress": "メールアドレスを変更",
|
||||
"views.index.AccountSettings.changePassword": "パスワードを変更",
|
||||
"views.index.AccountSettings.changePassword": "アドレスパスワードを変更",
|
||||
"views.admin.UserManagement.changeRole": "ロールを変更",
|
||||
"components.SendBox.showCode": "元のコード表示を切り替え",
|
||||
"views.admin.Telegram.status": "ステータスを確認",
|
||||
@@ -134,8 +164,8 @@ export const jaMessages = {
|
||||
"views.index.SimpleIndex.copyAddress": "コピー",
|
||||
"components.AiExtractInfo.copyFailed": "コピーに失敗しました",
|
||||
"views.Footer.copyright": "著作権",
|
||||
"views.Admin.account_create": "アカウントを作成",
|
||||
"views.admin.CreateAccount.creatNewEmail": "新しいメールを作成",
|
||||
"views.Admin.mailbox_create": "メールアドレスを作成",
|
||||
"views.admin.CreateAccount.createEmailAddress": "メールアドレスを作成",
|
||||
"views.common.Login.getNewEmail": "新しいメールを作成",
|
||||
"views.user.AddressManagement.create_or_bind": "作成または紐付け",
|
||||
"views.index.LocalAddress.create_or_bind": "作成または紐付け",
|
||||
@@ -170,8 +200,8 @@ export const jaMessages = {
|
||||
"views.admin.UserManagement.delete": "削除",
|
||||
"views.admin.AccountSettings.delete_rule": "削除",
|
||||
"views.admin.Maintenance.deleteCustomSql": "削除",
|
||||
"views.index.AccountSettings.deleteAccount": "アカウントを削除",
|
||||
"views.admin.Account.deleteAccount": "アカウントを削除",
|
||||
"views.index.AccountSettings.deleteMailbox": "メールボックスを削除",
|
||||
"views.admin.Account.deleteMailbox": "メールボックスを削除",
|
||||
"views.user.UserSettings.deletePasskey": "Passkeyを削除",
|
||||
"views.admin.AccountSettings.delete_success": "削除しました",
|
||||
"views.admin.UserManagement.deleteUser": "ユーザーを削除",
|
||||
@@ -242,7 +272,7 @@ export const jaMessages = {
|
||||
"views.admin.Telegram.globalMailPushList": "グローバルメール通知 Chat ID 一覧",
|
||||
"views.common.Appearance.globalTabplacement": "全体タブ位置",
|
||||
"views.common.Login.help": "ヘルプ",
|
||||
"views.Header.home": "ホーム",
|
||||
"views.Header.mailbox": "メールボックス",
|
||||
"views.index.SendMail.html": "HTML",
|
||||
"views.admin.SendMail.html": "HTML",
|
||||
"views.admin.UserOauth2Settings.icon": "アイコン (SVG、信頼できるソースのみ使用してください)",
|
||||
@@ -282,7 +312,7 @@ export const jaMessages = {
|
||||
"views.user.UserSettings.logout": "ログアウト",
|
||||
"views.user.BindAddress.logout": "ログアウト",
|
||||
"views.Admin.logout": "ログアウト",
|
||||
"views.index.AccountSettings.logout": "ログアウト",
|
||||
"views.index.AccountSettings.logout": "現在のメールアドレスからログアウト",
|
||||
"views.Admin.logoutSuccess": "ログアウトしました",
|
||||
"views.admin.UserOauth2Settings.mailAllowList": "メールアドレス許可リスト",
|
||||
"views.admin.UserSettings.mailAllowList": "メールアドレス許可リスト",
|
||||
@@ -291,7 +321,7 @@ export const jaMessages = {
|
||||
"views.admin.Account.addressCredential": "メールアドレス資格情報",
|
||||
"views.admin.CreateAccount.addressCredential": "メールアドレス資格情報",
|
||||
"views.index.AddressBar.fetchAddressError": "メール資格情報が無効かアカウントが存在しません。ネットワークの問題の可能性もあります。後でもう一度お試しください。",
|
||||
"views.Index.mailbox": "受信箱",
|
||||
"views.Index.inbox": "受信箱",
|
||||
"views.User.user_mail_box_tab": "受信箱",
|
||||
"views.user.AddressManagement.mail_count": "メール数",
|
||||
"views.admin.Account.mail_count": "メール数",
|
||||
@@ -321,11 +351,11 @@ export const jaMessages = {
|
||||
"views.admin.Account.multiClearInbox": "受信箱を一括削除",
|
||||
"views.admin.Account.multiClearSentItems": "送信済みを一括削除",
|
||||
"views.admin.Account.multiDelete": "一括削除",
|
||||
"views.user.AddressManagement.name": "名前",
|
||||
"views.user.AddressManagement.emailAddress": "メールアドレス",
|
||||
"views.index.AutoReply.name": "名前",
|
||||
"views.admin.Account.name": "名前",
|
||||
"views.admin.Account.emailAddress": "メールアドレス",
|
||||
"views.admin.UserOauth2Settings.name": "名前",
|
||||
"views.admin.UserAddressManagement.name": "名前",
|
||||
"views.admin.UserAddressManagement.emailAddress": "メールアドレス",
|
||||
"views.admin.Maintenance.sqlName": "名前",
|
||||
"views.index.AccountSettings.newPassword": "新しいパスワード",
|
||||
"views.admin.Account.newPassword": "新しいパスワード",
|
||||
@@ -334,7 +364,8 @@ export const jaMessages = {
|
||||
"views.admin.AccountSettings.noLimitSendAddressList": "残高無制限の送信アドレス一覧",
|
||||
"views.index.SimpleIndex.noMails": "メールが見つかりません",
|
||||
"views.admin.RoleAddressConfig.noRolesAvailable": "システム設定に利用可能なロールがありません",
|
||||
"views.index.SendMail.requestAccessTip": "まだ送信残高がありません。管理者がデフォルト残高を有効にしていれば自動付与されます。そうでない場合は権限申請または管理者へ連絡してください。",
|
||||
"views.index.SendMail.requestAccessTip": "送信権限と残高はメールアドレスごとに管理されます。現在のアドレス {address} には利用可能な残高がありません。このアドレスの権限を申請するか、管理者に連絡してください。",
|
||||
"views.index.SendMail.requestSuccess": "現在のアドレスの送信権限を申請しました",
|
||||
"components.SendBox.emptySent": "送信済みメールはありません",
|
||||
"views.admin.RoleAddressConfig.notConfigured": "未設定 (全体設定を使用)",
|
||||
"views.Admin.userOauth2Settings": "OAuth2設定",
|
||||
@@ -373,6 +404,7 @@ export const jaMessages = {
|
||||
"views.user.UserSettings.renamePasskeyNamePlaceholder": "新しい Passkey 名を入力してください",
|
||||
"views.user.UserSettings.passkeyNamePlaceholder": "Passkey 名を入力するか、空欄でランダム生成してください",
|
||||
"views.admin.CreateAccount.fillInAllFields": "すべての項目を入力してください",
|
||||
"views.admin.CreateAccount.generateName": "ランダム名を生成",
|
||||
"views.admin.SendBox.queryTip": "検索するアドレスを入力してください。空欄で全件検索します",
|
||||
"views.user.UserLogin.pleaseInputCode": "コードを入力してください",
|
||||
"views.admin.UserManagement.pleaseInput": "必要な情報をすべて入力してください",
|
||||
@@ -465,7 +497,7 @@ export const jaMessages = {
|
||||
"views.admin.UserAddressManagement.send_count": "送信数",
|
||||
"views.Index.sendmail": "メール送信",
|
||||
"views.Admin.sendMail": "メール送信",
|
||||
"views.index.SendMail.send_balance": "残り送信枠",
|
||||
"views.index.SendMail.send_balance": "現在のアドレスの送信残高",
|
||||
"views.admin.Statistics.sendMailCount": "送信メール数",
|
||||
"views.admin.AccountSettings.send_mail_limit": "送信上限",
|
||||
"views.user.UserLogin.sendVerificationCode": "認証コードを送信",
|
||||
@@ -526,7 +558,7 @@ export const jaMessages = {
|
||||
"views.index.LocalAddress.tip": "これらのアドレスはブラウザに保存されており、キャッシュを消すと失われる可能性があります。",
|
||||
"views.admin.UserOauth2Settings.tip": "サードパーティログインではユーザーのメールアドレスで自動的にアカウント登録されます(同じメールは同一アカウントとして扱われます)。「パスワードを忘れた」からパスワード設定も可能です。",
|
||||
"views.admin.AccountSettings.send_mail_limit_tip": "すべての送信チャネルに適用されます。-1 は無制限、0 は送信禁止です。",
|
||||
"views.admin.AccountSettings.create_address_subdomain_match_note": "これは RANDOM_SUBDOMAIN_DOMAINS と異なります。この設定では API 呼び出し側が独自サブドメインを指定でき、ランダムサブドメインは作成時に自動生成されるだけです。",
|
||||
"views.admin.AccountSettings.create_address_subdomain_match_note": "RANDOM_SUBDOMAIN_DOMAINS に列挙したベースドメインでは、ランダムまたは手動のサブドメインを利用できます。この設定は、他の許可済みベースドメインでも API からサブドメインを指定できるようにします。",
|
||||
"views.index.SendMail.tooLarge": "ファイルが大きすぎます。1MB 未満のファイルをアップロードしてください。",
|
||||
"views.admin.SendMail.tooLarge": "ファイルが大きすぎます。1MB 未満のファイルをアップロードしてください。",
|
||||
"views.common.Appearance.top": "上",
|
||||
@@ -555,13 +587,13 @@ export const jaMessages = {
|
||||
"views.admin.UserOauth2Settings.userEmailFormatTip": "正規表現でメールを変換します。例: ^(.+)@old\\.com$ と $1@new.com",
|
||||
"views.common.Appearance.useSimpleIndex": "シンプルインデックスを使う",
|
||||
"views.common.Appearance.useUTCDate": "UTC 日時を使う",
|
||||
"views.Header.user": "ユーザー",
|
||||
"views.Header.userCenter": "ユーザーセンター",
|
||||
"views.Admin.user": "ユーザー",
|
||||
"components.AddressSelect.userAddresses": "ユーザーのアドレス",
|
||||
"views.Admin.loginViaUserAdmin": "ユーザー管理者権限",
|
||||
"components.AddressSelect.linkedMailboxes": "連携済みメールボックス",
|
||||
"views.Admin.loginViaUserAdmin": "ユーザーアカウント(管理者ロール)",
|
||||
"views.admin.Statistics.userCount": "ユーザー数",
|
||||
"views.admin.UserManagement.user_email": "ユーザーメール",
|
||||
"views.index.AddressBar.userLogin": "ユーザーログイン",
|
||||
"views.index.AddressBar.userCenter": "ユーザーセンター",
|
||||
"views.Admin.user_management": "ユーザー管理",
|
||||
"views.Admin.user_settings": "ユーザー設定",
|
||||
"views.User.user_settings": "ユーザー設定",
|
||||
@@ -582,6 +614,10 @@ export const jaMessages = {
|
||||
"views.admin.AiExtractSettings.disabledTip": "無効時は AI 抽出がすべてのメールアドレスを処理します",
|
||||
"views.admin.AiExtractSettings.enableAllowListTip": "有効時は AI 抽出は許可リストのアドレス宛メールのみ処理します",
|
||||
"views.admin.CreateAccount.randomSubdomainTip": "有効時は作成されるアドレスがランダムなサブドメインを使用します。受信専用として推奨されます。ベースドメインの DNS にワイルドカード MX レコードの設定が必要です — ランダムサブドメインのドキュメントを参照してください。",
|
||||
"views.admin.CreateAccount.enableCustomSubdomain": "カスタムサブドメインを使用",
|
||||
"views.admin.CreateAccount.normalSubdomain": "通常ドメイン",
|
||||
"views.common.Login.enableCustomSubdomain": "カスタムサブドメインを使用",
|
||||
"views.common.Login.normalSubdomain": "通常ドメイン",
|
||||
"views.common.Login.randomSubdomainTip": "有効時は作成されるアドレスがランダムなサブドメインを使用します。受信専用として推奨されます。ベースドメインの DNS にワイルドカード MX レコードの設定が必要です — ランダムサブドメインのドキュメントを参照してください。",
|
||||
"views.admin.AiExtractSettings.allowListTip": "ワイルドカード * は任意の文字に一致します。例: *{'@'}example.com は example.com ドメイン配下のすべてのアドレスに一致します",
|
||||
"views.Admin.workerconfig": "Worker設定",
|
||||
@@ -617,5 +653,15 @@ export const jaMessages = {
|
||||
"components.AddressCredentialModal.starttls": "STARTTLS",
|
||||
"components.AddressCredentialModal.tip": "これらの認証情報は信頼できるクライアントと Agent でのみ使用してください。",
|
||||
"components.AddressCredentialModal.title": "アドレス認証情報と接続方法",
|
||||
"components.AddressCredentialModal.username": "ユーザー名"
|
||||
"components.AddressCredentialModal.username": "ユーザー名",
|
||||
"views.User.send_mail": "メール送信",
|
||||
"views.user.UserSendBox.noAddress": "紐付け済みのメールアドレスを選択してください",
|
||||
"views.user.UserSendBox.sendbox": "送信済み",
|
||||
"components.MailContentRenderer.markAsRead": "既読にする",
|
||||
"components.MailContentRenderer.markAsUnread": "未読にする",
|
||||
"components.MailContentRenderer.loadRemoteImages": "画像を読み込む",
|
||||
"components.MailContentRenderer.remoteImagesBlocked": "プライバシー保護のため、{count} 件の外部リソースをブロックしました",
|
||||
"views.user.AddressManagement.itemCount": "合計",
|
||||
"views.index.SimpleIndex.readStatusUpdated": "既読状態を更新しました",
|
||||
"views.common.Appearance.autoLoadRemoteImages": "メール本文の外部画像を自動的に読み込む"
|
||||
}
|
||||
|
||||
@@ -1,14 +1,44 @@
|
||||
export const ptBRMessages = {
|
||||
"views.index.SendMail.balanceUnavailable": "Sem saldo de envio para este endereço",
|
||||
"views.index.SendMail.composeMail": "Escrever e-mail",
|
||||
"views.index.SendMail.contentPlaceholder": "Escreva sua mensagem...",
|
||||
"views.index.SendMail.draftSaved": "Rascunho salvo neste navegador",
|
||||
"views.index.SendMail.recipientAddress": "Endereço do destinatário",
|
||||
"views.index.SendMail.recipientName": "Nome do destinatário (opcional)",
|
||||
"views.index.SendMail.senderAddress": "Endereço do remetente",
|
||||
"views.index.SendMail.senderName": "Nome do remetente (opcional)",
|
||||
"views.admin.SendMail.adminComposeTip": "Enviar usando um endereço de e-mail configurado",
|
||||
"views.admin.SendMail.composeMail": "Escrever e-mail",
|
||||
"views.admin.SendMail.contentPlaceholder": "Escreva sua mensagem...",
|
||||
"views.admin.SendMail.draftSaved": "Rascunho salvo neste navegador",
|
||||
"views.admin.SendMail.recipientAddress": "Endereço do destinatário",
|
||||
"views.admin.SendMail.recipientName": "Nome do destinatário (opcional)",
|
||||
"views.admin.SendMail.senderAddress": "Endereço do remetente",
|
||||
"views.admin.SendMail.senderName": "Nome do remetente (opcional)",
|
||||
"views.admin.DatabaseManager.current_database_size": "Tamanho atual do banco de dados",
|
||||
"views.admin.DatabaseManager.free_plan": "Free",
|
||||
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
|
||||
"views.admin.DatabaseManager.plan": "Plano do Workers",
|
||||
"views.admin.DatabaseManager.plan_placeholder": "Selecione seu plano do Cloudflare Workers",
|
||||
"views.admin.DatabaseManager.planSaved": "Plano do Workers salvo",
|
||||
"views.admin.DatabaseManager.single_database_limit": "Limite de capacidade do banco de dados",
|
||||
"views.admin.DatabaseManager.storage_description": "Compare o tamanho atual do banco de dados com os limites do seu plano.",
|
||||
"views.admin.DatabaseManager.storage_tip": "O uso é calculado com o tamanho atual do banco de dados e o limite do plano selecionado.",
|
||||
"views.admin.DatabaseManager.storage_title": "Capacidade de armazenamento D1",
|
||||
"views.admin.DatabaseManager.storage_usage": "Uso da capacidade",
|
||||
"views.admin.DatabaseManager.unavailable": "Indisponível",
|
||||
"views.index.SimpleIndex.mailCount": "{current} / {total} e-mails",
|
||||
"views.admin.Statistics.activeAddressCount30days": "Quantidade de endereços ativos em 30 dias",
|
||||
"views.admin.Statistics.activeAddressCount7days": "Quantidade de endereços ativos em 7 dias",
|
||||
"views.Index.about": "Sobre",
|
||||
"views.Admin.about": "Sobre",
|
||||
"views.Header.accessHeader": "Senha de acesso",
|
||||
"views.Admin.account": "Conta",
|
||||
"views.Index.accountSettings": "Configurações da conta",
|
||||
"views.Admin.account_settings": "Configurações da conta",
|
||||
"views.index.SimpleIndex.accountSettings": "Configurações da conta",
|
||||
"views.Header.admin": "Painel administrativo",
|
||||
"views.Admin.mailbox_management": "Gerenciamento de caixas de correio",
|
||||
"views.Admin.mailbox_list": "Caixas de correio",
|
||||
"views.Index.mailboxSettings": "Configurações da caixa de correio",
|
||||
"views.Admin.mailbox_settings": "Configurações globais de caixas de correio",
|
||||
"views.index.SimpleIndex.mailboxSettings": "Configurações da caixa de correio",
|
||||
"views.index.Attachment.action": "Ação",
|
||||
"views.admin.SenderAccess.action": "Ação",
|
||||
"views.user.UserSettings.actions": "Ações",
|
||||
@@ -22,7 +52,7 @@ export const ptBRMessages = {
|
||||
"views.admin.Maintenance.customSqlTip": "Adicione instruções SQL DELETE personalizadas para a limpeza agendada. Apenas uma instrução DELETE é permitida por item.",
|
||||
"views.admin.Maintenance.addCustomSql": "Adicionar SQL personalizada",
|
||||
"views.admin.UserOauth2Settings.addOauth2": "Adicionar OAuth2",
|
||||
"components.AddressSelect.address": "Endereço",
|
||||
"components.AddressSelect.selectMailbox": "Selecionar caixa de correio",
|
||||
"views.user.AddressManagement.address": "Endereço",
|
||||
"views.index.TelegramAddress.address": "Endereço",
|
||||
"views.admin.SenderAccess.address": "Endereço",
|
||||
@@ -58,10 +88,10 @@ export const ptBRMessages = {
|
||||
"views.admin.Account.clearSentItemsTip": "Tem certeza de que deseja limpar os itens enviados deste e-mail?",
|
||||
"views.admin.Account.multiDeleteTip": "Tem certeza de que deseja excluir os endereços selecionados?",
|
||||
"views.index.Attachment.deleteConfirm": "Tem certeza de que deseja excluir este anexo?",
|
||||
"views.admin.Account.deleteTip": "Tem certeza de que deseja excluir este e-mail?",
|
||||
"views.admin.Account.deleteTip": "Tem certeza de que deseja excluir esta caixa de correio?",
|
||||
"views.admin.SenderAccess.deleteTip": "Tem certeza de que deseja excluir isto?",
|
||||
"views.index.AccountSettings.deleteAccountConfirm": "Tem certeza de que deseja excluir sua conta e todos os e-mails dela?",
|
||||
"views.index.AccountSettings.logoutConfirm": "Tem certeza de que deseja sair?",
|
||||
"views.index.AccountSettings.deleteAccountConfirm": "Tem certeza de que deseja excluir este endereço de e-mail e todas as mensagens dele? Isso não exclui sua conta de usuário.",
|
||||
"views.index.AccountSettings.logoutConfirm": "Tem certeza de que deseja sair do endereço de e-mail atual?",
|
||||
"components.MailBox.deleteMailTip": "Tem certeza de que deseja excluir o e-mail?",
|
||||
"components.MailContentRenderer.deleteMailTip": "Tem certeza de que deseja excluir o e-mail?",
|
||||
"components.SendBox.deleteMailTip": "Tem certeza de que deseja excluir o e-mail?",
|
||||
@@ -95,10 +125,10 @@ export const ptBRMessages = {
|
||||
"views.admin.AccountSettings.cancel": "Cancelar",
|
||||
"components.MailBox.cancelMultiAction": "Cancelar múltipla seleção",
|
||||
"components.SendBox.cancelMultiAction": "Cancelar múltipla seleção",
|
||||
"views.user.AddressManagement.changeMailAddress": "Alterar endereço",
|
||||
"views.user.AddressManagement.openMailbox": "Abrir caixa de correio",
|
||||
"views.index.TelegramAddress.changeMailAddress": "Alterar endereço de e-mail",
|
||||
"views.index.LocalAddress.changeMailAddress": "Alterar endereço de e-mail",
|
||||
"views.index.AccountSettings.changePassword": "Alterar senha",
|
||||
"views.index.AccountSettings.changePassword": "Alterar senha do endereço",
|
||||
"views.admin.UserManagement.changeRole": "Alterar Função",
|
||||
"components.SendBox.showCode": "Alternar visualização do código original",
|
||||
"views.admin.Telegram.status": "Ver status",
|
||||
@@ -134,8 +164,8 @@ export const ptBRMessages = {
|
||||
"views.index.SimpleIndex.copyAddress": "Copiar",
|
||||
"components.AiExtractInfo.copyFailed": "Falha ao copiar",
|
||||
"views.Footer.copyright": "Direitos autorais",
|
||||
"views.Admin.account_create": "Criar conta",
|
||||
"views.admin.CreateAccount.creatNewEmail": "Criar novo e-mail",
|
||||
"views.Admin.mailbox_create": "Criar endereço de e-mail",
|
||||
"views.admin.CreateAccount.createEmailAddress": "Criar endereço de e-mail",
|
||||
"views.common.Login.getNewEmail": "Criar novo e-mail",
|
||||
"views.user.AddressManagement.create_or_bind": "Criar ou vincular",
|
||||
"views.index.LocalAddress.create_or_bind": "Criar ou vincular",
|
||||
@@ -170,8 +200,8 @@ export const ptBRMessages = {
|
||||
"views.admin.UserManagement.delete": "Excluir",
|
||||
"views.admin.AccountSettings.delete_rule": "Excluir",
|
||||
"views.admin.Maintenance.deleteCustomSql": "Excluir",
|
||||
"views.index.AccountSettings.deleteAccount": "Excluir conta",
|
||||
"views.admin.Account.deleteAccount": "Excluir conta",
|
||||
"views.index.AccountSettings.deleteMailbox": "Excluir caixa de correio",
|
||||
"views.admin.Account.deleteMailbox": "Excluir caixa de correio",
|
||||
"views.user.UserSettings.deletePasskey": "Excluir passkey",
|
||||
"views.admin.AccountSettings.delete_success": "Excluído com sucesso",
|
||||
"views.admin.UserManagement.deleteUser": "Excluir usuário",
|
||||
@@ -242,7 +272,7 @@ export const ptBRMessages = {
|
||||
"views.admin.Telegram.globalMailPushList": "Lista global de chat ID para envio de e-mails",
|
||||
"views.common.Appearance.globalTabplacement": "Posição global das abas",
|
||||
"views.common.Login.help": "Ajuda",
|
||||
"views.Header.home": "Início",
|
||||
"views.Header.mailbox": "Caixa de correio",
|
||||
"views.index.SendMail.html": "HTML",
|
||||
"views.admin.SendMail.html": "HTML",
|
||||
"views.admin.UserOauth2Settings.icon": "Ícone (SVG, use apenas fonte confiável)",
|
||||
@@ -282,7 +312,7 @@ export const ptBRMessages = {
|
||||
"views.user.UserSettings.logout": "Sair",
|
||||
"views.user.BindAddress.logout": "Sair",
|
||||
"views.Admin.logout": "Sair",
|
||||
"views.index.AccountSettings.logout": "Sair",
|
||||
"views.index.AccountSettings.logout": "Sair do endereço de e-mail atual",
|
||||
"views.Admin.logoutSuccess": "Logout realizado com sucesso",
|
||||
"views.admin.UserOauth2Settings.mailAllowList": "Lista branca de endereços de e-mail",
|
||||
"views.admin.UserSettings.mailAllowList": "Lista branca de endereços de e-mail",
|
||||
@@ -291,7 +321,7 @@ export const ptBRMessages = {
|
||||
"views.admin.Account.addressCredential": "Credencial do endereço de e-mail",
|
||||
"views.admin.CreateAccount.addressCredential": "Credencial do endereço de e-mail",
|
||||
"views.index.AddressBar.fetchAddressError": "A credencial do e-mail é inválida ou a conta não existe; também pode ser um problema de rede. Tente novamente mais tarde.",
|
||||
"views.Index.mailbox": "Caixa de entrada",
|
||||
"views.Index.inbox": "Caixa de entrada",
|
||||
"views.User.user_mail_box_tab": "Caixa de entrada",
|
||||
"views.user.AddressManagement.mail_count": "Quantidade de e-mails",
|
||||
"views.admin.Account.mail_count": "Quantidade de e-mails",
|
||||
@@ -321,11 +351,11 @@ export const ptBRMessages = {
|
||||
"views.admin.Account.multiClearInbox": "Limpar várias caixas de entrada",
|
||||
"views.admin.Account.multiClearSentItems": "Limpar vários itens enviados",
|
||||
"views.admin.Account.multiDelete": "Excluir em lote",
|
||||
"views.user.AddressManagement.name": "Nome",
|
||||
"views.user.AddressManagement.emailAddress": "Endereço de e-mail",
|
||||
"views.index.AutoReply.name": "Nome",
|
||||
"views.admin.Account.name": "Nome",
|
||||
"views.admin.Account.emailAddress": "Endereço de e-mail",
|
||||
"views.admin.UserOauth2Settings.name": "Nome",
|
||||
"views.admin.UserAddressManagement.name": "Nome",
|
||||
"views.admin.UserAddressManagement.emailAddress": "Endereço de e-mail",
|
||||
"views.admin.Maintenance.sqlName": "Nome",
|
||||
"views.index.AccountSettings.newPassword": "Nova senha",
|
||||
"views.admin.Account.newPassword": "Nova senha",
|
||||
@@ -334,7 +364,8 @@ export const ptBRMessages = {
|
||||
"views.admin.AccountSettings.noLimitSendAddressList": "Lista de endereços sem limite de saldo",
|
||||
"views.index.SimpleIndex.noMails": "Nenhum e-mail encontrado",
|
||||
"views.admin.RoleAddressConfig.noRolesAvailable": "Nenhuma função disponível na configuração do sistema",
|
||||
"views.index.SendMail.requestAccessTip": "Ainda não há saldo de envio. Se o administrador ativou um saldo padrão, ele será atribuído automaticamente; caso contrário, solicite acesso ou fale com o administrador.",
|
||||
"views.index.SendMail.requestAccessTip": "A permissão e o saldo de envio são gerenciados separadamente para cada endereço. O endereço atual, {address}, não tem saldo disponível. Solicite permissão para este endereço ou fale com o administrador.",
|
||||
"views.index.SendMail.requestSuccess": "Permissão de envio solicitada para o endereço atual",
|
||||
"components.SendBox.emptySent": "Nenhum e-mail enviado",
|
||||
"views.admin.RoleAddressConfig.notConfigured": "Não configurado (usar configurações globais)",
|
||||
"views.Admin.userOauth2Settings": "Configurações de OAuth2",
|
||||
@@ -373,6 +404,7 @@ export const ptBRMessages = {
|
||||
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Informe o novo nome da passkey",
|
||||
"views.user.UserSettings.passkeyNamePlaceholder": "Informe o nome da passkey ou deixe em branco para gerar um aleatório",
|
||||
"views.admin.CreateAccount.fillInAllFields": "Preencha todos os campos",
|
||||
"views.admin.CreateAccount.generateName": "Gerar nome aleatório",
|
||||
"views.admin.SendBox.queryTip": "Informe o endereço para consulta; deixe em branco para consultar todos",
|
||||
"views.user.UserLogin.pleaseInputCode": "Informe o código",
|
||||
"views.admin.UserManagement.pleaseInput": "Informe todas as informações",
|
||||
@@ -465,7 +497,7 @@ export const ptBRMessages = {
|
||||
"views.admin.UserAddressManagement.send_count": "Quantidade enviada",
|
||||
"views.Index.sendmail": "Enviar e-mail",
|
||||
"views.Admin.sendMail": "Enviar e-mail",
|
||||
"views.index.SendMail.send_balance": "Saldo restante de envio",
|
||||
"views.index.SendMail.send_balance": "Saldo de envio do endereço atual",
|
||||
"views.admin.Statistics.sendMailCount": "Quantidade de e-mails enviados",
|
||||
"views.admin.AccountSettings.send_mail_limit": "Limite de envio",
|
||||
"views.user.UserLogin.sendVerificationCode": "Enviar código de verificação",
|
||||
@@ -526,7 +558,7 @@ export const ptBRMessages = {
|
||||
"views.index.LocalAddress.tip": "Esses endereços ficam armazenados no navegador e podem ser perdidos se você limpar o cache.",
|
||||
"views.admin.UserOauth2Settings.tip": "O login de terceiros usará automaticamente o e-mail do usuário para registrar uma conta (o mesmo e-mail será considerado a mesma conta). Você também pode definir a senha via “esqueci minha senha”.",
|
||||
"views.admin.AccountSettings.send_mail_limit_tip": "Aplica-se a todos os canais de envio. Use -1 para ilimitado e 0 para bloquear o envio.",
|
||||
"views.admin.AccountSettings.create_address_subdomain_match_note": "Isso é diferente de RANDOM_SUBDOMAIN_DOMAINS: esta opção permite informar subdomínios personalizados; o subdomínio aleatório apenas gera um durante a criação.",
|
||||
"views.admin.AccountSettings.create_address_subdomain_match_note": "RANDOM_SUBDOMAIN_DOMAINS já permite subdomínios aleatórios ou manuais nos domínios base listados. Esta opção também permite que a API informe subdomínios em outros domínios base permitidos.",
|
||||
"views.index.SendMail.tooLarge": "Arquivo muito grande; envie um arquivo menor que 1 MB.",
|
||||
"views.admin.SendMail.tooLarge": "Arquivo muito grande; envie um arquivo menor que 1 MB.",
|
||||
"views.common.Appearance.top": "topo",
|
||||
@@ -555,13 +587,13 @@ export const ptBRMessages = {
|
||||
"views.admin.UserOauth2Settings.userEmailFormatTip": "Use regex para transformar o e-mail. Exemplo: ^(.+)@old\\.com$ com $1@new.com",
|
||||
"views.common.Appearance.useSimpleIndex": "Usar índice simples",
|
||||
"views.common.Appearance.useUTCDate": "Usar data UTC",
|
||||
"views.Header.user": "Usuário",
|
||||
"views.Header.userCenter": "Central do usuário",
|
||||
"views.Admin.user": "Usuário",
|
||||
"components.AddressSelect.userAddresses": "Endereços do usuário",
|
||||
"views.Admin.loginViaUserAdmin": "Permissão de administrador do usuário",
|
||||
"components.AddressSelect.linkedMailboxes": "Caixas de correio vinculadas",
|
||||
"views.Admin.loginViaUserAdmin": "Conta de usuário (função de administrador)",
|
||||
"views.admin.Statistics.userCount": "Quantidade de usuários",
|
||||
"views.admin.UserManagement.user_email": "E-mail do usuário",
|
||||
"views.index.AddressBar.userLogin": "Login do usuário",
|
||||
"views.index.AddressBar.userCenter": "Central do usuário",
|
||||
"views.Admin.user_management": "Gerenciamento de usuários",
|
||||
"views.Admin.user_settings": "Configurações do usuário",
|
||||
"views.User.user_settings": "Configurações do usuário",
|
||||
@@ -582,6 +614,10 @@ export const ptBRMessages = {
|
||||
"views.admin.AiExtractSettings.disabledTip": "Quando desativado, a extração por IA processará todos os endereços",
|
||||
"views.admin.AiExtractSettings.enableAllowListTip": "Quando ativado, a extração por IA só processará e-mails enviados aos endereços permitidos",
|
||||
"views.admin.CreateAccount.randomSubdomainTip": "Quando ativado, o endereço criado usará um subdomínio aleatório. Recomendado apenas para recebimento. Requer um registro MX curinga no DNS do domínio base — consulte a documentação de subdomínios aleatórios.",
|
||||
"views.admin.CreateAccount.enableCustomSubdomain": "Usar subdomínio personalizado",
|
||||
"views.admin.CreateAccount.normalSubdomain": "Domínio normal",
|
||||
"views.common.Login.enableCustomSubdomain": "Usar subdomínio personalizado",
|
||||
"views.common.Login.normalSubdomain": "Domínio normal",
|
||||
"views.common.Login.randomSubdomainTip": "Quando ativado, o endereço criado usará um subdomínio aleatório. Recomendado apenas para recebimento. Requer um registro MX curinga no DNS do domínio base — consulte a documentação de subdomínios aleatórios.",
|
||||
"views.admin.AiExtractSettings.allowListTip": "O curinga * corresponde a quaisquer caracteres; ex.: *{'@'}example.com corresponde a todos os endereços do domínio example.com",
|
||||
"views.Admin.workerconfig": "Configuração do Worker",
|
||||
@@ -617,5 +653,15 @@ export const ptBRMessages = {
|
||||
"components.AddressCredentialModal.starttls": "STARTTLS",
|
||||
"components.AddressCredentialModal.tip": "Use estas credenciais somente com clientes e agents confiáveis.",
|
||||
"components.AddressCredentialModal.title": "Credenciais do endereço e métodos de conexão",
|
||||
"components.AddressCredentialModal.username": "Nome de usuário"
|
||||
"components.AddressCredentialModal.username": "Nome de usuário",
|
||||
"views.User.send_mail": "Enviar e-mail",
|
||||
"views.user.UserSendBox.noAddress": "Selecione um endereço de e-mail vinculado",
|
||||
"views.user.UserSendBox.sendbox": "Enviados",
|
||||
"components.MailContentRenderer.markAsRead": "Marcar como lido",
|
||||
"components.MailContentRenderer.markAsUnread": "Marcar como não lido",
|
||||
"components.MailContentRenderer.loadRemoteImages": "Carregar imagens",
|
||||
"components.MailContentRenderer.remoteImagesBlocked": "{count} recursos externos foram bloqueados para proteger sua privacidade",
|
||||
"views.user.AddressManagement.itemCount": "Total",
|
||||
"views.index.SimpleIndex.readStatusUpdated": "Status de leitura atualizado",
|
||||
"views.common.Appearance.autoLoadRemoteImages": "Carregar automaticamente imagens externas no corpo do e-mail"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -120,11 +121,11 @@ onMounted(async () => {
|
||||
</n-modal>
|
||||
<n-tabs v-if="showAdminPage" type="card" v-model:value="adminTab" :placement="globalTabplacement">
|
||||
<n-tab-pane name="qucickSetup" :tab="t('qucickSetup')">
|
||||
<n-tabs type="bar" justify-content="center" animated>
|
||||
<n-tabs key="quick-setup-tabs" type="bar" justify-content="center" animated>
|
||||
<n-tab-pane name="database" :tab="t('database')">
|
||||
<DatabaseManager />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="account_settings" :tab="t('account_settings')">
|
||||
<n-tab-pane name="account_settings" :tab="t('mailbox_settings')">
|
||||
<AccountSettings />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="user_settings" :tab="t('user_settings')">
|
||||
@@ -135,15 +136,15 @@ onMounted(async () => {
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="account" :tab="t('account')">
|
||||
<n-tabs type="bar" justify-content="center" animated>
|
||||
<n-tab-pane name="account" :tab="t('account')">
|
||||
<n-tab-pane name="account" :tab="t('mailbox_management')">
|
||||
<n-tabs key="account-tabs" type="bar" justify-content="center" animated>
|
||||
<n-tab-pane name="account" :tab="t('mailbox_list')">
|
||||
<Account />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="account_create" :tab="t('account_create')">
|
||||
<n-tab-pane name="account_create" :tab="t('mailbox_create')">
|
||||
<CreateAccount />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="account_settings" :tab="t('account_settings')">
|
||||
<n-tab-pane name="account_settings" :tab="t('mailbox_settings')">
|
||||
<AccountSettings />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="senderAccess" :tab="t('senderAccess')">
|
||||
@@ -161,7 +162,7 @@ onMounted(async () => {
|
||||
</n-tabs>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="user" :tab="t('user')">
|
||||
<n-tabs type="bar" justify-content="center" animated>
|
||||
<n-tabs key="user-tabs" type="bar" justify-content="center" animated>
|
||||
<n-tab-pane name="user_management" :tab="t('user_management')">
|
||||
<UserManagement />
|
||||
</n-tab-pane>
|
||||
@@ -177,7 +178,7 @@ onMounted(async () => {
|
||||
</n-tabs>
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="mails" :tab="t('mails')">
|
||||
<n-tabs type="bar" justify-content="center" animated>
|
||||
<n-tabs key="mails-tabs" type="bar" justify-content="center" animated>
|
||||
<n-tab-pane name="mails" :tab="t('mails')">
|
||||
<Mails />
|
||||
</n-tab-pane>
|
||||
@@ -201,8 +202,11 @@ onMounted(async () => {
|
||||
<n-tab-pane name="statistics" :tab="t('statistics')">
|
||||
<Statistics />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane v-if="openSettings.enableRedeemCode" name="redeemCodes" :tab="t('redeemCodes')">
|
||||
<RedeemCodes />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="maintenance" :tab="t('maintenance')">
|
||||
<n-tabs type="bar" justify-content="center" animated>
|
||||
<n-tabs key="maintenance-tabs" type="bar" justify-content="center" animated>
|
||||
<n-tab-pane name="database" :tab="t('database')">
|
||||
<DatabaseManager />
|
||||
</n-tab-pane>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -14,7 +14,8 @@ const message = useMessage()
|
||||
const { t } = useScopedI18n('views.admin.CreateAccount')
|
||||
|
||||
const enablePrefix = ref(true)
|
||||
const enableRandomSubdomain = ref(false)
|
||||
const subdomainMode = ref("normal")
|
||||
const customSubdomain = ref("")
|
||||
const emailName = ref("")
|
||||
const emailDomain = ref("")
|
||||
const showReultModal = ref(false)
|
||||
@@ -22,6 +23,40 @@ const result = ref("")
|
||||
const addressPassword = ref("")
|
||||
const createdAddress = ref("")
|
||||
|
||||
const addressRegex = computed(() => {
|
||||
try {
|
||||
if (openSettings.value.addressRegex) {
|
||||
return new RegExp(openSettings.value.addressRegex, 'g');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
message.error(`Invalid addressRegex: ${openSettings.value.addressRegex}`);
|
||||
}
|
||||
return /[^a-z0-9]/g;
|
||||
});
|
||||
|
||||
const generateNameLoading = ref(false);
|
||||
const generateName = async () => {
|
||||
try {
|
||||
generateNameLoading.value = true;
|
||||
const { faker } = await import('https://esm.sh/@faker-js/faker');
|
||||
emailName.value = faker.internet.email()
|
||||
.split('@')[0]
|
||||
.replace(/\s+/g, '.')
|
||||
.replace(/\.{2,}/g, '.')
|
||||
.replace(addressRegex.value, '')
|
||||
.toLowerCase();
|
||||
// support maxAddressLen
|
||||
if (emailName.value.length > openSettings.value.maxAddressLen) {
|
||||
emailName.value = emailName.value.slice(0, openSettings.value.maxAddressLen);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
} finally {
|
||||
generateNameLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const canUseRandomSubdomain = computed(() => {
|
||||
if (!emailDomain.value) {
|
||||
return false
|
||||
@@ -31,7 +66,7 @@ const canUseRandomSubdomain = computed(() => {
|
||||
|
||||
watch(canUseRandomSubdomain, (enabled) => {
|
||||
if (!enabled) {
|
||||
enableRandomSubdomain.value = false
|
||||
subdomainMode.value = "normal"
|
||||
}
|
||||
})
|
||||
|
||||
@@ -41,13 +76,16 @@ const newEmail = async () => {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const domain = subdomainMode.value === "custom"
|
||||
? `${customSubdomain.value.trim()}.${emailDomain.value}`
|
||||
: emailDomain.value
|
||||
const res = await api.fetch(`/admin/new_address`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
enablePrefix: enablePrefix.value,
|
||||
enableRandomSubdomain: enableRandomSubdomain.value,
|
||||
enableRandomSubdomain: subdomainMode.value === "random",
|
||||
name: emailName.value,
|
||||
domain: emailDomain.value,
|
||||
domain,
|
||||
})
|
||||
})
|
||||
result.value = res["jwt"];
|
||||
@@ -77,26 +115,44 @@ onMounted(async () => {
|
||||
<n-switch v-model:value="enablePrefix" :round="false" />
|
||||
</n-form-item-row>
|
||||
<n-form-item-row :label="t('address')">
|
||||
<n-input-group>
|
||||
<n-input-group-label v-if="enablePrefix && openSettings.prefix">
|
||||
{{ openSettings.prefix }}
|
||||
</n-input-group-label>
|
||||
<n-input v-model:value="emailName" />
|
||||
<n-input-group-label>@</n-input-group-label>
|
||||
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
|
||||
:options="openSettings.domains" />
|
||||
</n-input-group>
|
||||
<n-spin :show="generateNameLoading" style="width: 100%;">
|
||||
<div>
|
||||
<n-button @click="generateName" style="margin-bottom: 10px;">
|
||||
{{ t('generateName') }}
|
||||
</n-button>
|
||||
<n-input-group>
|
||||
<n-input-group-label v-if="enablePrefix && openSettings.prefix">
|
||||
{{ openSettings.prefix }}
|
||||
</n-input-group-label>
|
||||
<n-input v-model:value="emailName" />
|
||||
<n-input-group-label>@</n-input-group-label>
|
||||
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
|
||||
:options="openSettings.domains" />
|
||||
</n-input-group>
|
||||
</div>
|
||||
</n-spin>
|
||||
</n-form-item-row>
|
||||
<n-form-item-row v-if="canUseRandomSubdomain">
|
||||
<n-checkbox v-model:checked="enableRandomSubdomain">
|
||||
{{ t('enableRandomSubdomain') }}
|
||||
</n-checkbox>
|
||||
<p style="margin: 8px 0 0; opacity: 0.75;">
|
||||
{{ t('randomSubdomainTip') }}
|
||||
</p>
|
||||
<div style="width: 100%;">
|
||||
<n-radio-group v-model:value="subdomainMode">
|
||||
<n-space vertical>
|
||||
<n-radio value="normal">{{ t('normalSubdomain') }}</n-radio>
|
||||
<n-radio value="random">{{ t('enableRandomSubdomain') }}</n-radio>
|
||||
<n-radio value="custom">{{ t('enableCustomSubdomain') }}</n-radio>
|
||||
</n-space>
|
||||
</n-radio-group>
|
||||
<p v-if="subdomainMode === 'random'" style="margin: 8px 0 0; opacity: 0.75;">
|
||||
{{ t('randomSubdomainTip') }}
|
||||
</p>
|
||||
<n-input-group v-if="subdomainMode === 'custom'" style="margin-top: 8px;">
|
||||
<n-input v-model:value="customSubdomain" />
|
||||
<n-input-group-label>.{{ emailDomain }}</n-input-group-label>
|
||||
</n-input-group>
|
||||
</div>
|
||||
</n-form-item-row>
|
||||
<n-button @click="newEmail" type="primary" block :loading="loading">
|
||||
{{ t('creatNewEmail') }}
|
||||
<n-button @click="newEmail" type="primary" block :loading="loading"
|
||||
:disabled="subdomainMode === 'custom' && !customSubdomain.trim()">
|
||||
{{ t('createEmailAddress') }}
|
||||
</n-button>
|
||||
</n-card>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
import { CleaningServicesFilled } from '@vicons/material'
|
||||
|
||||
@@ -7,24 +7,95 @@ import { api } from '../../api'
|
||||
import { init } from 'vooks/lib/on-fonts-ready';
|
||||
|
||||
const message = useMessage()
|
||||
const D1_STORAGE_PLAN_CONFIG_KEY = 'd1_storage_plan'
|
||||
const dbVersionData = ref({
|
||||
need_initialization: false,
|
||||
need_migration: false,
|
||||
current_db_version: '',
|
||||
code_db_version: ''
|
||||
code_db_version: '',
|
||||
database_size: null
|
||||
})
|
||||
const selectedPlan = ref(null)
|
||||
const savedPlan = ref(null)
|
||||
const savingPlan = ref(false)
|
||||
|
||||
const planOptions = computed(() => [
|
||||
{
|
||||
label: t('free_plan'),
|
||||
value: 'free',
|
||||
databaseLimit: 500 * 1024 ** 2
|
||||
},
|
||||
{
|
||||
label: t('paid_plan'),
|
||||
value: 'paid',
|
||||
databaseLimit: 10 * 1024 ** 3
|
||||
}
|
||||
])
|
||||
|
||||
const selectedPlanDetails = computed(() => (
|
||||
planOptions.value.find((plan) => plan.value === selectedPlan.value)
|
||||
))
|
||||
|
||||
const storagePercentage = computed(() => {
|
||||
if (!selectedPlanDetails.value || dbVersionData.value.database_size === null) return 0
|
||||
return dbVersionData.value.database_size / selectedPlanDetails.value.databaseLimit * 100
|
||||
})
|
||||
|
||||
const progressPercentage = computed(() => Math.min(storagePercentage.value, 100))
|
||||
|
||||
const progressStatus = computed(() => {
|
||||
if (storagePercentage.value >= 90) return 'error'
|
||||
if (storagePercentage.value >= 75) return 'warning'
|
||||
return 'success'
|
||||
})
|
||||
|
||||
const { t } = useScopedI18n('views.admin.DatabaseManager')
|
||||
|
||||
const formatBytes = (bytes) => {
|
||||
if (bytes === null || bytes === undefined) return t('unavailable')
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const unitIndex = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
||||
const value = bytes / 1024 ** unitIndex
|
||||
return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${units[unitIndex]}`
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const res = await api.fetch('/admin/db_version');
|
||||
if (res) Object.assign(dbVersionData.value, res);
|
||||
const [versionRes, configRes] = await Promise.all([
|
||||
api.fetch('/admin/db_version'),
|
||||
api.fetch(`/admin/config/${D1_STORAGE_PLAN_CONFIG_KEY}`)
|
||||
]);
|
||||
if (versionRes) Object.assign(dbVersionData.value, versionRes);
|
||||
|
||||
const configuredPlan = configRes?.value
|
||||
if (planOptions.value.some((plan) => plan.value === configuredPlan)) {
|
||||
selectedPlan.value = configuredPlan
|
||||
savedPlan.value = configuredPlan
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
}
|
||||
}
|
||||
|
||||
const savePlan = async (plan) => {
|
||||
savingPlan.value = true
|
||||
try {
|
||||
await api.fetch('/admin/config', {
|
||||
method: 'POST',
|
||||
body: { key: D1_STORAGE_PLAN_CONFIG_KEY, value: plan }
|
||||
})
|
||||
savedPlan.value = plan
|
||||
message.success(t('planSaved'))
|
||||
} catch (error) {
|
||||
selectedPlan.value = savedPlan.value
|
||||
message.error(error.message || "error")
|
||||
} finally {
|
||||
savingPlan.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const initialization = async () => {
|
||||
try {
|
||||
await api.fetch('/admin/db_initialize', {
|
||||
@@ -77,6 +148,58 @@ onMounted(async () => {
|
||||
</span>
|
||||
</n-alert>
|
||||
|
||||
<div class="storage-panel">
|
||||
<div class="storage-heading">
|
||||
<div>
|
||||
<h3>{{ t('storage_title') }}</h3>
|
||||
<p>{{ t('storage_description') }}</p>
|
||||
</div>
|
||||
<div class="plan-select">
|
||||
<span>{{ t('plan') }}</span>
|
||||
<n-select
|
||||
v-model:value="selectedPlan"
|
||||
:options="planOptions"
|
||||
:placeholder="t('plan_placeholder')"
|
||||
:disabled="dbVersionData.need_initialization"
|
||||
:loading="savingPlan"
|
||||
@update:value="savePlan"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-grid cols="1 s:2" responsive="screen" :x-gap="12" :y-gap="12">
|
||||
<n-grid-item>
|
||||
<div class="storage-stat">
|
||||
<span>{{ t('current_database_size') }}</span>
|
||||
<strong>{{ formatBytes(dbVersionData.database_size) }}</strong>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<div class="storage-stat">
|
||||
<span>{{ t('single_database_limit') }}</span>
|
||||
<strong>{{ selectedPlanDetails ? formatBytes(selectedPlanDetails.databaseLimit) : '—' }}</strong>
|
||||
</div>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<div v-if="selectedPlanDetails" class="storage-progress">
|
||||
<div class="storage-progress-label">
|
||||
<span>{{ t('storage_usage') }}</span>
|
||||
<span>{{ storagePercentage.toFixed(2) }}%</span>
|
||||
</div>
|
||||
<n-progress
|
||||
type="line"
|
||||
:percentage="progressPercentage"
|
||||
:status="progressStatus"
|
||||
:show-indicator="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<n-alert class="storage-tip" type="default" :show-icon="false" :bordered="false">
|
||||
{{ t('storage_tip') }}
|
||||
</n-alert>
|
||||
</div>
|
||||
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -100,4 +223,88 @@ onMounted(async () => {
|
||||
.n-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.storage-panel {
|
||||
margin-top: 18px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--n-border-color);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.storage-heading {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.storage-heading h3,
|
||||
.storage-heading p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.storage-heading p {
|
||||
margin-top: 4px;
|
||||
color: var(--n-text-color-3);
|
||||
}
|
||||
|
||||
.plan-select {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.plan-select > span {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--n-text-color-2);
|
||||
}
|
||||
|
||||
.storage-stat {
|
||||
display: flex;
|
||||
min-height: 72px;
|
||||
padding: 14px;
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
border: 1px solid var(--n-border-color);
|
||||
border-radius: var(--n-border-radius);
|
||||
}
|
||||
|
||||
.storage-stat span {
|
||||
color: var(--n-text-color-3);
|
||||
}
|
||||
|
||||
.storage-stat strong {
|
||||
margin-top: 8px;
|
||||
font-size: 18px;
|
||||
color: var(--n-text-color);
|
||||
}
|
||||
|
||||
.storage-progress {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.storage-progress-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
color: var(--n-text-color-2);
|
||||
}
|
||||
|
||||
.storage-tip {
|
||||
margin-top: 16px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.storage-heading {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.plan-select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('toName')" label-placement="top">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="sendMailModel.toName" />
|
||||
<n-input v-model:value="sendMailModel.toMail" />
|
||||
</n-input-group>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('subject')" label-placement="top">
|
||||
<n-input v-model:value="sendMailModel.subject" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('options')" label-placement="top">
|
||||
<n-radio-group v-model:value="sendMailModel.contentType">
|
||||
<n-radio-button v-for="option in contentTypes" :key="option.value" :value="option.value"
|
||||
:label="option.label" />
|
||||
</n-radio-group>
|
||||
<n-button v-if="sendMailModel.contentType != 'text'" @click="isPreview = !isPreview"
|
||||
style="margin-left: 10px;">
|
||||
{{ isPreview ? t('edit') : t('preview') }}
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('content')" label-placement="top">
|
||||
<n-card :bordered="false" embedded v-if="isPreview">
|
||||
<div v-html="sendMailModel.content" />
|
||||
</n-card>
|
||||
<div v-else-if="sendMailModel.contentType == 'rich'" style="border: 1px solid #ccc">
|
||||
<Toolbar style="border-bottom: 1px solid #ccc" :defaultConfig="toolbarConfig"
|
||||
:editor="editorRef" mode="default" />
|
||||
<Editor style="height: 500px; overflow-y: hidden;" v-model="sendMailModel.content"
|
||||
:defaultConfig="editorConfig" mode="default" @onCreated="handleCreated" />
|
||||
<div class="composer-page">
|
||||
<n-card class="composer-card" :bordered="false" embedded>
|
||||
<template #header>
|
||||
<div class="composer-title">
|
||||
<h2>{{ t('composeMail') }}</h2>
|
||||
<n-text depth="3">{{ t('adminComposeTip') }}</n-text>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<n-form class="composer-form" :model="sendMailModel" label-placement="top">
|
||||
<n-grid cols="1 m:2" responsive="screen" :x-gap="16">
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('senderAddress')" required
|
||||
:label-props="{ for: 'admin-send-mail-sender-address' }">
|
||||
<n-input v-model:value="sendMailModel.fromMail"
|
||||
:input-props="{ id: 'admin-send-mail-sender-address' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('senderName')"
|
||||
:label-props="{ for: 'admin-send-mail-sender-name' }">
|
||||
<n-input v-model:value="sendMailModel.fromName"
|
||||
:input-props="{ id: 'admin-send-mail-sender-name' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('recipientAddress')" required
|
||||
:label-props="{ for: 'admin-send-mail-recipient-address' }">
|
||||
<n-input v-model:value="sendMailModel.toMail"
|
||||
:input-props="{ id: 'admin-send-mail-recipient-address' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('recipientName')"
|
||||
:label-props="{ for: 'admin-send-mail-recipient-name' }">
|
||||
<n-input v-model:value="sendMailModel.toName"
|
||||
:input-props="{ id: 'admin-send-mail-recipient-name' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-form-item :label="t('subject')" required
|
||||
:label-props="{ for: 'admin-send-mail-subject' }">
|
||||
<n-input v-model:value="sendMailModel.subject"
|
||||
:input-props="{ id: 'admin-send-mail-subject' }" />
|
||||
</n-form-item>
|
||||
|
||||
<div class="editor-panel">
|
||||
<div class="editor-panel-header">
|
||||
<n-text id="admin-send-mail-content-label" strong>{{ t('content') }} <span
|
||||
class="required-mark">*</span></n-text>
|
||||
<div class="editor-controls">
|
||||
<n-radio-group class="format-options" v-model:value="sendMailModel.contentType"
|
||||
size="small" aria-labelledby="admin-send-mail-content-label">
|
||||
<n-radio-button v-for="option in contentTypes" :key="option.value"
|
||||
:value="option.value" :label="option.label" />
|
||||
</n-radio-group>
|
||||
<n-button v-if="sendMailModel.contentType !== 'text'" tertiary size="small"
|
||||
@click="isPreview = !isPreview">
|
||||
{{ isPreview ? t('edit') : t('preview') }}
|
||||
</n-button>
|
||||
</div>
|
||||
<n-input v-else type="textarea" v-model:value="sendMailModel.content" :autosize="{
|
||||
minRows: 3
|
||||
}" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isPreview && sendMailModel.contentType !== 'text'" class="compose-preview">
|
||||
<ShadowHtmlComponent :htmlContent="previewContent" :isDark="isDark" />
|
||||
</div>
|
||||
<div v-else-if="sendMailModel.contentType === 'rich'" class="rich-editor">
|
||||
<Toolbar :defaultConfig="toolbarConfig" :editor="editorRef" mode="default" />
|
||||
<Editor v-model="sendMailModel.content" :defaultConfig="editorConfig" mode="default"
|
||||
@onCreated="handleCreated" />
|
||||
</div>
|
||||
<n-input v-else class="compose-textarea" type="textarea" :bordered="false"
|
||||
v-model:value="sendMailModel.content" :placeholder="t('contentPlaceholder')"
|
||||
:input-props="{ 'aria-label': t('content') }"
|
||||
:autosize="{ minRows: 14, maxRows: 24 }" />
|
||||
</div>
|
||||
|
||||
<div class="composer-actions">
|
||||
<n-text depth="3" class="draft-status">{{ t('draftSaved') }}</n-text>
|
||||
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">
|
||||
<template #icon><n-icon :component="SendRound" /></template>
|
||||
{{ t('send') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</n-form>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.n-card {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
.composer-page {
|
||||
width: 100%;
|
||||
padding: 14px 0 24px;
|
||||
text-align: left;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.center {
|
||||
.composer-card {
|
||||
width: min(900px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.composer-title {
|
||||
display: flex;
|
||||
text-align: center;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.left {
|
||||
.composer-title > h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.composer-title .n-text {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(128, 128, 128, 0.24);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.editor-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 46px;
|
||||
padding: 6px 10px 6px 14px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.editor-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.required-mark {
|
||||
color: #d03050;
|
||||
}
|
||||
|
||||
.format-options {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.format-options :deep(.n-radio-button) {
|
||||
min-width: 72px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.compose-preview {
|
||||
min-height: 360px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.rich-editor {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-toolbar) {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-text-container),
|
||||
.rich-editor :deep(.w-e-scroll) {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.compose-textarea :deep(.n-input__textarea-el),
|
||||
.compose-textarea :deep(.n-input__placeholder) {
|
||||
line-height: 1.7;
|
||||
text-align: left;
|
||||
place-items: left;
|
||||
justify-content: left;
|
||||
}
|
||||
|
||||
.composer-form :deep(.n-input__input-el) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.draft-status {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.composer-page {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.editor-panel-header {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.editor-controls {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.format-options {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.format-options :deep(.n-radio-button) {
|
||||
min-width: 0;
|
||||
padding-right: 8px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-toolbar) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -32,7 +32,7 @@ const fetchData = async () => {
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('name'),
|
||||
title: t('emailAddress'),
|
||||
key: "name"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useScopedI18n } from '@/i18n/app'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { NewLabelOutlined, EmailOutlined } from '@vicons/material'
|
||||
import { NewLabelOutlined, EmailOutlined, RedeemOutlined } from '@vicons/material'
|
||||
|
||||
import AdminContact from '../common/AdminContact.vue'
|
||||
import Turnstile from '../../components/Turnstile.vue'
|
||||
@@ -48,7 +48,8 @@ const credential = ref('')
|
||||
const emailName = ref("")
|
||||
const emailDomain = ref("")
|
||||
const cfToken = ref("")
|
||||
const enableRandomSubdomain = ref(false)
|
||||
const subdomainMode = ref("normal")
|
||||
const customSubdomain = ref("")
|
||||
const loginCfToken = ref("")
|
||||
const loginTurnstileRef = ref(null)
|
||||
const loginMethod = ref('credential') // 'credential' or 'password'
|
||||
@@ -65,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) {
|
||||
@@ -167,11 +169,14 @@ const newEmail = async () => {
|
||||
try {
|
||||
// If custom names are disabled, send empty name to trigger backend auto-generation
|
||||
const nameToSend = openSettings.value.disableCustomAddressName ? "" : emailName.value;
|
||||
const domainToSend = subdomainMode.value === "custom"
|
||||
? `${customSubdomain.value.trim()}.${emailDomain.value}`
|
||||
: emailDomain.value;
|
||||
const res = await props.newAddressPath(
|
||||
nameToSend,
|
||||
emailDomain.value,
|
||||
domainToSend,
|
||||
cfToken.value,
|
||||
enableRandomSubdomain.value
|
||||
subdomainMode.value === "random"
|
||||
);
|
||||
jwt.value = res["jwt"];
|
||||
addressPassword.value = res["password"] || '';
|
||||
@@ -188,6 +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) {
|
||||
@@ -206,7 +215,7 @@ const canUseRandomSubdomain = computed(() => {
|
||||
|
||||
watch(canUseRandomSubdomain, (enabled) => {
|
||||
if (!enabled) {
|
||||
enableRandomSubdomain.value = false;
|
||||
subdomainMode.value = "normal";
|
||||
}
|
||||
});
|
||||
|
||||
@@ -238,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);
|
||||
@@ -275,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') }}
|
||||
@@ -294,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')">
|
||||
@@ -321,20 +342,42 @@ onMounted(async () => {
|
||||
:options="domainsOptions" />
|
||||
</n-input-group>
|
||||
<n-form-item-row v-if="canUseRandomSubdomain">
|
||||
<n-checkbox v-model:checked="enableRandomSubdomain">
|
||||
{{ t('enableRandomSubdomain') }}
|
||||
</n-checkbox>
|
||||
<p style="margin: 8px 0 0; opacity: 0.75;">
|
||||
{{ t('randomSubdomainTip') }}
|
||||
</p>
|
||||
<div style="width: 100%;">
|
||||
<n-radio-group v-model:value="subdomainMode">
|
||||
<n-space vertical>
|
||||
<n-radio value="normal">{{ t('normalSubdomain') }}</n-radio>
|
||||
<n-radio value="random">{{ t('enableRandomSubdomain') }}</n-radio>
|
||||
<n-radio value="custom">{{ t('enableCustomSubdomain') }}</n-radio>
|
||||
</n-space>
|
||||
</n-radio-group>
|
||||
<p v-if="subdomainMode === 'random'" style="margin: 8px 0 0; opacity: 0.75;">
|
||||
{{ t('randomSubdomainTip') }}
|
||||
</p>
|
||||
<n-input-group v-if="subdomainMode === 'custom'" style="margin-top: 8px;">
|
||||
<n-input v-model:value="customSubdomain" />
|
||||
<n-input-group-label>.{{ emailDomain }}</n-input-group-label>
|
||||
</n-input-group>
|
||||
</div>
|
||||
</n-form-item-row>
|
||||
<Turnstile v-model:value="cfToken" />
|
||||
<n-button type="primary" block secondary strong @click="newEmail" :loading="loading">
|
||||
<n-button type="primary" block secondary strong @click="newEmail" :loading="loading"
|
||||
:disabled="subdomainMode === 'custom' && !customSubdomain.trim()">
|
||||
<template #icon>
|
||||
<n-icon :component="NewLabelOutlined" />
|
||||
</template>
|
||||
{{ t('getNewEmail') }}
|
||||
</n-button>
|
||||
<div v-if="openSettings.enableRedeemCode" class="redeem-entry-section">
|
||||
<n-divider />
|
||||
<n-text type="info" class="redeem-entry-tip">{{ t('redeemEntryTip') }}</n-text>
|
||||
<n-button data-testid="redeem-entry-register" block secondary strong
|
||||
@click="openRedeemPage">
|
||||
<template #icon>
|
||||
<n-icon :component="RedeemOutlined" />
|
||||
</template>
|
||||
{{ t('useRedeemCode') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</n-form>
|
||||
</n-spin>
|
||||
</n-tab-pane>
|
||||
@@ -360,6 +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 {
|
||||
valid: true,
|
||||
address: payload.address,
|
||||
jwt: curJwt
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
valid: false,
|
||||
address: `invalid jwt [${curJwt}]`,
|
||||
jwt: curJwt
|
||||
}
|
||||
return getCachedAddresses(localAddressCache.value).map(({ token, address, type }, index) => {
|
||||
const isPasswordLogin = type === 'address_password_login';
|
||||
if (address && openSettings.value.addressPasswordLoginOnly && !isPasswordLogin) return null;
|
||||
return {
|
||||
address: address
|
||||
? `${address} (${loginT(isPasswordLogin ? 'passwordLogin' : 'credentialLogin')})`
|
||||
: t('savedMailbox', { index: index + 1 }),
|
||||
jwt: token
|
||||
}
|
||||
})
|
||||
|
||||
}).filter(Boolean)
|
||||
})
|
||||
|
||||
const bindAddress = async () => {
|
||||
try {
|
||||
// @ts-ignore
|
||||
if (!localAddressCache.value.includes(jwt.value)) {
|
||||
// @ts-ignore
|
||||
localAddressCache.value.push(jwt.value)
|
||||
}
|
||||
tabValue.value = 'address'
|
||||
message.success(t('bindAddressSuccess'));
|
||||
} catch (error) {
|
||||
message.error((error as Error).message || "error");
|
||||
}
|
||||
const bindAddress = () => {
|
||||
tabValue.value = 'address'
|
||||
message.success(t('bindAddressSuccess'));
|
||||
}
|
||||
|
||||
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
|
||||
@@ -116,7 +130,7 @@ const requestAccess = async () => {
|
||||
body: JSON.stringify({})
|
||||
}
|
||||
)
|
||||
message.success(t("success"))
|
||||
message.success(t("requestSuccess"))
|
||||
await api.getSettings();
|
||||
} catch (error) {
|
||||
message.error(error.message || "error");
|
||||
@@ -157,95 +171,292 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center" v-if="settings.address">
|
||||
<n-card :bordered="false" embedded>
|
||||
<div v-if="!settings.send_balance || settings.send_balance <= 0">
|
||||
<n-alert type="warning" :show-icon="false" :bordered="false">
|
||||
{{ t('requestAccessTip') }}
|
||||
<n-button type="primary" tertiary @click="requestAccess" size="small">{{ t('requestAccess')
|
||||
}}</n-button>
|
||||
</n-alert>
|
||||
<AdminContact />
|
||||
</div>
|
||||
<div v-else>
|
||||
<n-alert type="info" :show-icon="false" :bordered="false" closable>
|
||||
{{ t('send_balance') }}: {{ settings.send_balance }}
|
||||
</n-alert>
|
||||
<n-flex justify="end">
|
||||
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">{{ t('send') }}</n-button>
|
||||
</n-flex>
|
||||
<div class="left">
|
||||
<n-form :model="sendMailModel">
|
||||
<n-form-item :label="t('fromName')" label-placement="top">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="sendMailModel.fromName" />
|
||||
<n-input :value="settings.address" disabled />
|
||||
</n-input-group>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('toName')" label-placement="top">
|
||||
<n-input-group>
|
||||
<n-input v-model:value="sendMailModel.toName" />
|
||||
<n-input v-model:value="sendMailModel.toMail" />
|
||||
</n-input-group>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('subject')" label-placement="top">
|
||||
<n-input v-model:value="sendMailModel.subject" />
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('options')" label-placement="top">
|
||||
<n-radio-group v-model:value="sendMailModel.contentType">
|
||||
<n-radio-button v-for="option in contentTypes" :key="option.value" :value="option.value"
|
||||
:label="option.label" />
|
||||
</n-radio-group>
|
||||
<n-button v-if="sendMailModel.contentType != 'text'" @click="isPreview = !isPreview"
|
||||
style="margin-left: 10px;">
|
||||
{{ isPreview ? t('edit') : t('preview') }}
|
||||
</n-button>
|
||||
</n-form-item>
|
||||
<n-form-item :label="t('content')" label-placement="top">
|
||||
<n-card :bordered="false" embedded v-if="isPreview">
|
||||
<div v-html="sendMailModel.content" />
|
||||
</n-card>
|
||||
<div v-else-if="sendMailModel.contentType == 'rich'" style="border: 1px solid #ccc">
|
||||
<Toolbar style="border-bottom: 1px solid #ccc" :defaultConfig="toolbarConfig"
|
||||
:editor="editorRef" mode="default" />
|
||||
<Editor style="height: 500px; overflow-y: hidden;" v-model="sendMailModel.content"
|
||||
:defaultConfig="editorConfig" mode="default" @onCreated="handleCreated" />
|
||||
</div>
|
||||
<n-input v-else type="textarea" v-model:value="sendMailModel.content" :autosize="{
|
||||
minRows: 3
|
||||
}" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<div class="composer-page" v-if="settings.address">
|
||||
<n-card class="composer-card" :bordered="false" embedded>
|
||||
<template #header>
|
||||
<div class="composer-title">
|
||||
<h2>{{ t('composeMail') }}</h2>
|
||||
<n-text depth="3">{{ settings.address }}</n-text>
|
||||
</div>
|
||||
</template>
|
||||
<template #header-extra>
|
||||
<n-tag v-if="settings.send_balance > 0" type="success" round :bordered="false">
|
||||
{{ t('send_balance') }} · {{ settings.send_balance }}
|
||||
</n-tag>
|
||||
</template>
|
||||
|
||||
<div v-if="!settings.send_balance || settings.send_balance <= 0">
|
||||
<div class="access-state">
|
||||
<div class="access-copy">
|
||||
<h3>{{ t('balanceUnavailable') }}</h3>
|
||||
<p>{{ t('requestAccessTip', { address: settings.address }) }}</p>
|
||||
</div>
|
||||
<n-button type="primary" @click="requestAccess">{{ t('requestAccess') }}</n-button>
|
||||
</div>
|
||||
<div class="admin-contact"><AdminContact /></div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<n-form class="composer-form" :model="sendMailModel" label-placement="top">
|
||||
<n-grid cols="1 m:2" responsive="screen" :x-gap="16">
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('senderAddress')" :label-props="{ for: 'send-mail-sender-address' }">
|
||||
<n-input :value="settings.address" readonly
|
||||
:input-props="{ id: 'send-mail-sender-address' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('senderName')" :label-props="{ for: 'send-mail-sender-name' }">
|
||||
<n-input v-model:value="sendMailModel.fromName"
|
||||
:input-props="{ id: 'send-mail-sender-name' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('recipientAddress')" required
|
||||
:label-props="{ for: 'send-mail-recipient-address' }">
|
||||
<n-input v-model:value="sendMailModel.toMail"
|
||||
:input-props="{ id: 'send-mail-recipient-address' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-form-item :label="t('recipientName')" :label-props="{ for: 'send-mail-recipient-name' }">
|
||||
<n-input v-model:value="sendMailModel.toName"
|
||||
:input-props="{ id: 'send-mail-recipient-name' }" />
|
||||
</n-form-item>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
<n-form-item :label="t('subject')" required :label-props="{ for: 'send-mail-subject' }">
|
||||
<n-input v-model:value="sendMailModel.subject"
|
||||
:input-props="{ id: 'send-mail-subject' }" />
|
||||
</n-form-item>
|
||||
|
||||
<div class="editor-panel">
|
||||
<div class="editor-panel-header">
|
||||
<n-text id="send-mail-content-label" strong>{{ t('content') }} <span
|
||||
class="required-mark">*</span></n-text>
|
||||
<div class="editor-controls">
|
||||
<n-radio-group class="format-options" v-model:value="sendMailModel.contentType"
|
||||
size="small" aria-labelledby="send-mail-content-label">
|
||||
<n-radio-button v-for="option in contentTypes" :key="option.value"
|
||||
:value="option.value" :label="option.label" />
|
||||
</n-radio-group>
|
||||
<n-button v-if="sendMailModel.contentType !== 'text'" tertiary size="small"
|
||||
@click="isPreview = !isPreview">
|
||||
{{ isPreview ? t('edit') : t('preview') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isPreview && sendMailModel.contentType !== 'text'" class="compose-preview">
|
||||
<ShadowHtmlComponent :htmlContent="previewContent" :isDark="isDark" />
|
||||
</div>
|
||||
<div v-else-if="sendMailModel.contentType === 'rich'" class="rich-editor">
|
||||
<Toolbar :defaultConfig="toolbarConfig" :editor="editorRef" mode="default" />
|
||||
<Editor v-model="sendMailModel.content" :defaultConfig="editorConfig" mode="default"
|
||||
@onCreated="handleCreated" />
|
||||
</div>
|
||||
<n-input v-else class="compose-textarea" type="textarea" :bordered="false"
|
||||
v-model:value="sendMailModel.content" :placeholder="t('contentPlaceholder')"
|
||||
:input-props="{ 'aria-label': t('content') }"
|
||||
:autosize="{ minRows: 14, maxRows: 24 }" />
|
||||
</div>
|
||||
|
||||
<div class="composer-actions">
|
||||
<n-text depth="3" class="draft-status">{{ t('draftSaved') }}</n-text>
|
||||
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">
|
||||
<template #icon><n-icon :component="SendRound" /></template>
|
||||
{{ t('send') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</n-form>
|
||||
</template>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.n-card {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.n-button {
|
||||
.composer-page {
|
||||
width: 100%;
|
||||
padding: 14px 0 24px;
|
||||
text-align: left;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.center {
|
||||
.composer-card {
|
||||
width: min(900px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.composer-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.composer-title > h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.composer-title .n-text {
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.access-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 6px 0 18px;
|
||||
}
|
||||
|
||||
.access-copy h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.access-copy p {
|
||||
max-width: 640px;
|
||||
margin: 8px 0 0;
|
||||
line-height: 1.65;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.admin-contact {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(128, 128, 128, 0.24);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.editor-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
min-height: 46px;
|
||||
padding: 6px 10px 6px 14px;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.editor-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.required-mark {
|
||||
color: #d03050;
|
||||
}
|
||||
|
||||
.format-options {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.format-options :deep(.n-radio-button) {
|
||||
min-width: 72px;
|
||||
text-align: center;
|
||||
place-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.left {
|
||||
.compose-preview {
|
||||
min-height: 360px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.rich-editor {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-toolbar) {
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-text-container) {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-scroll) {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.compose-textarea :deep(.n-input__textarea-el),
|
||||
.compose-textarea :deep(.n-input__placeholder) {
|
||||
line-height: 1.7;
|
||||
text-align: left;
|
||||
place-items: left;
|
||||
justify-content: left;
|
||||
}
|
||||
|
||||
.n-alert {
|
||||
margin-bottom: 10px;
|
||||
.composer-form :deep(.n-input__input-el) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(128, 128, 128, 0.18);
|
||||
}
|
||||
|
||||
.draft-status {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.composer-page {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.access-state {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.composer-card :deep(.n-card-header) {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.composer-card :deep(.n-card-header__main) {
|
||||
flex: 1 1 180px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.composer-card :deep(.n-card-header__extra) {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.editor-panel-header {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.editor-controls {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.format-options {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.format-options :deep(.n-radio-button) {
|
||||
min-width: 0;
|
||||
padding-right: 8px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.rich-editor :deep(.w-e-toolbar) {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user