Compare commits

..
220 changed files with 11259 additions and 13949 deletions
+3 -7
View File
@@ -16,17 +16,13 @@ description: Create a GitHub release for cloudflare_temp_email project. Use when
gh pr list --state merged --search "is:pr is:merged merged:>$SINCE base:main" --json number,title,author --limit 200
```
Sort by PR number ascending.
4. **Check database upgrades**: Compare the previous release tag with the exact release target, including `db/` migrations, `db/schema.sql`, and the Admin database-update implementation. Use `git diff <previous-tag> <target> -- db/` (fetch missing refs first), not PR timestamps alone. Read each changed migration to determine required upgrade steps and effects on existing data. Only recommend the Admin database-update button after verifying it applies these migrations. Do not execute database changes as part of release preparation.
5. **Compose release body**: Follow the template in [references/release-template.md](references/release-template.md). Key rules:
4. **Compose release body**: Follow the template in [references/release-template.md](references/release-template.md). Key rules:
- Write release body in **bilingual format**: Chinese sections first (from `CHANGELOG.md`), then wrap the English sections (from `CHANGELOG_EN.md`) in `<details><summary>English</summary>...</details>`.
- Copy changelog sections verbatim from both files. Omit empty sections.
- For features requiring database migrations, follow release `v1.5.0`: add exactly three concise, natural-language sub-bullets under each corresponding feature in both languages. In order, explain how to enable it; the release-tag-pinned SQL migration link or verified Admin page action, plus whether unused features can skip it; and what the feature does. Do not prefix these lines with labels such as "Feature switch:", "Database changes:" or "Functionality:". Keep each feature's three lines separate, without a shared Database Changes section or classifying optional migrations as Breaking Changes. Preserve the original changelog bullet; these sub-bullets supplement it.
- State which feature requires each migration. Verify the feature's disabled code paths before saying it is optional: if the feature is not used and existing functionality does not depend on the migration, explicitly say users can skip it unless they enable that feature later. Distinguish these optional migrations from migrations required for normal operation; do not describe every migration as mandatory for all deployments.
- Keep migration instructions accurate and short: never recommend reapplying an already-applied non-idempotent migration. Include any destructive data impact in the database sub-bullet rather than hiding it to meet the format. Resolve unclear upgrade paths before publishing; do not invent switches or migration actions.
- Wrap PRs list in `<details><summary>PRs</summary>...</details>`.
- Always include the cache-clearing discussion link.
- End with `**Full Changelog**` comparison link.
6. **Create release**:
5. **Create release**:
- Write body to a temp file (e.g. `/tmp/release-notes.md`)
- Run: `gh release create vX.Y.Z --title "vX.Y.Z" --notes-file /tmp/release-notes.md --target main`
7. **Verify**: Read back the published body, verify all required migration links and bilingual upgrade instructions are present, then confirm the release URL and ask the user to review.
6. **Verify**: Confirm the release URL and ask the user to review.
@@ -8,9 +8,6 @@ Release notes body 使用以下格式,内容从 CHANGELOG.md 的对应版本
### Features
- feat: |模块| 描述
- 设置对应环境变量开启。
- 启用前执行对应迁移 SQL,或在已验证的 Admin 数据库页面升级;不使用此可选功能可跳过。
- 简述启用后的行为。
### Bug Fixes
@@ -24,20 +21,6 @@ Release notes body 使用以下格式,内容从 CHANGELOG.md 的对应版本
- style/refactor/perf/docs: |模块| 描述
<details>
<summary>English</summary>
### Features
- feat: |Module| Description
- Set the corresponding environment variable to enable it.
- Before enabling it, apply the migration SQL or use the verified Admin database-upgrade action; skip it if the optional feature is unused.
- Briefly describe the enabled behavior.
<!-- Copy the remaining matching English changelog sections here. -->
</details>
### [更新或者部署网页不生效请如图勾选清理缓存](https://github.com/dreamhunter2333/cloudflare_temp_email/discussions/487)
<details>
@@ -53,8 +36,6 @@ Release notes body 使用以下格式,内容从 CHANGELOG.md 的对应版本
## Notes
- Sections without entries should be omitted
- For features requiring migrations, use three concise sub-bullets under each feature: switch, database changes, functionality, without heading-like labels on the lines. Replace guidance with verified variables, release-tag-pinned SQL links and supported UI actions. Do not add a shared Database Changes section. Features without migrations do not need this format.
- Verify whether each migration is optional or mandatory from actual feature dependencies. Preserve any separate Breaking Changes entries and provide matching Chinese and English upgrade notes.
- PRs section uses `<details>` to collapse by default
- PRs are sorted by PR number ascending
- The cache clearing discussion link is always included
+6 -53
View File
@@ -6,65 +6,18 @@
<a href="CHANGELOG_EN.md">English</a>
</p>
## v1.13.0(main)
## v1.11.0(main)
### Features
- feat: |Frontend Next| 新增 `frontend-next` Vite React + shadcn 命令生成组件的非 admin 邮件客户端,按参考稿 B 风格实现创建/恢复地址、收件箱、写信、地址管理、用户账号集成、设置与亮色/暗色切换
- docs: |前端/文档| 统一邮箱地址、用户账号与 Admin 权限的表述,并明确发信权限和额度按邮箱地址独立申请与管理
### Bug Fixes
### Improvements
- style: |Frontend| 将分栏邮件列表改为紧凑布局,压缩信息间距并省略长主题和地址,减少窄列表换行,为正文保留更多空间
## v1.12.0
### Features
- feat: |Webhook| 测试弹框支持随机邮件或指定邮件 ID,校验请求体及邮箱归属并适配现有前端语言及中英文错误提示
- feat: |Webhook| 支持无需 S3 的多附件签名链接、纯 URL 与 Markdown 链接列表,签名兼容大小写,绑定本次入库邮件并保留下载文件名,关闭 Webhook 时禁止附件下载(issue #1142
- feat: |Worker| 新增 `DISABLE_ADDRESS_UPDATED_AT`,可关闭单地址及用户批量的主动保活刷新,并禁止内置手动及定时不活跃地址清理,降低 D1 写入量
- feat: |Frontend| 新增 `VITE_DEFAULT_LANG` 构建变量,并支持通过 `index.html` 运行时配置覆盖前端设置
- feat: |兑换码| 新增角色、发信额度及专属邮箱兑换与管理,完善并发保护和表单提示
- feat: |邮件| 新增可选的已读/未读状态,支持点击邮件自动已读和手动切换状态
- feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限
- feat: |Admin| 创建邮箱页面支持一键生成随机邮箱名称(issue #1126
- feat: |Admin API| 新增 `ADMIN_API_IP_WHITELIST`,可按来源 IP 限制所有管理接口访问
- feat: |用户系统| 用户中心新增发送邮件、与收件箱一致的可按绑定地址过滤的发件箱,以及地址管理凭证弹框;提供使用用户 JWT 的地址设置、发信权限申请、发信及发件箱 API
### Bug Fixes
- 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: |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
@@ -255,7 +208,7 @@
- test: |E2E| 新增 Docker 化端到端测试环境(Playwright + Mailpit),`cd e2e && npm test` 一条命令运行
- test: |E2E| 覆盖 API 健康检查、地址生命周期、SMTP 发信、收件箱 UI、回复 HTML 邮件及 XSS 防护
- test: |Worker| 新增 `/admin/test/seed_mail` 测试端点
- test: |Worker| 新增 `/admin/test/seed_mail` 测试端点,仅 `E2E_TEST_MODE` 启用时可用
### Improvements
+6 -53
View File
@@ -6,65 +6,18 @@
<a href="CHANGELOG_EN.md">English</a>
</p>
## v1.13.0(main)
## v1.11.0(main)
### Features
- feat: |Frontend Next| Add a `frontend-next` Vite React non-admin mail client using shadcn CLI-generated components in the reference B style, covering address create/restore, inbox, compose, address management, user account integration, settings, and light/dark theme support
- docs: |Frontend/Docs| Clarify mailbox address, user account, and Admin permission terminology, including address-specific send access and balances
### Bug Fixes
### Improvements
- style: |Frontend| Compact the split-pane mail list with tighter spacing and truncated subjects and addresses, reducing wrapping in narrow lists and preserving space for message content
## v1.12.0
### Features
- feat: |Webhook| Support random or specified email IDs in the test dialog, with request-body validation, mailbox ownership checks, existing UI languages and Chinese/English errors
- feat: |Webhook| Support signed attachment URLs without S3, plain URL and Markdown link lists, with case-insensitive signatures bound to the inserted email and original download filenames; deny attachment downloads when Webhook is disabled (issue #1142)
- feat: |Worker| Add `DISABLE_ADDRESS_UPDATED_AT` to disable individual and user-wide address activity keep-alive updates and built-in manual/scheduled inactive-address cleanup, reducing D1 writes
- feat: |Frontend| Add the `VITE_DEFAULT_LANG` build variable and support overriding frontend settings through runtime configuration in `index.html`
- feat: |Redemption Codes| Add role, sending-credit and custom-mailbox redemption with Admin management, concurrency protection and form validation
- feat: |Mail| Add optional read/unread status with click-to-read and manual status switching
- feat: |Admin| Add D1 storage capacity details to the database page, with persistent Free and Workers Paid plan selection and a comparison between the current database size and capacity limit
- feat: |Admin| Add one-click random email-name generation to the address creation page (issue #1126)
- feat: |Admin API| Add `ADMIN_API_IP_WHITELIST` to restrict all admin endpoints by source IP
- feat: |User| Add mail composition, inbox-style sent-item filtering by bound address, and the shared address-credentials dialog to the user center, backed by User JWT APIs for address settings, send-access requests, sending, and sent-item management
### Bug Fixes
- fix: |Mailbox Auth| Fix stale mailbox credentials retaining API access, unauthorized Telegram unbinding, ineffective rebinding and credential storage in external sent mail; 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: |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)
@@ -255,7 +208,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
- test: |Worker| Add `/admin/test/seed_mail` test endpoint, only available when `E2E_TEST_MODE` is enabled
### Improvements
-1
View File
@@ -1 +0,0 @@
ALTER TABLE raw_mails ADD COLUMN is_unread INTEGER;
-15
View File
@@ -1,15 +0,0 @@
CREATE TABLE IF NOT EXISTS redeem_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
redeem_type TEXT NOT NULL,
value TEXT NOT NULL,
result TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
redeemed INTEGER NOT NULL DEFAULT 0 CHECK (redeemed IN (0, 1)),
expires_at DATETIME NOT NULL,
redeemed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_redeem_codes_type ON redeem_codes(redeem_type);
-17
View File
@@ -6,7 +6,6 @@ CREATE TABLE IF NOT EXISTS raw_mails (
raw TEXT,
raw_blob BLOB,
metadata TEXT,
is_unread INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -120,19 +119,3 @@ CREATE TABLE IF NOT EXISTS user_passkeys (
CREATE INDEX IF NOT EXISTS idx_user_passkeys_user_id ON user_passkeys(user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_passkeys_user_id_passkey_id ON user_passkeys(user_id, passkey_id);
CREATE TABLE IF NOT EXISTS redeem_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
redeem_type TEXT NOT NULL,
value TEXT NOT NULL,
result TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
redeemed INTEGER NOT NULL DEFAULT 0 CHECK (redeemed IN (0, 1)),
expires_at DATETIME NOT NULL,
redeemed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_redeem_codes_type ON redeem_codes(redeem_type);
+1 -1
View File
@@ -1,5 +1,5 @@
# Keep this version in sync with @playwright/test in package.json
FROM mcr.microsoft.com/playwright:v1.63.0-noble
FROM mcr.microsoft.com/playwright:v1.62.1-noble
RUN apt-get update && apt-get install -y curl netcat-openbsd && rm -rf /var/lib/apt/lists/*
-1
View File
@@ -33,7 +33,6 @@ config.server = {\
"/admin": { target: workerTarget, changeOrigin: true },\
"/user_api": { target: workerTarget, changeOrigin: true },\
"/open_api": { target: workerTarget, changeOrigin: true },\
"/redeem_api": { target: workerTarget, changeOrigin: true },\
"/external": { target: workerTarget, changeOrigin: true },\
"/health_check": { target: workerTarget, changeOrigin: true },\
},\
+1 -3
View File
@@ -10,12 +10,10 @@ COPY worker/patches/ patches/
RUN pnpm install --frozen-lockfile || (echo "WARN: frozen-lockfile failed, falling back to pnpm install" && pnpm install)
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", "--test-scheduled"]
CMD ["pnpm", "exec", "wrangler", "dev", "--port", "8787", "--ip", "0.0.0.0"]
+1 -2
View File
@@ -51,8 +51,7 @@ Test results and HTML reports are exported via volumes:
## Configuration
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.
+1 -38
View File
@@ -42,7 +42,7 @@ services:
dockerfile: e2e/Dockerfile.worker
ports:
- "8790:8790"
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8790", "--ip", "0.0.0.0", "--test-scheduled"]
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8790", "--ip", "0.0.0.0"]
volumes:
- ./fixtures/wrangler.toml.e2e.env-off:/app/worker/wrangler.toml:ro
depends_on:
@@ -90,24 +90,6 @@ 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: ..
@@ -118,19 +100,6 @@ 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
@@ -178,9 +147,7 @@ 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"
@@ -200,12 +167,8 @@ 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:
-3
View File
@@ -1,3 +0,0 @@
export class EmailMessage {
constructor(public from: string, public to: string, public raw: string) {}
}
+11 -20
View File
@@ -7,9 +7,7 @@ export const WORKER_URL_SUBDOMAIN = process.env.WORKER_URL_SUBDOMAIN || '';
export const WORKER_URL_ENV_OFF = process.env.WORKER_URL_ENV_OFF || '';
export const WORKER_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';
@@ -29,11 +27,10 @@ export function hashPassword(password: string): string {
export async function createTestAddress(
ctx: APIRequestContext,
name: string,
domain: string = TEST_DOMAIN,
workerUrl: string = WORKER_URL,
domain: string = TEST_DOMAIN
): Promise<{ jwt: string; address: string; address_id: number }> {
const uniqueName = `${name}${Date.now()}`;
const res = await ctx.post(`${workerUrl}/api/new_address`, {
const res = await ctx.post(`${WORKER_URL}/api/new_address`, {
data: { name: uniqueName, domain },
});
if (!res.ok()) {
@@ -78,7 +75,7 @@ export async function seedTestMail(
`--${boundary}--`,
].join('\r\n');
const res = await ctx.post(`${WORKER_URL}/__test/receive_mail`, {
const res = await ctx.post(`${WORKER_URL}/admin/test/receive_mail`, {
data: { from, to: address, raw },
});
if (!res.ok()) {
@@ -167,20 +164,14 @@ export function onMailpitMessage(
ws.on('open', () => readyResolve());
ws.on('message', (data: WebSocket.Data) => {
// Mailpit may batch multiple newline-separated events in one message.
for (const line of data.toString().split('\n')) {
if (settled) return;
try {
const event = JSON.parse(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 */ }
}
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 */ }
});
ws.on('close', () => {
-19
View File
@@ -1,19 +0,0 @@
import { Hono } from 'hono';
import worker from '../../worker/src/worker';
import { CONSTANTS } from '../../worker/src/constants';
import mailApi from './mail-api';
const testApi = new Hono<HonoCustomType>();
testApi.post('/seed_mail', c => mailApi.seedMail(c.req.raw, c.env));
testApi.post('/receive_mail', c => mailApi.receiveMail(c.req.raw, c.env, c.executionCtx as ExecutionContext));
testApi.get('/telegram_binding', async c => {
const address = c.req.query('address');
if (!address) return c.text('address is required', 400);
return c.json(await c.env.KV.get(`${CONSTANTS.TG_KV_PREFIX}:${address}`));
});
const app = new Hono<HonoCustomType>();
app.route('/__test', testApi);
app.all('*', c => worker.fetch(c.req.raw, c.env, c.executionCtx));
export default { ...worker, fetch: app.fetch };
+3 -15
View File
@@ -1,5 +1,5 @@
name = "cloudflare_temp_email"
main = "../e2e/fixtures/worker.ts"
main = "src/worker.ts"
compatibility_date = "2025-04-01"
compatibility_flags = [ "nodejs_compat" ]
keep_vars = true
@@ -7,31 +7,22 @@ keep_vars = true
[vars]
PREFIX = "TMP"
DEFAULT_DOMAINS = []
DOMAINS = ["TEST.EXAMPLE.COM", "MANUAL.EXAMPLE.COM"]
RANDOM_SUBDOMAIN_DOMAINS = ["TEST.EXAMPLE.COM"]
CREATE_ADDRESS_DEFAULT_DOMAIN_FIRST = true
DOMAINS = ["TEST.EXAMPLE.COM"]
USER_ROLES = [
{ domains = ["TEST.EXAMPLE.COM"], role = "case-role", prefix = "ROLE" },
{ domains = [], role = "empty-role", prefix = "EMPTY" },
]
JWT_SECRET = "e2e-test-secret-key"
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
BACKEND_URL = "http://worker:8787/"
REMOVE_EXCEED_SIZE_ATTACHMENT = true
E2E_TEST_MODE = true
CLEANUP_BATCH_SIZE = 10
SMTP_CONFIG = """
{"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}}
@@ -45,6 +36,3 @@ 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"
+2 -8
View File
@@ -1,5 +1,5 @@
name = "cloudflare_temp_email_env_off"
main = "../e2e/fixtures/worker.ts"
main = "src/worker.ts"
compatibility_date = "2025-04-01"
compatibility_flags = [ "nodejs_compat" ]
keep_vars = true
@@ -13,16 +13,13 @@ 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
BACKEND_URL = "http://worker-env-off:8790"
REMOVE_ALL_ATTACHMENT = true
E2E_TEST_MODE = true
SMTP_CONFIG = """
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
"""
@@ -35,6 +32,3 @@ 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"
+2 -5
View File
@@ -1,5 +1,5 @@
name = "cloudflare_temp_email_gzip"
main = "../e2e/fixtures/worker.ts"
main = "src/worker.ts"
compatibility_date = "2025-04-01"
compatibility_flags = [ "nodejs_compat" ]
keep_vars = true
@@ -18,7 +18,7 @@ ENABLE_ADDRESS_PASSWORD = true
DISABLE_ADMIN_PASSWORD_CHECK = true
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
ENABLE_WEBHOOK = true
BACKEND_URL = "http://worker-gzip:8788"
E2E_TEST_MODE = true
ENABLE_MAIL_GZIP = true
SMTP_CONFIG = """
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
@@ -32,6 +32,3 @@ 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 = "../e2e/fixtures/worker.ts"
main = "src/worker.ts"
compatibility_date = "2025-04-01"
compatibility_flags = [ "nodejs_compat" ]
keep_vars = true
@@ -23,6 +23,7 @@ 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}}
"""
@@ -35,6 +36,3 @@ 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,34 +0,0 @@
name = "cloudflare_temp_email_site_password"
main = "../e2e/fixtures/worker.ts"
compatibility_date = "2025-04-01"
compatibility_flags = [ "nodejs_compat" ]
keep_vars = true
[vars]
PREFIX = "tmp"
DEFAULT_DOMAINS = ["test.example.com"]
DOMAINS = ["test.example.com"]
USER_ROLES = [
{ domains = ["test.example.com"], role = "case-role", prefix = "" },
]
ADMIN_USER_ROLE = "admin"
JWT_SECRET = "e2e-site-password-secret"
PASSWORDS = '["e2e-site-pass"]'
BLACK_LIST = ""
ENABLE_REDEEM_CODE = true
ENABLE_ADDRESS_PASSWORD = true
ADDRESS_CHECK_REGEX = "^(?!.*blocked).*$"
DISABLE_ADMIN_PASSWORD_CHECK = false
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
[[kv_namespaces]]
binding = "KV"
id = "e2e-test-kv-site-password-00000000-0000-0000-0000-000000000000"
[[d1_databases]]
binding = "DB"
database_name = "e2e-temp-email-site-password"
database_id = "e2e-test-db-site-password-00000000-0000-0000-0000-000000000000"
[alias]
"cloudflare:email" = "../e2e/fixtures/email.ts"
+71 -56
View File
@@ -6,14 +6,14 @@
"": {
"name": "cloudflare-temp-email-e2e",
"dependencies": {
"imapflow": "^2.0.2",
"nodemailer": "^10.0.9"
"imapflow": "^1.6.5",
"nodemailer": "^9.0.4"
},
"devDependencies": {
"@playwright/test": "1.63.0",
"@playwright/test": "1.62.1",
"@types/nodemailer": "^8.0.1",
"@types/ws": "^8.18.1",
"hono": "^4.13.7",
"ws": "^8.21.3"
"ws": "^8.21.2"
}
},
"node_modules/@pinojs/redact": {
@@ -23,13 +23,13 @@
"license": "MIT"
},
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.63.0"
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
@@ -48,6 +48,16 @@
"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",
@@ -59,13 +69,13 @@
}
},
"node_modules/@zone-eu/mailsplit": {
"version": "5.4.16",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.16.tgz",
"integrity": "sha512-zQ9iXvlT3Wi/hazeC1MdI4rQc1UJwJ6IQ6QzSZ5KDxLZZWQSazWLOzImLFluXadKShJ9WJvI1xH+AyVS8b9azg==",
"version": "5.4.14",
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.14.tgz",
"integrity": "sha512-rz0FQOhN3Vq1XrSeSSa9+dPcaFbBxmQPjiZm6zS9oxdVHV7rOWIAYX3yP2YAUf0qBncY8CI+NogzPCmMVrMXcw==",
"license": "(MIT OR EUPL-1.1+)",
"dependencies": {
"libbase64": "1.3.0",
"libmime": "5.4.3",
"libmime": "5.4.1",
"libqp": "2.1.1"
}
},
@@ -79,22 +89,27 @@
}
},
"node_modules/encoding-japanese": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.3.0.tgz",
"integrity": "sha512-eQyh1vzHz13DUkZcJO+0IOAoKXRQwKV5IBffeuYsWZyRLGiSzfzXObCqWvqFXdX0UU8qOk+lBXbkUhMCpdJe4Q==",
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
"node": ">=8.10.0"
}
},
"node_modules/hono": {
"version": "4.13.7",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz",
"integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==",
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.9.0"
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/iconv-lite": {
@@ -114,28 +129,25 @@
}
},
"node_modules/imapflow": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-2.0.2.tgz",
"integrity": "sha512-jrWWecWFifN3Feum2MqaTUupNeYSAdPEltKuwGdr8IPdxW72NZXf7ObVUfTWoUH2MPrli0q7SZm9pRt8ob/9XQ==",
"version": "1.6.5",
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.6.5.tgz",
"integrity": "sha512-5XgLcfl6blDju2SP7TgTv4WIlTt7zxXYcfjznJFmfFrogHR2XzHGlS9s2wdqxv9ZJ5CPaRnig7KaK7qNoJPmlg==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.16",
"encoding-japanese": "2.3.0",
"@zone-eu/mailsplit": "5.4.14",
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.3",
"libbase64": "1.3.0",
"libmime": "5.4.3",
"libmime": "5.4.1",
"libqp": "2.1.1",
"pino": "10.3.1",
"socks": "2.8.10"
},
"engines": {
"node": ">=20.0.0"
"socks": "2.8.9"
}
},
"node_modules/ip-address": {
"version": "10.7.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
"integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.3.1.tgz",
"integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -148,12 +160,12 @@
"license": "MIT"
},
"node_modules/libmime": {
"version": "5.4.3",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.3.tgz",
"integrity": "sha512-di9BoDabBUMqjeD/wGj+hHpSgdqAph5ui7w6OdY6NpzU6O6VFLQsMOg9tqCjm/zf9OHzAM9EZxSOF7uIb8O8Hw==",
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.1.tgz",
"integrity": "sha512-0wHGhsofo9IdQPenr3BBHXuxcwMq4atFUTsZ9Ogc1OvI5h4rUdDIrBQEN9JHjCXfDMrE59LUMJWsTD82wTYk8A==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.3.0",
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.3",
"libbase64": "1.3.0",
"libqp": "2.1.1"
@@ -166,12 +178,12 @@
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "10.0.9",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.9.tgz",
"integrity": "sha512-BF0qcyplCwp+jMk6HCjFykBz/YhhZSsxrARhOldLwFWH+8kGjQsd2WIMIZhqEuyXRoGFi0ONbDeWDMDoL8MhLw==",
"version": "9.0.4",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.4.tgz",
"integrity": "sha512-LmJNRVRtfSCULxcZpy0Cpg4WWenlUZ9+zbmTO+S7v9wD6XreYLjXRFtDjtV/4F0HT5p1GyZfA0Ux/myxHb18CQ==",
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
"node": ">=6.0.0"
}
},
"node_modules/on-exit-leak-free": {
@@ -221,25 +233,28 @@
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -306,9 +321,9 @@
}
},
"node_modules/socks": {
"version": "2.8.10",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz",
"integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==",
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.1.1",
@@ -363,9 +378,9 @@
"license": "MIT"
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"version": "8.21.2",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz",
"integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==",
"dev": true,
"license": "MIT",
"engines": {
+5 -6
View File
@@ -3,18 +3,17 @@
"private": true,
"type": "module",
"scripts": {
"test:unit": "node --experimental-strip-types --test tests/unit/*.test.mjs",
"test": "docker compose up --build --abort-on-container-exit --exit-code-from e2e-runner",
"test:down": "docker compose down -v"
},
"devDependencies": {
"@playwright/test": "1.63.0",
"@playwright/test": "1.62.1",
"@types/nodemailer": "^8.0.1",
"@types/ws": "^8.18.1",
"hono": "^4.13.7",
"ws": "^8.21.3"
"ws": "^8.21.2"
},
"dependencies": {
"imapflow": "^2.0.2",
"nodemailer": "^10.0.9"
"imapflow": "^1.6.5",
"nodemailer": "^9.0.4"
}
}
-38
View File
@@ -59,21 +59,6 @@ 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
@@ -87,21 +72,6 @@ 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
@@ -140,13 +110,5 @@ 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 "$@"
+2 -2
View File
@@ -48,7 +48,7 @@ async function receiveGzipMail(
`--${boundary}--`,
].join('\r\n');
const res = await ctx.post(`${WORKER_GZIP_URL}/__test/receive_mail`, {
const res = await ctx.post(`${WORKER_GZIP_URL}/admin/test/receive_mail`, {
data: { from, to: address, raw },
});
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}/__test/seed_mail`, {
const res = await ctx.post(`${WORKER_GZIP_URL}/admin/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 -325
View File
@@ -1,8 +1,6 @@
import { test, expect, type APIRequestContext } from '@playwright/test';
import { test, expect } from '@playwright/test';
import {
WORKER_URL,
WORKER_URL_ENV_OFF,
onMailpitMessage,
createTestAddress,
deleteAddress,
hashPassword,
@@ -79,325 +77,3 @@ test.describe('Address activity throttling', () => {
}
});
});
const OLD = '2020-01-01 00:00:00';
const RECENT = new Date(Date.now() - 3_600_000).toISOString().replace('T', ' ').slice(0, 19);
type Mailbox = { address: string; address_id: number; jwt: string; password: string };
type User = { id: number; email: string; jwt: string };
for (const { base, disabled } of [
{ base: WORKER_URL, disabled: false },
{ base: WORKER_URL_ENV_OFF, disabled: true },
]) {
test.describe(`Address activity disabled: ${disabled}`, () => {
let mailboxes: Mailbox[];
let users: User[];
let orphanAddresses: string[];
let originalUserSettings: Record<string, unknown>;
async function call(request: APIRequestContext, path: string,
options: Parameters<APIRequestContext['fetch']>[1] = {}, status = 200) {
const response = await request.fetch(`${base}${path}`, options);
expect(response.status(), await response.text()).toBe(status);
return response;
}
async function list(request: APIRequestContext, path: string, query: Record<string, string>) {
return (await call(request, path, { params: { limit: '100', offset: '0', ...query } })).json();
}
async function addressRow(request: APIRequestContext, mailbox: Mailbox) {
return (await list(request, '/admin/address', { query: mailbox.address })).results
.find((row: { name: string }) => row.name === mailbox.address);
}
async function newMailbox(request: APIRequestContext) {
const response = await call(request, '/admin/new_address', {
method: 'POST', data: { name: `activity${Date.now()}${mailboxes.length}`, domain: 'test.example.com' },
});
const mailbox: Mailbox = await response.json();
mailboxes.push(mailbox);
return mailbox;
}
async function newUser(request: APIRequestContext) {
const email = `activity${Date.now()}${users.length}@test.example.com`;
const password = hashPassword('test-password-123');
await call(request, '/admin/users', { method: 'POST', data: { email, password } });
const response = await call(request, '/user_api/login', { method: 'POST', data: { email, password } });
const { jwt } = await response.json();
const { user_id: id } = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
const user = { id, email, jwt };
users.push(user);
return user;
}
const addressAuth = (mailbox: Mailbox) => ({ Authorization: `Bearer ${mailbox.jwt}` });
const userAuth = (user: User) => ({ 'x-user-token': user.jwt });
async function bind(request: APIRequestContext, mailbox: Mailbox, user: User) {
await call(request, '/admin/users/bind_address', {
method: 'POST', data: { user_id: user.id, address_id: mailbox.address_id },
});
}
async function seed(request: APIRequestContext, mailbox: Mailbox, updatedAt: string | null = OLD, createdAt?: string) {
await call(request, '/__test/seed_mail', {
method: 'POST', data: {
address: mailbox.address, raw: `From: sender@test.example.com\r\nTo: ${mailbox.address}\r\nSubject: activity\r\n\r\nBody`,
address_updated_at: updatedAt, address_created_at: createdAt, created_at: OLD,
},
});
expect((await addressRow(request, mailbox)).updated_at).toBe(updatedAt);
}
async function expectActivity(request: APIRequestContext, mailbox: Mailbox, previous: string | null) {
if (disabled || previous === RECENT) {
await waitForNextTimestamp();
expect((await addressRow(request, mailbox)).updated_at).toBe(previous);
return;
}
await expect.poll(async () => (await addressRow(request, mailbox)).updated_at).not.toBe(previous);
expect((await addressRow(request, mailbox)).updated_at).toBeTruthy();
}
async function send(request: APIRequestContext, mailbox: Mailbox, user?: User) {
const subject = `Activity ${disabled} ${Date.now()}`;
const listener = onMailpitMessage(mail => mail.Subject === subject);
await listener.ready;
const [, delivered] = await Promise.all([
call(request, user ? `/user_api/address/${mailbox.address_id}/send_mail` : '/api/send_mail', {
method: 'POST', headers: user ? userAuth(user) : addressAuth(mailbox),
data: { to_mail: 'recipient@test.example.com', subject, content: 'Activity test', is_html: false },
}),
listener.message,
]);
expect(delivered.From.Address).toBe(mailbox.address);
}
async function relatedCounts(request: APIRequestContext, mailbox: Mailbox, user: User) {
const address = await addressRow(request, mailbox);
const inbox = await list(request, '/admin/mails', { address: mailbox.address });
const sent = await list(request, '/admin/sendbox', { address: mailbox.address });
const sender = await list(request, '/admin/address_sender', { address: mailbox.address });
let replyMailbox = mailbox;
if (!address) {
await call(request, '/api/auto_reply', { headers: addressAuth(mailbox) }, 401);
// A new credential lets us check that no old auto-reply data survived cleanup.
const [name, domain] = mailbox.address.split('@');
replyMailbox = await (await call(request, '/admin/new_address', {
method: 'POST', data: { name, domain, enablePrefix: false },
})).json();
}
const reply = await (await call(request, '/api/auto_reply', { headers: addressAuth(replyMailbox) })).json();
if (!address) await call(request, `/admin/delete_address/${replyMailbox.address_id}`, { method: 'DELETE' });
const bound = await (await call(request, '/user_api/bind_address', { headers: userAuth(user) })).json();
return [Number(!!address), inbox.count, sent.count, sender.count, Number(!!reply.subject),
bound.results.filter((row: { name: string }) => row.name === mailbox.address).length];
}
async function prepareRelated(request: APIRequestContext) {
const mailbox = await newMailbox(request);
const user = await newUser(request);
await bind(request, mailbox, user);
await call(request, '/api/auto_reply', {
method: 'POST', headers: addressAuth(mailbox),
data: { auto_reply: { subject: 'Reply', message: 'Body', enabled: false } },
});
await send(request, mailbox);
await seed(request, mailbox);
expect(await relatedCounts(request, mailbox, user)).toEqual([1, 1, 1, 1, 1, 1]);
return { mailbox, user };
}
test.beforeAll(async ({ request }) => {
expect(base).toBeTruthy();
originalUserSettings = await (await call(request, '/admin/user_settings')).json();
await call(request, '/admin/user_settings', {
method: 'POST', data: { ...originalUserSettings, enable: true, enableMailVerify: false },
});
});
test.afterAll(async ({ request }) => {
await call(request, '/admin/user_settings', { method: 'POST', data: originalUserSettings });
});
test.beforeEach(() => {
expect(base).toBeTruthy();
mailboxes = [];
users = [];
orphanAddresses = [];
});
test.afterEach(async ({ request }) => {
for (const mailbox of mailboxes) {
const row = await addressRow(request, mailbox);
if (row) await call(request, `/admin/delete_address/${row.id}`, { method: 'DELETE' });
}
for (const user of users) await call(request, `/admin/users/${user.id}`, { method: 'DELETE' });
for (const address of orphanAddresses) {
const mails = await list(request, '/admin/mails', { address });
for (const mail of mails.results) await call(request, `/admin/mails/${mail.id}`, { method: 'DELETE' });
}
});
test('repeated user settings reads respect activity tracking and ownership', async ({ request }) => {
const user = await newUser(request);
const otherUser = await newUser(request);
for (const [index, previous] of [OLD, null, RECENT, OLD, OLD].entries()) {
const mailbox = await newMailbox(request);
if (index < 3) await bind(request, mailbox, user);
if (index === 4) await bind(request, mailbox, otherUser);
await seed(request, mailbox, previous);
}
await call(request, '/user_api/settings', { headers: userAuth(user) });
await expectActivity(request, mailboxes[0], OLD);
const after = await Promise.all(mailboxes.map(mailbox => addressRow(request, mailbox)));
expect(after.map(row => row.updated_at).slice(2)).toEqual([RECENT, OLD, OLD]);
if (disabled) expect(after[1].updated_at).toBeNull();
else expect(after[1].updated_at).toBeTruthy();
await call(request, '/user_api/settings', { headers: userAuth(user) });
await waitForNextTimestamp();
expect(await Promise.all(mailboxes.map(mailbox => addressRow(request, mailbox)))).toEqual(after);
});
for (const path of ['/api/settings', '/api/mails?limit=20&offset=0', '/api/parsed_mails?limit=20&offset=0']) {
test(`${path} respects the switch for stale, null and recent activity`, async ({ request }) => {
const mailbox = await newMailbox(request);
const other = await newMailbox(request);
await seed(request, other);
for (const previous of [OLD, null, RECENT]) {
await seed(request, mailbox, previous);
await call(request, path, { headers: addressAuth(mailbox) });
await expectActivity(request, mailbox, previous);
expect((await addressRow(request, other)).updated_at).toBe(OLD);
}
});
}
test('later inbox pages do not refresh activity', async ({ request }) => {
const mailbox = await newMailbox(request);
await seed(request, mailbox);
for (const path of ['/api/mails?limit=20&offset=20', '/api/parsed_mails?limit=20&offset=20']) {
await call(request, path, { headers: addressAuth(mailbox) });
}
await waitForNextTimestamp();
expect((await addressRow(request, mailbox)).updated_at).toBe(OLD);
});
test('incoming mail is stored and both send APIs respect activity tracking', async ({ request }) => {
const mailbox = await newMailbox(request);
const user = await newUser(request);
await bind(request, mailbox, user);
await seed(request, mailbox);
const raw = `From: sender@test.example.com\r\nTo: ${mailbox.address}\r\nSubject: incoming\r\n\r\nBody`;
const received = await call(request, '/__test/receive_mail', {
method: 'POST', data: { from: 'sender@test.example.com', to: mailbox.address, raw },
});
expect((await received.json()).success).toBe(true);
expect((await list(request, '/admin/mails', { address: mailbox.address })).count).toBe(2);
expect((await addressRow(request, mailbox)).updated_at).toBe(OLD);
for (const sender of [undefined, user]) {
await seed(request, mailbox);
await send(request, mailbox, sender);
await expectActivity(request, mailbox, OLD);
}
const sender = await list(request, '/admin/address_sender', { address: mailbox.address });
expect(sender.results[0].balance).toBe(8);
expect((await list(request, '/admin/sendbox', { address: mailbox.address })).count).toBe(2);
});
test('password generation, password changes and admin resets keep working', async ({ request }) => {
const mailbox = await newMailbox(request);
expect(mailbox.password).toBeTruthy();
expect((await addressRow(request, mailbox)).updated_at).toBeTruthy();
await call(request, '/api/address_login', {
method: 'POST', data: { email: mailbox.address, password: hashPassword(mailbox.password) },
});
for (const path of ['/api/address_change_password', `/admin/address/${mailbox.address_id}/reset_password`]) {
await seed(request, mailbox);
const password = hashPassword(`password-${path}`);
await call(request, path, {
method: 'POST', headers: addressAuth(mailbox), data: { password, new_password: password },
});
expect((await addressRow(request, mailbox)).updated_at).not.toBe(OLD);
await call(request, '/api/address_login', { method: 'POST', data: { email: mailbox.address, password } });
}
});
test('address transfer still initializes its timestamp and changes ownership', async ({ request }) => {
const mailbox = await newMailbox(request);
const user = await newUser(request);
const target = await newUser(request);
await bind(request, mailbox, user);
await seed(request, mailbox);
await call(request, '/user_api/transfer_address', {
method: 'POST', headers: userAuth(user), data: { address_id: mailbox.address_id, target_user_email: target.email },
});
const transferred = await addressRow(request, mailbox);
expect(transferred.id).not.toBe(mailbox.address_id);
expect(transferred.updated_at).toBeTruthy();
expect(transferred.updated_at).not.toBe(OLD);
const bindings = await (await call(request, '/user_api/bind_address', { headers: userAuth(target) })).json();
expect(bindings.results.map((row: { name: string }) => row.name)).toContain(mailbox.address);
});
test('manual inactivity cleanup protects the address and all related data when disabled', async ({ request }) => {
const { mailbox, user } = await prepareRelated(request);
for (const lang of ['en', 'zh']) {
const response = await call(request, '/admin/cleanup', {
method: 'POST', headers: { 'x-lang': lang }, data: { cleanType: 'inactiveAddress', cleanDays: 1 },
}, disabled ? 500 : 200);
if (disabled) expect(await response.text()).toBe(lang === 'zh'
? '清理失败,请检查清理配置;禁用地址活跃时间更新时,无法按不活跃时间清理。'
: 'Cleanup failed. Check your cleanup settings; inactive-address cleanup is unavailable when address activity updates are disabled.');
else expect(await response.json()).toEqual({ success: true });
expect(await relatedCounts(request, mailbox, user)).toEqual(disabled ? [1, 1, 1, 1, 1, 1] : [0, 0, 0, 0, 0, 0]);
}
});
test('scheduled inactivity cleanup respects the switch and continues later cleanup', async ({ request }) => {
const { mailbox, user } = await prepareRelated(request);
const created = await newMailbox(request);
await seed(request, created, RECENT, OLD);
const original = await (await call(request, '/admin/auto_cleanup')).json();
try {
await call(request, '/admin/auto_cleanup', { method: 'POST', data: {
enableInactiveAddressAutoCleanup: true, cleanInactiveAddressDays: 1,
enableAddressAutoCleanup: true, cleanAddressDays: 1,
} });
await call(request, '/__scheduled');
expect(await relatedCounts(request, mailbox, user)).toEqual(disabled ? [1, 1, 1, 1, 1, 1] : [0, 0, 0, 0, 0, 0]);
expect(await addressRow(request, created)).toBeUndefined();
} finally {
await call(request, '/admin/auto_cleanup', { method: 'POST', data: original || {} });
}
});
for (const cleanType of ['addressCreated', 'unboundAddress', 'emptyAddress', 'mails', 'mails_unknow', 'sendbox']) {
test(`${cleanType} cleanup remains available`, async ({ request }) => {
const mailbox = await newMailbox(request);
await seed(request, mailbox, RECENT, OLD);
let queryAddress = mailbox.address;
if (cleanType === 'emptyAddress') await call(request, `/admin/clear_inbox/${mailbox.address_id}`, { method: 'DELETE' });
if (cleanType === 'mails_unknow') {
queryAddress = `unknown${Date.now()}@test.example.com`;
orphanAddresses.push(queryAddress);
await call(request, '/__test/seed_mail', {
method: 'POST', data: { address: queryAddress, raw: 'old unknown mail', created_at: OLD },
});
}
if (cleanType === 'sendbox') {
await send(request, mailbox);
await waitForNextTimestamp();
}
await call(request, '/admin/cleanup', {
method: 'POST', data: { cleanType, cleanDays: cleanType === 'sendbox' ? 0 : 1 },
});
if (['addressCreated', 'unboundAddress', 'emptyAddress'].includes(cleanType)) {
expect(await addressRow(request, mailbox)).toBeUndefined();
} else {
const path = cleanType === 'sendbox' ? '/admin/sendbox' : '/admin/mails';
expect((await list(request, path, { address: queryAddress })).count).toBe(0);
expect(await addressRow(request, mailbox)).toBeTruthy();
}
});
}
test('invalid cleanup requests keep their original error details', async ({ request }) => {
for (const { data, message } of [
{ data: { cleanType: 'invalid', cleanDays: 1 }, message: 'Operation failed: Invalid cleanType' },
{ data: { cleanType: 'mails', cleanDays: -1 }, message: 'Operation failed: Invalid cleanType or cleanDays' },
]) {
const response = await call(request, '/admin/cleanup', { method: 'POST', data }, 500);
expect(await response.text()).toBe(message);
}
});
});
}
-124
View File
@@ -1,124 +0,0 @@
import { expect, test, type APIRequestContext } from '@playwright/test';
import { createHmac } from 'node:crypto';
import { WORKER_URL, TEST_DOMAIN, createTestAddress, deleteAddress, seedTestMail } from '../../fixtures/test-helpers';
function signToken(payload: Record<string, unknown>) {
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
const signature = createHmac('sha256', 'e2e-test-secret-key')
.update(`${header}.${body}`).digest('base64url');
return `${header}.${body}.${signature}`;
}
async function expectRejected(request: APIRequestContext, jwt: string) {
for (const [method, path] of [
['GET', '/api/settings'],
['GET', '/api/mails?limit=20&offset=0'],
['GET', '/api/mail/1'],
['GET', '/api/parsed_mails?limit=20&offset=0'],
['GET', '/api/parsed_mail/1'],
['GET', '/api/sendbox?limit=20&offset=0'],
['GET', '/api/auto_reply'],
['POST', '/api/webhook/settings'],
['POST', '/api/attachment/get_url'],
['POST', '/api/address_change_password'],
['POST', '/api/request_send_mail_access'],
['POST', '/api/send_mail'],
['PATCH', '/api/mails/1/read'],
['DELETE', '/api/mails/1'],
['DELETE', '/api/sendbox/1'],
['DELETE', '/api/clear_inbox'],
['DELETE', '/api/clear_sent_items'],
['DELETE', '/api/delete_address'],
]) {
const response = await request.fetch(`${WORKER_URL}${path}`, {
method,
headers: { Authorization: `Bearer ${jwt}` },
...(method === 'POST' || method === 'PATCH' ? { data: {} } : {}),
});
expect(response.status(), `${method} ${path}`).toBe(401);
}
const login = await request.post(`${WORKER_URL}/open_api/credential_login`, {
data: { credential: jwt },
});
expect(login.status()).toBe(401);
const send = await request.post(`${WORKER_URL}/external/api/send_mail`, {
headers: { 'x-lang': 'en' },
data: { token: jwt },
});
expect(send.status()).toBe(400);
expect(await send.text()).toBe('Failed to send mail Invalid address credential');
const bind = await request.post(`${WORKER_URL}/user_api/bind_address`, {
headers: {
Authorization: `Bearer ${jwt}`,
'x-user-token': signToken({ user_id: 1, exp: Math.floor(Date.now() / 1000) + 60 }),
},
});
expect(bind.status()).toBe(401);
}
test('deleted credentials cannot access or delete a recreated mailbox', async ({ request }) => {
const name = `credential${Date.now()}`;
const create = async () => {
const response = await request.post(`${WORKER_URL}/api/new_address`, {
data: { name, domain: TEST_DOMAIN },
});
expect(response.ok()).toBe(true);
return await response.json();
};
const original = await create();
await deleteAddress(request, original.jwt);
await expectRejected(request, original.jwt);
const recreated = await create();
try {
expect(recreated.address).toBe(original.address);
expect(recreated.address_id).not.toBe(original.address_id);
await seedTestMail(request, recreated.address, { subject: 'New owner mail' });
await expectRejected(request, original.jwt);
const mails = await request.get(`${WORKER_URL}/api/mails?limit=20&offset=0`, {
headers: { Authorization: `Bearer ${recreated.jwt}` },
});
expect(mails.ok()).toBe(true);
expect((await mails.json()).count).toBe(1);
} finally {
await deleteAddress(request, recreated.jwt);
}
});
test('valid numeric/string IDs work; missing, invalid and mismatched IDs are rejected', async ({ request }) => {
const mailbox = await createTestAddress(request, 'credential-id');
try {
for (const address_id of [mailbox.address_id, String(mailbox.address_id)]) {
const token = signToken({ address: mailbox.address, address_id });
const settings = await request.get(`${WORKER_URL}/api/settings`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(settings.ok()).toBe(true);
const login = await request.post(`${WORKER_URL}/open_api/credential_login`, {
data: { credential: token },
});
expect(login.ok()).toBe(true);
}
for (const payload of [
{ address: mailbox.address },
...[0, -1, 1.5, true, null, '', '1e3', {}, Number.MAX_SAFE_INTEGER + 1]
.map(address_id => ({ address: mailbox.address, address_id })),
{ address: `other@${TEST_DOMAIN}`, address_id: mailbox.address_id },
{ address_id: mailbox.address_id },
]) {
const jwt = signToken(payload);
const response = await request.get(`${WORKER_URL}/api/mails?limit=20&offset=0`, {
headers: { Authorization: `Bearer ${jwt}` },
});
expect(response.status(), JSON.stringify(payload)).toBe(401);
const login = await request.post(`${WORKER_URL}/open_api/credential_login`, {
data: { credential: jwt },
});
expect(login.status(), JSON.stringify(payload)).toBe(401);
}
} finally {
await deleteAddress(request, mailbox.jwt);
}
});
-73
View File
@@ -1,73 +0,0 @@
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);
});
});
+1 -1
View File
@@ -175,7 +175,7 @@ async function seedTestMailWithReply(
text,
].join('\r\n');
const res = await ctx.post(`${WORKER_URL}/__test/receive_mail`, {
const res = await ctx.post(`${WORKER_URL}/admin/test/receive_mail`, {
data: { from, to: address, raw },
});
if (!res.ok()) {
+3 -3
View File
@@ -13,7 +13,7 @@ test.describe('Bounded cleanup', () => {
test('cleans at most one batch and continues on the next run', async ({ request }) => {
const address = `cleanup-batch-${Date.now()}@test.example.com`;
const seedResponses = await Promise.all(Array.from({ length: 11 }, (_, index) =>
request.post(`${WORKER_URL}/__test/seed_mail`, {
request.post(`${WORKER_URL}/admin/test/seed_mail`, {
data: {
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}/__test/seed_mail`, {
const recentMailResponse = await request.post(`${WORKER_URL}/admin/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}/__test/seed_mail`, {
const seedResponse = await request.post(`${WORKER_URL}/admin/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}/__test/receive_mail`, {
const res = await request.post(`${WORKER_URL}/admin/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}/__test/receive_mail`, {
const res = await request.post(`${WORKER_URL}/admin/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}/__test/receive_mail`, {
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
data: { from: 'sender@test.example.com', to: address, raw },
});
expect(res.ok()).toBe(true);
-3
View File
@@ -19,7 +19,6 @@ 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) });
});
});
@@ -44,7 +43,6 @@ 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 }) => {
@@ -55,7 +53,6 @@ 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) });
});
});
+1 -1
View File
@@ -70,7 +70,7 @@ test.describe('Mail Deletion', () => {
`--${boundary}--`,
].join('\r\n');
const seedRes = await request.post(`${WORKER_URL_ENV_OFF}/__test/receive_mail`, {
const seedRes = await request.post(`${WORKER_URL_ENV_OFF}/admin/test/receive_mail`, {
data: { from, to: address, raw },
});
expect(seedRes.ok()).toBe(true);
-108
View File
@@ -1,108 +0,0 @@
import { expect, test } from '@playwright/test';
import {
WORKER_URL,
WORKER_URL_ENV_OFF,
createTestAddress,
deleteAddress,
seedTestMail,
} from '../../fixtures/test-helpers';
const headers = (jwt: string) => ({ Authorization: `Bearer ${jwt}` });
test.describe('Mail read status', () => {
test('keeps historical mail read and switches one new mail state', async ({ request }) => {
const mailbox = await createTestAddress(request, 'mail-read');
try {
const historical = await request.post(`${WORKER_URL}/__test/seed_mail`, {
data: {
address: mailbox.address,
source: 'sender@test.example.com',
raw: 'From: sender@test.example.com\r\nSubject: Historical\r\n\r\nHistorical',
},
});
expect(historical.ok()).toBe(true);
await seedTestMail(request, mailbox.address, { subject: 'New unread mail' });
const list = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
headers: headers(mailbox.jwt),
});
const mails = (await list.json()).results;
expect(mails.find((mail: any) => mail.raw.includes('Historical')).is_unread).toBeNull();
const unreadMail = mails.find((mail: any) => mail.raw.includes('New unread mail'));
expect(unreadMail.is_unread).toBe(1);
const markRead = await request.patch(`${WORKER_URL}/api/mails/${unreadMail.id}/read`, {
headers: headers(mailbox.jwt),
data: { isUnread: false },
});
expect((await markRead.json()).success).toBe(true);
const updated = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
headers: headers(mailbox.jwt),
});
const updatedMail = (await updated.json()).results.find((mail: any) => mail.id === unreadMail.id);
expect(updatedMail.is_unread).toBe(0);
const markUnread = await request.patch(`${WORKER_URL}/api/mails/${unreadMail.id}/read`, {
headers: headers(mailbox.jwt),
data: { isUnread: true },
});
expect((await markUnread.json()).success).toBe(true);
const restored = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
headers: headers(mailbox.jwt),
});
const restoredMail = (await restored.json()).results.find((mail: any) => mail.id === unreadMail.id);
expect(restoredMail.is_unread).toBe(1);
const invalid = await request.patch(`${WORKER_URL}/api/mails/${unreadMail.id}/read`, {
headers: headers(mailbox.jwt),
data: { isUnread: 'yes' },
});
expect(invalid.status()).toBe(400);
} finally {
await deleteAddress(request, mailbox.jwt);
}
});
test('scopes the update and keeps disabled instances unchanged', async ({ request }) => {
const first = await createTestAddress(request, 'mail-read-first');
const second = await createTestAddress(request, 'mail-read-second');
try {
await seedTestMail(request, second.address, { subject: 'Second mail' });
const secondList = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
headers: headers(second.jwt),
});
const secondMail = (await secondList.json()).results[0];
await request.patch(`${WORKER_URL}/api/mails/${secondMail.id}/read`, {
headers: headers(first.jwt),
data: { isUnread: false },
});
const unchanged = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
headers: headers(second.jwt),
});
expect((await unchanged.json()).results[0].is_unread).toBe(1);
const disabledMailbox = await createTestAddress(
request,
'mail-read-disabled',
'test.example.com',
WORKER_URL_ENV_OFF,
);
const oldList = await request.get(`${WORKER_URL_ENV_OFF}/api/mails?limit=10&offset=0`, {
headers: headers(disabledMailbox.jwt),
});
expect(oldList.ok()).toBe(true);
const disabledUpdate = await request.patch(`${WORKER_URL_ENV_OFF}/api/mails/1/read`, {
headers: headers(disabledMailbox.jwt),
data: { isUnread: false },
});
expect(disabledUpdate.status()).toBe(403);
} finally {
await deleteAddress(request, first.jwt);
await deleteAddress(request, second.jwt);
}
});
});
-312
View File
@@ -1,312 +0,0 @@
import { expect, test } from '@playwright/test';
import { createHmac } from 'node:crypto';
import {
WORKER_URL_ENV_OFF,
WORKER_URL_SITE_PASSWORD,
} from '../../fixtures/test-helpers';
const SITE_HEADERS = { 'x-custom-auth': 'e2e-site-pass' };
const ADMIN_HEADERS = { ...SITE_HEADERS, 'x-admin-auth': 'e2e-admin-pass' };
const futureExpiration = () => new Date(Date.now() + 3_600_000).toISOString();
const signTestToken = (payload: Record<string, unknown>, secret = 'e2e-site-password-secret') => {
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
const signature = createHmac('sha256', secret).update(`${header}.${body}`).digest('base64url');
return `${header}.${body}.${signature}`;
};
const tokenPayload = (role: string) => ({
user_id: 1,
user_role: role,
exp: Math.floor(Date.now() / 1000) + 3600,
});
test.describe('Redemption feature access boundaries', () => {
test('the disabled switch hides every user and Admin endpoint', async ({ request }) => {
const settingsResponse = await request.get(`${WORKER_URL_ENV_OFF}/open_api/settings`);
expect(settingsResponse.ok()).toBe(true);
expect((await settingsResponse.json()).enableRedeemCode).toBe(false);
const requests = [
request.post(`${WORKER_URL_ENV_OFF}/redeem_api/query`, { data: { code: 'anything' } }),
request.post(`${WORKER_URL_ENV_OFF}/redeem_api/result`, { data: { code: 'anything' } }),
request.post(`${WORKER_URL_ENV_OFF}/redeem_api/redeem`, {
data: { code: 'anything', user_email: 'user@test.example.com' },
}),
request.get(`${WORKER_URL_ENV_OFF}/admin/redeem_codes?redeem_type=role`),
request.get(`${WORKER_URL_ENV_OFF}/admin/redeem_codes/export?redeem_type=role&limit=1`),
request.post(`${WORKER_URL_ENV_OFF}/admin/redeem_codes/batch`, {
data: {
count: 1,
redeem_type: 'role',
value: 'case-role',
enabled: true,
expires_at: futureExpiration(),
},
}),
request.put(`${WORKER_URL_ENV_OFF}/admin/redeem_codes/1`, {
data: {
redeem_type: 'role',
value: 'case-role',
enabled: true,
expires_at: futureExpiration(),
},
}),
request.delete(`${WORKER_URL_ENV_OFF}/admin/redeem_codes/1`),
];
const responses = await Promise.all(requests);
for (const response of responses) {
expect(response.status()).toBe(404);
}
});
test('site password takes priority over otherwise public redemption APIs', async ({ request }) => {
const settingsResponse = await request.get(`${WORKER_URL_SITE_PASSWORD}/open_api/settings`);
expect(settingsResponse.ok()).toBe(true);
expect(await settingsResponse.json()).toMatchObject({
needAuth: true,
enableRedeemCode: true,
});
const blockedAdminResponse = await request.post(
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/batch`,
{
headers: { 'x-admin-auth': 'e2e-admin-pass' },
data: {
count: 1,
redeem_type: 'role',
value: 'case-role',
enabled: true,
expires_at: futureExpiration(),
},
},
);
expect(blockedAdminResponse.status()).toBe(401);
expect(await blockedAdminResponse.json()).toMatchObject({ code: 'AUTH_SITE_PASSWORD_INVALID' });
const createResponse = await request.post(
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/batch`,
{
headers: ADMIN_HEADERS,
data: {
count: 1,
redeem_type: 'role',
value: 'case-role',
enabled: true,
expires_at: futureExpiration(),
},
},
);
expect(createResponse.ok()).toBe(true);
const code = (await createResponse.json()).codes[0] as string;
for (const path of ['query', 'result', 'redeem']) {
const missingPassword = await request.post(
`${WORKER_URL_SITE_PASSWORD}/redeem_api/${path}`,
{ data: { code } },
);
expect(missingPassword.status()).toBe(401);
expect(await missingPassword.json()).toMatchObject({ code: 'AUTH_SITE_PASSWORD_INVALID' });
const wrongPassword = await request.post(
`${WORKER_URL_SITE_PASSWORD}/redeem_api/${path}`,
{ headers: { 'x-custom-auth': 'wrong' }, data: { code } },
);
expect(wrongPassword.status()).toBe(401);
expect(await wrongPassword.json()).toMatchObject({ code: 'AUTH_SITE_PASSWORD_INVALID' });
}
const validPassword = await request.post(`${WORKER_URL_SITE_PASSWORD}/redeem_api/query`, {
headers: SITE_HEADERS,
data: { code },
});
expect(validPassword.ok()).toBe(true);
expect(await validPassword.json()).toEqual({
redeem_type: 'role', value: 'case-role', status: 'unused',
});
const listResponse = await request.get(
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes?redeem_type=role`
+ `&limit=20&offset=0&query=${encodeURIComponent(code)}`,
{ headers: ADMIN_HEADERS },
);
const row = (await listResponse.json()).results[0];
const deleteResponse = await request.delete(
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/${row.id}`,
{ headers: ADMIN_HEADERS },
);
expect(deleteResponse.ok()).toBe(true);
});
test('special-address redemption preserves the configured address regex', async ({ request }) => {
const createResponse = await request.post(
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/batch`,
{
headers: ADMIN_HEADERS,
data: {
count: 1,
redeem_type: 'address_prefix_once',
value: '',
enabled: true,
expires_at: futureExpiration(),
},
},
);
expect(createResponse.ok()).toBe(true);
const code = (await createResponse.json()).codes[0] as string;
const redeemResponse = await request.post(
`${WORKER_URL_SITE_PASSWORD}/redeem_api/redeem`,
{
headers: SITE_HEADERS,
data: { code, name: 'blocked', domain: 'test.example.com' },
},
);
expect(redeemResponse.status()).toBe(400);
const queryResponse = await request.post(
`${WORKER_URL_SITE_PASSWORD}/redeem_api/query`,
{ headers: SITE_HEADERS, data: { code } },
);
expect(queryResponse.ok()).toBe(true);
const listResponse = await request.get(
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes?redeem_type=address_prefix_once`
+ `&limit=20&offset=0&query=${encodeURIComponent(code)}`,
{ headers: ADMIN_HEADERS },
);
const row = (await listResponse.json()).results[0];
const deleteResponse = await request.delete(
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/${row.id}`,
{ headers: ADMIN_HEADERS },
);
expect(deleteResponse.ok()).toBe(true);
});
});
test.describe('Redemption Admin authentication', () => {
const baseUrl = `${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes`;
const listUrl = `${baseUrl}?redeem_type=address_prefix_once&limit=100&offset=0`;
const codeData = () => ({
count: 1,
redeem_type: 'address_prefix_once',
value: 'auth',
enabled: true,
expires_at: futureExpiration(),
});
const deniedCredentials: { name: string; headers: () => Record<string, string> }[] = [
{ name: 'site password alone', headers: () => ({}) },
{ name: 'wrong Admin password', headers: () => ({ 'x-admin-auth': 'wrong' }) },
{
name: 'mailbox JWT',
headers: () => ({
Authorization: `Bearer ${signTestToken({ address: 'mail@test.example.com', address_id: 1 })}`,
}),
},
{
name: 'user account JWT',
headers: () => ({
'x-user-token': signTestToken({ user_id: 1, exp: Math.floor(Date.now() / 1000) + 3600 }),
}),
},
{
name: 'non-Admin role token',
headers: () => ({ 'x-user-access-token': signTestToken(tokenPayload('case-role')) }),
},
{
name: 'forged Admin role token',
headers: () => ({ 'x-user-access-token': signTestToken(tokenPayload('admin'), 'wrong-secret') }),
},
{
name: 'expired Admin role token',
headers: () => ({
'x-user-access-token': signTestToken({ ...tokenPayload('admin'), exp: Math.floor(Date.now() / 1000) - 60 }),
}),
},
{
name: 'Admin role token without expiration',
headers: () => ({ 'x-user-access-token': signTestToken({ user_id: 1, user_role: 'admin' }) }),
},
{ name: 'malformed role token', headers: () => ({ 'x-user-access-token': 'not-a-jwt' }) },
];
for (const credentials of deniedCredentials) {
test(`rejects ${credentials.name} on all five endpoints without changing data`, async ({ request }) => {
const original = codeData();
const created = await request.post(`${baseUrl}/batch`, { headers: ADMIN_HEADERS, data: original });
expect(created.ok()).toBe(true);
const code = (await created.json()).codes[0] as string;
const beforeResponse = await request.get(listUrl, { headers: ADMIN_HEADERS });
expect(beforeResponse.ok()).toBe(true);
const before = await beforeResponse.json();
const row = before.results.find((item: { code: string }) => item.code === code);
expect(row).toBeDefined();
try {
const headers = { ...SITE_HEADERS, ...credentials.headers() };
const responses = await Promise.all([
request.get(listUrl, { headers }),
request.get(`${baseUrl}/export?redeem_type=address_prefix_once&limit=100`, { headers }),
request.post(`${baseUrl}/batch`, { headers, data: original }),
request.put(`${baseUrl}/${row.id}`, { headers, data: { ...original, value: 'changed' } }),
request.delete(`${baseUrl}/${row.id}`, { headers }),
]);
for (const response of responses) {
expect(response.status(), response.url()).toBe(401);
expect(await response.json()).toMatchObject({
code: credentials.name === 'expired Admin role token'
? 'AUTH_USER_ACCESS_TOKEN_EXPIRED' : 'AUTH_ADMIN_CREDENTIAL_INVALID',
});
expect(await response.text()).not.toContain(code);
}
const after = await request.get(listUrl, { headers: ADMIN_HEADERS });
expect(after.ok()).toBe(true);
expect(await after.json()).toEqual(before);
} finally {
const deleted = await request.delete(`${baseUrl}/${row.id}`, { headers: ADMIN_HEADERS });
expect(deleted.ok()).toBe(true);
}
});
}
for (const authType of ['password', 'role token'] as const) {
test(`accepts a valid Admin ${authType} for all five endpoints`, async ({ request }) => {
const headers: Record<string, string> = authType === 'password'
? ADMIN_HEADERS
: { ...SITE_HEADERS, 'x-user-access-token': signTestToken(tokenPayload('admin')) };
const original = codeData();
const created = await request.post(`${baseUrl}/batch`, { headers, data: original });
expect(created.ok()).toBe(true);
const code = (await created.json()).codes[0] as string;
const listResponse = await request.get(listUrl, { headers });
expect(listResponse.ok()).toBe(true);
const row = (await listResponse.json()).results.find((item: { code: string }) => item.code === code);
expect(row).toBeDefined();
let deleted = false;
try {
const exported = await request.get(`${baseUrl}/export?redeem_type=address_prefix_once&limit=100`, { headers });
expect(exported.ok()).toBe(true);
expect(exported.headers()['content-type']).toContain('text/csv');
expect(await exported.text()).toContain(code);
const updated = await request.put(`${baseUrl}/${row.id}`, { headers, data: { ...original, value: 'updated' } });
expect(updated.ok()).toBe(true);
const afterUpdate = await request.get(`${listUrl}&query=${encodeURIComponent(code)}`, { headers });
expect(afterUpdate.ok()).toBe(true);
expect((await afterUpdate.json()).results).toEqual([
expect.objectContaining({ id: row.id, code, value: 'updated' }),
]);
const deletion = await request.delete(`${baseUrl}/${row.id}`, { headers });
expect(deletion.ok()).toBe(true);
deleted = true;
const afterDelete = await request.get(`${listUrl}&query=${encodeURIComponent(code)}`, { headers });
expect(afterDelete.ok()).toBe(true);
expect(await afterDelete.json()).toEqual({ results: [], count: 0 });
} finally {
if (!deleted) {
const cleanup = await request.delete(`${baseUrl}/${row.id}`, { headers: ADMIN_HEADERS });
expect(cleanup.ok()).toBe(true);
}
}
});
}
});
-336
View File
@@ -1,336 +0,0 @@
import { expect, test, type APIRequestContext } from '@playwright/test';
import { deleteAddress, WORKER_URL } from '../../fixtures/test-helpers';
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
const MAX_BATCH_SIZE = 500;
const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
type RedeemType = 'role' | 'send_balance' | 'address_prefix_once';
async function createCodes(
request: APIRequestContext,
{
count = 1,
type = 'role',
value = 'case-role',
enabled = true,
expiresAt = new Date(Date.now() + 3_600_000).toISOString(),
}: {
count?: unknown;
type?: string;
value?: unknown;
enabled?: unknown;
expiresAt?: unknown;
},
) {
return await request.post(`${WORKER_URL}/admin/redeem_codes/batch`, {
headers: ADMIN_HEADERS,
data: {
count,
redeem_type: type,
value,
enabled,
expires_at: expiresAt,
},
});
}
async function listCodes(
request: APIRequestContext,
type: RedeemType,
query = '',
) {
const response = await request.get(
`${WORKER_URL}/admin/redeem_codes?redeem_type=${type}&limit=100&offset=0`
+ (query ? `&query=${encodeURIComponent(query)}` : ''),
{ headers: ADMIN_HEADERS },
);
expect(response.ok()).toBe(true);
return await response.json();
}
async function deleteCode(request: APIRequestContext, id: number) {
const response = await request.delete(`${WORKER_URL}/admin/redeem_codes/${id}`, {
headers: ADMIN_HEADERS,
});
expect(response.ok()).toBe(true);
}
test.describe('Redemption code Admin API', () => {
test('rejects malformed JSON when creating codes', async ({ request }) => {
const response = await request.post(`${WORKER_URL}/admin/redeem_codes/batch`, {
headers: { ...ADMIN_HEADERS, 'content-type': 'application/json' },
data: '{',
});
expect(response.status()).toBe(400);
});
test('reports the enabled switch and exposes the migrated table', async ({ request }) => {
const settingsResponse = await request.get(`${WORKER_URL}/open_api/settings`);
expect(settingsResponse.ok()).toBe(true);
expect((await settingsResponse.json()).enableRedeemCode).toBe(true);
const listResponse = await request.get(
`${WORKER_URL}/admin/redeem_codes?redeem_type=role&limit=20&offset=0`,
{ headers: ADMIN_HEADERS },
);
expect(listResponse.ok()).toBe(true);
await expect(listResponse.json()).resolves.toMatchObject({
results: expect.any(Array),
count: expect.any(Number),
});
});
test('batch generates UUIDs, filters by type, updates, and deletes', async ({ request }) => {
const createResponse = await createCodes(request, { count: 2 });
expect(createResponse.ok()).toBe(true);
const created = await createResponse.json();
expect(created).toMatchObject({ success: true, created: 2 });
expect(created.codes).toHaveLength(2);
expect(new Set(created.codes).size).toBe(2);
expect(created.codes.every((code: string) => UUID_V4_PATTERN.test(code))).toBe(true);
const [firstCode, secondCode] = created.codes as string[];
const queryPrefix = firstCode.slice(0, 8);
const roleList = await listCodes(request, 'role', queryPrefix);
expect(roleList.count).toBe(1);
expect(roleList.results[0]).toMatchObject({
code: firstCode,
redeem_type: 'role',
value: 'case-role',
enabled: 1,
redeemed: false,
result: null,
});
const balanceList = await listCodes(request, 'send_balance', queryPrefix);
expect(balanceList.count).toBe(0);
const firstId = roleList.results[0].id as number;
const expiresAt = new Date(Date.now() + 3_600_000).toISOString();
const updateResponse = await request.put(`${WORKER_URL}/admin/redeem_codes/${firstId}`, {
headers: ADMIN_HEADERS,
data: {
redeem_type: 'role',
value: 'empty-role',
enabled: false,
expires_at: expiresAt,
},
});
expect(updateResponse.ok()).toBe(true);
const updatedList = await listCodes(request, 'role', firstCode);
expect(updatedList.count).toBe(1);
expect(updatedList.results[0]).toMatchObject({
id: firstId,
code: firstCode,
value: 'empty-role',
enabled: 0,
});
expect(updatedList.results[0].expires_at).toEqual(expect.any(String));
const secondList = await listCodes(request, 'role', secondCode);
await deleteCode(request, firstId);
await deleteCode(request, secondList.results[0].id);
expect((await listCodes(request, 'role', firstCode)).count).toBe(0);
});
test('normalizes typed values and accepts both empty and maximum usable prefixes', async ({ request }) => {
const emptyResponse = await createCodes(request, {
type: 'address_prefix_once',
value: ' ',
});
expect(emptyResponse.ok()).toBe(true);
const emptyCode = (await emptyResponse.json()).codes[0] as string;
const maxResponse = await createCodes(request, {
type: 'address_prefix_once',
value: ` ${'A'.repeat(29)} `,
});
expect(maxResponse.ok()).toBe(true);
const maxCode = (await maxResponse.json()).codes[0] as string;
const balanceResponse = await createCodes(request, {
type: 'send_balance',
value: ' 42 ',
});
expect(balanceResponse.ok()).toBe(true);
const balanceCode = (await balanceResponse.json()).codes[0] as string;
const emptyRow = (await listCodes(request, 'address_prefix_once', emptyCode)).results[0];
const maxRow = (await listCodes(request, 'address_prefix_once', maxCode)).results[0];
const balanceRow = (await listCodes(request, 'send_balance', balanceCode)).results[0];
expect(emptyRow.value).toBe('');
expect(maxRow.value).toBe('a'.repeat(29));
expect(balanceRow.value).toBe('42');
await deleteCode(request, emptyRow.id);
await deleteCode(request, maxRow.id);
await deleteCode(request, balanceRow.id);
});
test('rejects every invalid batch shape and typed business value', async ({ request }) => {
const invalidRequests = [
{ type: 'unknown', value: 'x' },
{ type: 'role', value: '' },
{ type: 'role', value: 'missing-role' },
{ type: 'role', value: 'admin' },
{ type: 'send_balance', value: '0' },
{ type: 'send_balance', value: '-1' },
{ type: 'send_balance', value: '1.5' },
{ type: 'send_balance', value: '1000000001' },
{ type: 'address_prefix_once', value: 'bad-' },
{ type: 'address_prefix_once', value: 'a'.repeat(30) },
{ count: 0 },
{ count: -1 },
{ count: 1.5 },
{ count: MAX_BATCH_SIZE + 1 },
{ count: '1' },
{ count: null },
{ enabled: 'yes' },
{ value: 1 },
{ expiresAt: null },
{ expiresAt: '' },
{ expiresAt: 'not-a-date' },
{ expiresAt: new Date(Date.now() - 60_000).toISOString() },
];
for (const invalidRequest of invalidRequests) {
const response = await createCodes(request, invalidRequest);
expect(response.status(), JSON.stringify(invalidRequest)).toBe(400);
}
});
test('rejects invalid updates and IDs', async ({ request }) => {
const expiresAt = new Date(Date.now() + 3_600_000).toISOString();
const createResponse = await createCodes(request, { count: 2 });
expect(createResponse.ok()).toBe(true);
const [firstCode, secondCode] = (await createResponse.json()).codes as string[];
const firstRow = (await listCodes(request, 'role', firstCode)).results[0];
const secondRow = (await listCodes(request, 'role', secondCode)).results[0];
const invalidId = await request.put(`${WORKER_URL}/admin/redeem_codes/${firstRow.id}x`, {
headers: ADMIN_HEADERS,
data: { redeem_type: 'role', value: 'case-role', enabled: true, expires_at: expiresAt },
});
expect(invalidId.status()).toBe(400);
const invalidRole = await request.put(`${WORKER_URL}/admin/redeem_codes/${firstRow.id}`, {
headers: ADMIN_HEADERS,
data: { redeem_type: 'role', value: 'admin', enabled: true, expires_at: expiresAt },
});
expect(invalidRole.status()).toBe(400);
const wrongType = await request.put(`${WORKER_URL}/admin/redeem_codes/${firstRow.id}`, {
headers: ADMIN_HEADERS,
data: {
redeem_type: 'send_balance',
value: '1',
enabled: true,
expires_at: expiresAt,
},
});
expect(wrongType.status()).toBe(404);
const invalidDelete = await request.delete(`${WORKER_URL}/admin/redeem_codes/${firstRow.id}x`, {
headers: ADMIN_HEADERS,
});
expect(invalidDelete.status()).toBe(400);
await deleteCode(request, firstRow.id);
await deleteCode(request, secondRow.id);
});
test('does not update a redeemed code', async ({ request }) => {
let code = '';
let addressResult: { jwt?: string } | undefined;
try {
const createResponse = await createCodes(request, {
type: 'address_prefix_once',
value: 'locked',
});
expect(createResponse.ok()).toBe(true);
code = (await createResponse.json()).codes[0] as string;
const redeemResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, name: 'mail', domain: 'test.example.com' },
});
expect(redeemResponse.ok()).toBe(true);
addressResult = await redeemResponse.json();
const row = (await listCodes(request, 'address_prefix_once', code)).results[0];
expect(row.redeemed).toBe(true);
expect(row.redeemed_at).toEqual(expect.any(String));
const updateResponse = await request.put(`${WORKER_URL}/admin/redeem_codes/${row.id}`, {
headers: ADMIN_HEADERS,
data: {
redeem_type: 'address_prefix_once',
value: 'changed',
enabled: false,
expires_at: new Date(Date.now() + 3_600_000).toISOString(),
},
});
expect(updateResponse.status()).toBe(409);
const unchanged = (await listCodes(request, 'address_prefix_once', code)).results[0];
expect(unchanged).toMatchObject({
code,
value: 'locked',
enabled: 1,
redeemed: true,
});
await deleteCode(request, row.id);
} finally {
if (addressResult?.jwt) await deleteAddress(request, addressResult.jwt);
const row = (await listCodes(request, 'address_prefix_once', code)).results[0];
if (row) await deleteCode(request, row.id);
}
});
test('exports one selected type with its business columns and enforces the limit', async ({ request }) => {
const roleResponse = await createCodes(request, {});
expect(roleResponse.ok()).toBe(true);
const roleCode = (await roleResponse.json()).codes[0] as string;
const balanceResponse = await createCodes(request, {
type: 'send_balance',
value: '25',
});
expect(balanceResponse.ok()).toBe(true);
const balanceCode = (await balanceResponse.json()).codes[0] as string;
const roleExport = await request.get(
`${WORKER_URL}/admin/redeem_codes/export?redeem_type=role&limit=100`,
{ headers: ADMIN_HEADERS },
);
expect(roleExport.ok()).toBe(true);
expect(roleExport.headers()['content-type']).toContain('text/csv');
expect(roleExport.headers()['content-disposition']).toContain('redeem-codes-role.csv');
const roleCsv = await roleExport.text();
expect(roleCsv).toContain('role,redeemed_user_id');
expect(roleCsv).toContain(roleCode);
expect(roleCsv).not.toContain(balanceCode);
const balanceExport = await request.get(
`${WORKER_URL}/admin/redeem_codes/export?redeem_type=send_balance&limit=100`,
{ headers: ADMIN_HEADERS },
);
const balanceCsv = await balanceExport.text();
expect(balanceCsv).toContain('amount,target_address');
expect(balanceCsv).toContain(balanceCode);
expect(balanceCsv).not.toContain(roleCode);
for (const query of [
'redeem_type=unknown&limit=1',
'redeem_type=role&limit=0',
'redeem_type=role&limit=10001',
'redeem_type=role&limit=1x',
'redeem_type=role',
]) {
const response = await request.get(`${WORKER_URL}/admin/redeem_codes/export?${query}`, {
headers: ADMIN_HEADERS,
});
expect(response.status(), query).toBe(400);
}
const roleRow = (await listCodes(request, 'role', roleCode)).results[0];
const balanceRow = (await listCodes(request, 'send_balance', balanceCode)).results[0];
await deleteCode(request, roleRow.id);
await deleteCode(request, balanceRow.id);
});
});
-628
View File
@@ -1,628 +0,0 @@
import { expect, test, type APIRequestContext } from '@playwright/test';
import {
WORKER_URL,
createTestAddress,
deleteAddress,
getAddressSender,
hashPassword,
} from '../../fixtures/test-helpers';
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
type RedeemType = 'role' | 'send_balance' | 'address_prefix_once';
type AddressRedeemResult = {
type: 'address_prefix_once';
address: string;
address_id: number;
jwt: string;
password?: string;
};
const uniqueValue = (label: string) => (
`${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
);
async function createCode(
request: APIRequestContext,
type: RedeemType,
value: string,
options: { enabled?: boolean; expiresAt?: string } = {},
) {
const response = await request.post(`${WORKER_URL}/admin/redeem_codes/batch`, {
headers: ADMIN_HEADERS,
data: {
count: 1,
redeem_type: type,
value,
enabled: options.enabled ?? true,
expires_at: options.expiresAt ?? new Date(Date.now() + 3_600_000).toISOString(),
},
});
expect(response.ok()).toBe(true);
const body = await response.json();
expect(body.codes).toHaveLength(1);
return body.codes[0] as string;
}
async function listCode(request: APIRequestContext, type: RedeemType, code: string) {
const response = await request.get(
`${WORKER_URL}/admin/redeem_codes?redeem_type=${type}`
+ `&limit=100&offset=0&query=${encodeURIComponent(code)}`,
{ headers: ADMIN_HEADERS },
);
expect(response.ok()).toBe(true);
const body = await response.json();
return body.results.find((row: { code: string }) => row.code === code);
}
async function deleteCode(request: APIRequestContext, type: RedeemType, code: string) {
const row = await listCode(request, type, code);
if (!row) return;
const response = await request.delete(`${WORKER_URL}/admin/redeem_codes/${row.id}`, {
headers: ADMIN_HEADERS,
});
expect(response.ok()).toBe(true);
}
async function queryCode(request: APIRequestContext, code: unknown) {
return await request.post(`${WORKER_URL}/redeem_api/query`, { data: { code } });
}
async function queryResult(request: APIRequestContext, code: unknown) {
return await request.post(`${WORKER_URL}/redeem_api/result`, { data: { code } });
}
async function createUser(request: APIRequestContext) {
const email = `${uniqueValue('redeem-user')}@test.example.com`;
const createResponse = await request.post(`${WORKER_URL}/admin/users`, {
headers: ADMIN_HEADERS,
data: { email, password: hashPassword('redeem-user-password') },
});
expect(createResponse.ok()).toBe(true);
const listResponse = await request.get(
`${WORKER_URL}/admin/users?limit=10&offset=0&query=${encodeURIComponent(email)}`,
{ headers: ADMIN_HEADERS },
);
const user = (await listResponse.json()).results.find(
(row: { user_email: string }) => row.user_email === email,
);
expect(user).toBeTruthy();
await setUserRole(request, user.id, null);
return { id: user.id as number, email };
}
async function setUserRole(request: APIRequestContext, userId: number, role: string | null) {
const response = await request.post(`${WORKER_URL}/admin/user_roles`, {
headers: ADMIN_HEADERS,
data: { user_id: userId, role_text: role },
});
expect(response.ok()).toBe(true);
}
async function deleteUser(request: APIRequestContext, userId: number) {
await setUserRole(request, userId, null);
const response = await request.delete(`${WORKER_URL}/admin/users/${userId}`, {
headers: ADMIN_HEADERS,
});
expect(response.ok()).toBe(true);
}
test.describe('Redemption code query and guards', () => {
test('previews each supported type without consuming it', async ({ request }) => {
const roleCode = await createCode(request, 'role', 'case-role');
const balanceCode = await createCode(request, 'send_balance', '12');
const addressCode = await createCode(request, 'address_prefix_once', 'vip');
try {
const roleResponse = await queryCode(request, roleCode);
expect(roleResponse.ok()).toBe(true);
expect(await roleResponse.json()).toEqual({
redeem_type: 'role', value: 'case-role', status: 'unused',
});
const balanceResponse = await queryCode(request, balanceCode);
expect(balanceResponse.ok()).toBe(true);
expect(await balanceResponse.json()).toEqual({
redeem_type: 'send_balance', value: '12', status: 'unused',
});
const addressResponse = await queryCode(request, addressCode);
expect(addressResponse.ok()).toBe(true);
expect(await addressResponse.json()).toEqual({
redeem_type: 'address_prefix_once',
value: 'vip',
status: 'unused',
});
expect((await listCode(request, 'role', roleCode)).redeemed_at).toBeNull();
expect((await listCode(request, 'send_balance', balanceCode)).redeemed_at).toBeNull();
expect((await listCode(request, 'address_prefix_once', addressCode)).redeemed_at).toBeNull();
expect((await listCode(request, 'role', roleCode)).redeemed).toBe(false);
expect((await listCode(request, 'send_balance', balanceCode)).redeemed).toBe(false);
expect((await listCode(request, 'address_prefix_once', addressCode)).redeemed).toBe(false);
expect((await queryResult(request, addressCode)).status()).toBe(400);
} finally {
await deleteCode(request, 'role', roleCode);
await deleteCode(request, 'send_balance', balanceCode);
await deleteCode(request, 'address_prefix_once', addressCode);
}
});
test('rejects missing, malformed, unknown, and disabled codes, but reports expired codes', async ({ request }) => {
const disabledCode = await createCode(request, 'role', 'case-role', { enabled: false });
const expiredCode = await createCode(request, 'role', 'case-role', {
expiresAt: new Date(Date.now() + 5_000).toISOString(),
});
try {
await new Promise((resolve) => setTimeout(resolve, 5_500));
for (const code of [undefined, null, '', 'x'.repeat(257), uniqueValue('missing')]) {
expect((await queryCode(request, code)).status()).toBe(400);
}
expect((await queryCode(request, disabledCode)).status()).toBe(400);
const expiredResponse = await queryCode(request, expiredCode);
expect(expiredResponse.ok()).toBe(true);
expect(await expiredResponse.json()).toEqual({
redeem_type: 'role', value: 'case-role', status: 'expired',
});
} finally {
await deleteCode(request, 'role', disabledCode);
await deleteCode(request, 'role', expiredCode);
}
});
test('rejects malformed JSON request bodies', async ({ request }) => {
const options = {
headers: { 'content-type': 'application/json' },
data: '{',
};
for (const path of ['/query', '/result', '/redeem']) {
const response = await request.post(`${WORKER_URL}/redeem_api${path}`, options);
expect(response.status()).toBe(400);
}
});
test('uses the code type and does not consume when its required target is missing', async ({ request }) => {
const code = await createCode(request, 'send_balance', '9');
try {
const mismatchResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, user_email: 'nobody@test.example.com' },
});
expect(mismatchResponse.status()).toBe(400);
expect((await queryCode(request, code)).ok()).toBe(true);
expect((await listCode(request, 'send_balance', code)).redeemed_at).toBeNull();
} finally {
await deleteCode(request, 'send_balance', code);
}
});
});
test.describe('Role redemption', () => {
test('concurrent different roles consume only the winning code', async ({ request }) => {
const user = await createUser(request);
const roles = ['case-role', 'empty-role'];
const codes = await Promise.all(roles.map((role) => createCode(request, 'role', role)));
try {
const responses = await Promise.all(codes.map((code) => (
request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, user_email: user.email },
})
)));
expect(responses.filter((response) => response.ok())).toHaveLength(1);
expect(responses.filter((response) => response.status() === 409)).toHaveLength(1);
const winner = responses.findIndex((response) => response.ok());
const loser = 1 - winner;
const rows = await Promise.all(codes.map((code) => listCode(request, 'role', code)));
expect(rows[winner].redeemed).toBe(true);
expect(rows[loser]).toMatchObject({ redeemed: false, result: null, redeemed_at: null });
const users = await request.get(
`${WORKER_URL}/admin/users?limit=10&offset=0&query=${encodeURIComponent(user.email)}`,
{ headers: ADMIN_HEADERS },
);
expect((await users.json()).results[0].role_text).toBe(roles[winner]);
} finally {
for (const code of codes) await deleteCode(request, 'role', code);
await deleteUser(request, user.id);
}
});
test('applies a configured role by case-insensitive account email and consumes once', async ({ request }) => {
const user = await createUser(request);
const code = await createCode(request, 'role', 'case-role');
try {
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, user_email: user.email.toUpperCase() },
});
expect(response.ok()).toBe(true);
expect(await response.json()).toEqual({
success: true,
type: 'role',
role: 'case-role',
user_email: user.email,
});
const userResponse = await request.get(
`${WORKER_URL}/admin/users?limit=10&offset=0&query=${encodeURIComponent(user.email)}`,
{ headers: ADMIN_HEADERS },
);
expect((await userResponse.json()).results[0].role_text).toBe('case-role');
const row = await listCode(request, 'role', code);
expect(row.redeemed).toBe(true);
expect(row.redeemed_at).toEqual(expect.any(String));
expect(JSON.parse(row.result)).toMatchObject({
type: 'role',
user_id: user.id,
user_email: user.email,
role: 'case-role',
});
const publicResult = await queryResult(request, code);
expect(publicResult.ok()).toBe(true);
expect(await publicResult.json()).toEqual({
type: 'role', role: 'case-role', user_email: user.email,
});
const repeatedResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, user_email: user.email },
});
expect(repeatedResponse.status()).toBe(400);
expect(await (await queryCode(request, code)).json()).toEqual({
redeem_type: 'role', value: 'case-role', status: 'redeemed',
});
} finally {
await deleteCode(request, 'role', code);
await deleteUser(request, user.id);
}
});
test('accepts the same existing role', async ({ request }) => {
const user = await createUser(request);
const code = await createCode(request, 'role', 'case-role');
try {
await setUserRole(request, user.id, 'case-role');
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, user_email: user.email },
});
expect(response.ok()).toBe(true);
} finally {
await deleteCode(request, 'role', code);
await deleteUser(request, user.id);
}
});
test('keeps the code unused when the account is missing or has a conflicting role', async ({ request }) => {
const missingCode = await createCode(request, 'role', 'case-role');
const conflictCode = await createCode(request, 'role', 'case-role');
const user = await createUser(request);
try {
const missingResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code: missingCode, user_email: `${uniqueValue('missing')}@test.example.com` },
});
expect(missingResponse.status()).toBe(400);
expect((await queryCode(request, missingCode)).ok()).toBe(true);
await setUserRole(request, user.id, 'empty-role');
const conflictResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code: conflictCode, user_email: user.email },
});
expect(conflictResponse.status()).toBe(409);
expect((await queryCode(request, conflictCode)).ok()).toBe(true);
expect((await listCode(request, 'role', conflictCode)).result).toBeNull();
} finally {
await deleteCode(request, 'role', missingCode);
await deleteCode(request, 'role', conflictCode);
await deleteUser(request, user.id);
}
});
});
test.describe('Sending-credit redemption', () => {
test('preserves default credits before the mailbox is first opened', async ({ request }) => {
const address = await createTestAddress(request, 'rcfresh');
const code = await createCode(request, 'send_balance', '7');
try {
const before = await request.get(
`${WORKER_URL}/admin/address_sender?limit=1&offset=0&address=${encodeURIComponent(address.address)}`,
{ headers: ADMIN_HEADERS },
);
expect((await before.json()).results).toHaveLength(0);
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, address: address.address },
});
expect(response.ok()).toBe(true);
expect(await response.json()).toMatchObject({ amount: 7, balance: 17 });
for (let index = 0; index < 2; index++) {
const settings = await request.get(`${WORKER_URL}/api/settings`, {
headers: { Authorization: `Bearer ${address.jwt}` },
});
expect(settings.ok()).toBe(true);
}
expect((await getAddressSender(request, address.address)).balance).toBe(17);
} finally {
await deleteCode(request, 'send_balance', code);
await deleteAddress(request, address.jwt);
}
});
test('coalesces NULL balance, preserves disabled state, records result, and consumes once', async ({ request }) => {
const address = await createTestAddress(request, 'rcnull');
const code = await createCode(request, 'send_balance', '7');
try {
const settingsResponse = await request.get(`${WORKER_URL}/api/settings`, {
headers: { Authorization: `Bearer ${address.jwt}` },
});
expect(settingsResponse.ok()).toBe(true);
const sender = await getAddressSender(request, address.address);
const nullResponse = await request.post(`${WORKER_URL}/admin/address_sender`, {
headers: ADMIN_HEADERS,
data: {
address: address.address,
address_id: sender.id,
balance: null,
enabled: false,
},
});
expect(nullResponse.ok()).toBe(true);
const redeemResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, address: address.address.toUpperCase() },
});
expect(redeemResponse.ok()).toBe(true);
expect(await redeemResponse.json()).toEqual({
success: true,
type: 'send_balance',
address: address.address,
amount: 7,
balance: 7,
});
const updatedSender = await getAddressSender(request, address.address);
expect(updatedSender.balance).toBe(7);
expect(updatedSender.enabled).toBe(0);
expect(JSON.parse((await listCode(request, 'send_balance', code)).result)).toMatchObject({
type: 'send_balance',
address: address.address,
amount: 7,
});
const publicResult = await queryResult(request, code);
expect(publicResult.ok()).toBe(true);
expect(await publicResult.json()).toEqual({
type: 'send_balance', address: address.address, amount: 7,
});
const repeatedResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, address: address.address },
});
expect(repeatedResponse.status()).toBe(400);
expect(await (await queryCode(request, code)).json()).toEqual({
redeem_type: 'send_balance', value: '7', status: 'redeemed',
});
} finally {
await deleteCode(request, 'send_balance', code);
await deleteAddress(request, address.jwt);
}
});
test('does not consume when the target address is missing', async ({ request }) => {
const code = await createCode(request, 'send_balance', '5');
try {
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, address: `${uniqueValue('missing')}@test.example.com` },
});
expect(response.status()).toBe(400);
expect((await queryCode(request, code)).ok()).toBe(true);
expect((await listCode(request, 'send_balance', code)).result).toBeNull();
} finally {
await deleteCode(request, 'send_balance', code);
}
});
test('concurrent requests add the balance exactly once', async ({ request }) => {
const address = await createTestAddress(request, 'rcrace');
const code = await createCode(request, 'send_balance', '5');
try {
await request.get(`${WORKER_URL}/api/settings`, {
headers: { Authorization: `Bearer ${address.jwt}` },
});
const before = await getAddressSender(request, address.address);
const responses = await Promise.all([
request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, address: address.address },
}),
request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, address: address.address },
}),
]);
expect(responses.filter((response) => response.ok())).toHaveLength(1);
expect(responses.filter((response) => [400, 409].includes(response.status()))).toHaveLength(1);
expect((await getAddressSender(request, address.address)).balance).toBe(before.balance + 5);
} finally {
await deleteCode(request, 'send_balance', code);
await deleteAddress(request, address.jwt);
}
});
});
test.describe('Special-address redemption', () => {
test('uses the redemption prefix without the system prefix and repeats the complete original result', async ({ request }) => {
const code = await createCode(request, 'address_prefix_once', 'vip');
const name = `a${Math.random().toString(36).slice(2, 8)}`;
let result: AddressRedeemResult | undefined;
try {
const firstResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, name, domain: 'TEST.EXAMPLE.COM' },
});
expect(firstResponse.ok()).toBe(true);
result = await firstResponse.json();
expect(result).toMatchObject({
type: 'address_prefix_once',
address: `vip${name}@test.example.com`,
address_id: expect.any(Number),
jwt: expect.any(String),
password: expect.any(String),
});
expect(result.address).not.toContain('tmpvip');
const repeatedResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, name: 'ignored', domain: 'manual.example.com' },
});
expect(repeatedResponse.ok()).toBe(true);
expect(await repeatedResponse.json()).toEqual(result);
const previewResponse = await queryCode(request, code);
expect(previewResponse.ok()).toBe(true);
const preview = await previewResponse.json();
expect(preview).toEqual({
redeem_type: 'address_prefix_once', value: 'vip', status: 'redeemed',
});
const publicResult = await queryResult(request, code);
expect(publicResult.ok()).toBe(true);
expect(await publicResult.json()).toEqual({
type: 'address_prefix_once',
address: result.address,
jwt: result.jwt,
password: result.password,
});
const row = await listCode(request, 'address_prefix_once', code);
expect(JSON.parse(row.result)).toEqual(result);
} finally {
if (result?.jwt) await deleteAddress(request, result.jwt);
await deleteCode(request, 'address_prefix_once', code);
}
});
test('supports an empty prefix and the longest usable prefix', async ({ request }) => {
const emptyCode = await createCode(request, 'address_prefix_once', '');
const maxCode = await createCode(request, 'address_prefix_once', 'a'.repeat(29));
const emptyName = `n${Math.random().toString(36).slice(2, 8)}`;
let emptyResult: AddressRedeemResult | undefined;
let maxResult: AddressRedeemResult | undefined;
try {
const emptyResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code: emptyCode, name: emptyName, domain: 'test.example.com' },
});
expect(emptyResponse.ok()).toBe(true);
emptyResult = await emptyResponse.json();
expect(emptyResult.address).toBe(`${emptyName}@test.example.com`);
const maxResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code: maxCode, name: 'z', domain: 'test.example.com' },
});
expect(maxResponse.ok()).toBe(true);
maxResult = await maxResponse.json();
expect(maxResult.address.split('@')[0]).toBe(`${'a'.repeat(29)}z`);
} finally {
if (emptyResult?.jwt) await deleteAddress(request, emptyResult.jwt);
if (maxResult?.jwt) await deleteAddress(request, maxResult.jwt);
await deleteCode(request, 'address_prefix_once', emptyCode);
await deleteCode(request, 'address_prefix_once', maxCode);
}
});
test('keeps length and domain validation without consuming failed codes', async ({ request }) => {
const lengthCode = await createCode(request, 'address_prefix_once', 'a'.repeat(29));
const domainCode = await createCode(request, 'address_prefix_once', 'vip');
try {
const lengthResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code: lengthCode, name: 'zz', domain: 'test.example.com' },
});
expect(lengthResponse.status()).toBe(400);
const domainResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code: domainCode, name: 'valid', domain: 'invalid.example.com' },
});
expect(domainResponse.status()).toBe(400);
expect((await queryCode(request, lengthCode)).ok()).toBe(true);
expect((await queryCode(request, domainCode)).ok()).toBe(true);
} finally {
await deleteCode(request, 'address_prefix_once', lengthCode);
await deleteCode(request, 'address_prefix_once', domainCode);
}
});
test('generates a valid random suffix when the user omits the name', async ({ request }) => {
const code = await createCode(request, 'address_prefix_once', 'rnd');
let result: AddressRedeemResult | undefined;
try {
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, domain: 'test.example.com' },
});
expect(response.ok()).toBe(true);
result = await response.json();
const localPart = result.address.split('@')[0];
expect(localPart.startsWith('rnd')).toBe(true);
expect(localPart.length).toBeLessThanOrEqual(30);
expect(localPart.length).toBeGreaterThan(3);
} finally {
if (result?.jwt) await deleteAddress(request, result.jwt);
await deleteCode(request, 'address_prefix_once', code);
}
});
test('does not return an address result after its original address is removed', async ({ request }) => {
const code = await createCode(request, 'address_prefix_once', 'removed');
try {
const firstResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, name: 'mail', domain: 'test.example.com' },
});
const first = await firstResponse.json();
await deleteAddress(request, first.jwt);
const repeatResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, name: 'ignored', domain: 'test.example.com' },
});
expect(repeatResponse.status()).toBe(400);
const resultResponse = await request.post(`${WORKER_URL}/redeem_api/result`, {
data: { code },
});
expect(resultResponse.status()).toBe(400);
} finally {
await deleteCode(request, 'address_prefix_once', code);
}
});
test('concurrent creations converge on one complete result', async ({ request }) => {
const code = await createCode(request, 'address_prefix_once', 'race');
let result: AddressRedeemResult | undefined;
try {
const responses = await Promise.all([
request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, name: `a${Math.random().toString(36).slice(2, 7)}`, domain: 'test.example.com' },
}),
request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, name: `b${Math.random().toString(36).slice(2, 7)}`, domain: 'test.example.com' },
}),
]);
expect(responses.every((response) => response.ok())).toBe(true);
const results = await Promise.all(responses.map((response) => response.json()));
expect(results[1]).toEqual(results[0]);
result = results[0];
expect(result.password).toEqual(expect.any(String));
expect(JSON.parse((await listCode(request, 'address_prefix_once', code)).result)).toEqual(result);
} finally {
if (result?.jwt) await deleteAddress(request, result.jwt);
await deleteCode(request, 'address_prefix_once', code);
}
});
test('expiry stops retrieval of an existing result', async ({ request }) => {
const expiredCode = await createCode(request, 'address_prefix_once', 'exp', {
expiresAt: new Date(Date.now() + 5_000).toISOString(),
});
let expiredResult: AddressRedeemResult | undefined;
try {
expiredResult = await (await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code: expiredCode, name: 'mail', domain: 'test.example.com' },
})).json();
await new Promise((resolve) => setTimeout(resolve, 5_500));
expect((await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code: expiredCode },
})).status()).toBe(400);
expect(await (await queryCode(request, expiredCode)).json()).toMatchObject({ status: 'expired' });
expect((await queryResult(request, expiredCode)).status()).toBe(400);
} finally {
if (expiredResult?.jwt) await deleteAddress(request, expiredResult.jwt);
await deleteCode(request, 'address_prefix_once', expiredCode);
}
});
});
-29
View File
@@ -51,33 +51,4 @@ test.describe('Send Mail via SMTP', () => {
// Cleanup
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);
}
});
});
+12 -44
View File
@@ -1,15 +1,13 @@
import { test, expect } from '@playwright/test';
import { TEST_DOMAIN, WORKER_URL, WORKER_URL_ENV_OFF, WORKER_URL_SUBDOMAIN } from '../../fixtures/test-helpers';
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 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 CREATE_ADDRESS_WORKER_URL = WORKER_URL_SUBDOMAIN || WORKER_URL;
let originalCreateAddressStoredEnabled: boolean | undefined;
let originalEnvOffStoredEnabled: boolean | undefined;
@@ -121,16 +119,16 @@ test.describe('Create Address Subdomain Match', () => {
);
});
test('random subdomain scope allows a manually entered subdomain', async ({ request }) => {
test('persisted false still keeps exact match only', 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: RANDOM_SUBDOMAIN },
data: { name: uniqueName, domain: SUBDOMAIN },
});
expect(res.ok()).toBe(true);
expect((await res.json()).address).toContain(`@${RANDOM_SUBDOMAIN}`);
expect(res.ok()).toBe(false);
expect(await res.text()).toContain('Invalid domain');
});
test('admin switch enables suffix subdomain match for both admin and user create APIs', async ({ request }) => {
@@ -186,43 +184,13 @@ 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: RANDOM_SUBDOMAIN },
data: { name: `subdomain-env-off-${Date.now()}`, domain: SUBDOMAIN },
});
expect(res.ok()).toBe(false);
expect(await res.text()).toContain('Invalid domain');
+1 -1
View File
@@ -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}/__test/receive_mail`, {
const receiveRes = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
data: {
from: 'sender@test.example.com',
to: address,
-147
View File
@@ -1,147 +0,0 @@
import { expect, test, type APIRequestContext } from '@playwright/test';
import { createHmac } from 'node:crypto';
import { WORKER_URL, createTestAddress } from '../../fixtures/test-helpers';
function initData(userId: number) {
const fields = {
auth_date: String(Math.floor(Date.now() / 1000)),
user: JSON.stringify({ id: userId }),
};
const key = createHmac('sha256', 'WebAppData').update('e2e-telegram-test-token').digest();
const hash = createHmac('sha256', key)
.update(Object.entries(fields).map(([name, value]) => `${name}=${value}`).join('\n'))
.digest('hex');
return new URLSearchParams({ ...fields, hash }).toString();
}
async function bind(request: APIRequestContext, userId: number, jwt: string) {
const response = await request.post(`${WORKER_URL}/telegram/bind_address`, {
data: { initData: initData(userId), jwt },
});
expect(response.ok(), await response.text()).toBe(true);
}
async function unbind(request: APIRequestContext, userId: number, address: string, status = 200) {
const response = await request.post(`${WORKER_URL}/telegram/unbind_address`, {
data: { initData: initData(userId), address },
});
expect(response.status(), await response.text()).toBe(status);
}
async function addressList(request: APIRequestContext, userId: number) {
const response = await request.post(`${WORKER_URL}/telegram/get_bind_address`, {
data: { initData: initData(userId) },
});
expect(response.ok(), await response.text()).toBe(true);
return response.json();
}
async function expectPushOwner(request: APIRequestContext, address: string, userId: number | null) {
const response = await request.get(`${WORKER_URL}/__test/telegram_binding`, {
params: { address },
});
expect(response.ok(), await response.text()).toBe(true);
expect(await response.json()).toBe(userId === null ? null : String(userId));
}
test('Telegram users can remove their own bindings after another user binds the mailbox', async ({ request }) => {
const mailbox = await createTestAddress(request, 'tg-owner');
const owner = Date.now();
const other = owner + 1;
try {
await bind(request, owner, mailbox.jwt);
await unbind(request, other, mailbox.address, 400);
await expectPushOwner(request, mailbox.address, owner);
await unbind(request, owner, mailbox.address);
await expectPushOwner(request, mailbox.address, null);
await bind(request, owner, mailbox.jwt);
await bind(request, other, mailbox.jwt);
await expectPushOwner(request, mailbox.address, other);
await unbind(request, owner, mailbox.address);
expect(await addressList(request, owner)).toEqual([]);
expect(await addressList(request, other)).toEqual([{ address: mailbox.address, jwt: mailbox.jwt }]);
await expectPushOwner(request, mailbox.address, other);
await unbind(request, other, mailbox.address);
expect(await addressList(request, other)).toEqual([]);
await expectPushOwner(request, mailbox.address, null);
await bind(request, owner, mailbox.jwt);
await bind(request, other, mailbox.jwt);
await bind(request, owner, mailbox.jwt);
expect(await addressList(request, owner)).toEqual([{ address: mailbox.address, jwt: mailbox.jwt }]);
await expectPushOwner(request, mailbox.address, owner);
await unbind(request, other, mailbox.address);
await expectPushOwner(request, mailbox.address, owner);
await unbind(request, owner, mailbox.address);
expect(await addressList(request, owner)).toEqual([]);
await expectPushOwner(request, mailbox.address, null);
} finally {
await request.delete(`${WORKER_URL}/api/delete_address`, {
headers: { Authorization: `Bearer ${mailbox.jwt}` },
});
}
});
test('stale Telegram credentials cannot unbind; internal mailbox cleanup still works', async ({ request }) => {
const original = await createTestAddress(request, 'tg-stale');
const owner = Date.now();
await bind(request, owner, original.jwt);
const deletion = await request.delete(`${WORKER_URL}/admin/delete_address/${original.address_id}`);
expect(deletion.ok()).toBe(true);
const [name, domain] = original.address.split('@');
const creation = await request.post(`${WORKER_URL}/admin/new_address`, {
data: { name, domain, enablePrefix: false },
});
expect(creation.ok()).toBe(true);
const recreated = await creation.json();
try {
expect(recreated.address_id).not.toBe(original.address_id);
await unbind(request, owner, recreated.address, 400);
await expectPushOwner(request, recreated.address, owner);
const response = await request.delete(`${WORKER_URL}/api/delete_address`, {
headers: { Authorization: `Bearer ${recreated.jwt}` },
});
expect(response.ok(), await response.text()).toBe(true);
await expectPushOwner(request, recreated.address, null);
const listing = await request.post(`${WORKER_URL}/telegram/get_bind_address`, {
data: { initData: initData(owner) },
});
expect(listing.ok()).toBe(true);
expect(await listing.json()).toEqual([]);
} finally {
await request.delete(`${WORKER_URL}/admin/delete_address/${recreated.address_id}`);
}
});
test('Telegram unbind accepts a current credential after a stale credential for the same address', async ({ request }) => {
const original = await createTestAddress(request, 'tg-recreated');
const unrelated = await createTestAddress(request, 'tg-retained');
const owner = Date.now();
await bind(request, owner, original.jwt);
await bind(request, owner, unrelated.jwt);
const deletion = await request.delete(`${WORKER_URL}/admin/delete_address/${original.address_id}`);
expect(deletion.ok()).toBe(true);
const [name, domain] = original.address.split('@');
const creation = await request.post(`${WORKER_URL}/admin/new_address`, {
data: { name, domain, enablePrefix: false },
});
expect(creation.ok()).toBe(true);
const recreated = await creation.json();
try {
expect(recreated.address_id).not.toBe(original.address_id);
await bind(request, owner, recreated.jwt);
await unbind(request, owner, recreated.address);
await expectPushOwner(request, recreated.address, null);
expect(await addressList(request, owner)).toEqual([{ address: unrelated.address, jwt: unrelated.jwt }]);
await expectPushOwner(request, unrelated.address, owner);
} finally {
for (const mailbox of [recreated, unrelated]) {
await request.delete(`${WORKER_URL}/api/delete_address`, {
headers: { Authorization: `Bearer ${mailbox.jwt}` },
});
}
}
});
-65
View File
@@ -1,65 +0,0 @@
import { test, expect } from '@playwright/test';
import { createHmac } from 'node:crypto';
import { WORKER_URL, WORKER_URL_SITE_PASSWORD } from '../../fixtures/test-helpers';
for (const scenario of ['expired role', 'valid role', 'invalid signature', 'missing expiry', 'admin password', 'wrong password'] as const) {
test(`Admin role token errors remain distinct from password errors: ${scenario}`, async ({ request }) => {
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({
user_id: 1, user_role: 'admin',
exp: scenario === 'missing expiry' ? undefined
: Math.floor(Date.now() / 1000) + (scenario === 'valid role' || scenario === 'invalid signature' ? 3600 : -60),
})).toString('base64url');
const signature = createHmac('sha256', scenario === 'invalid signature' ? 'wrong-secret' : 'e2e-site-password-secret')
.update(`${header}.${payload}`).digest('base64url');
const headers: Record<string, string> = { 'x-custom-auth': 'e2e-site-pass', 'x-lang': 'en' };
if (scenario !== 'wrong password') headers['x-user-access-token'] = `${header}.${payload}.${signature}`;
if (scenario === 'admin password') headers['x-admin-auth'] = 'e2e-admin-pass';
if (scenario === 'wrong password') headers['x-admin-auth'] = 'wrong-password';
const response = await request.get(`${WORKER_URL_SITE_PASSWORD}/admin/db_version`, { headers });
if (scenario === 'expired role') {
expect(response.status()).toBe(401);
expect(await response.json()).toEqual({
code: 'AUTH_USER_ACCESS_TOKEN_EXPIRED',
message: 'Your access token has expired, please refresh the page',
});
} else if (scenario === 'missing expiry') {
expect(response.status()).toBe(401);
expect(await response.json()).toEqual({
code: 'AUTH_ADMIN_CREDENTIAL_INVALID',
message: 'Your access token has expired, please refresh the page',
});
} else if (scenario === 'valid role' || scenario === 'admin password') {
expect(response.ok()).toBe(true);
} else {
expect(response.status()).toBe(401);
expect(await response.json()).toEqual({
code: 'AUTH_ADMIN_CREDENTIAL_INVALID',
message: 'You need to provide the admin password to access this page',
});
}
});
}
test('generic authentication errors preserve their text messages and CORS headers', async ({ request }) => {
for (const { path, message } of [
{ path: '/api/settings', message: 'Invalid address credential' },
{ path: '/user_api/settings', message: 'Your token has expired, please login again' },
]) {
const response = await request.get(`${WORKER_URL}${path}`, { headers: { 'x-lang': 'en' } });
expect(response.status()).toBe(401);
expect(response.headers()['content-type']).toContain('text/plain');
expect(response.headers()['access-control-allow-origin']).toBe('*');
expect(await response.text()).toBe(message);
}
});
test('uncaught server errors return JSON with the original error detail', async ({ request }) => {
const response = await request.post(`${WORKER_URL}/open_api/admin_login`, {
headers: { 'content-type': 'application/json' },
data: Buffer.from('{invalid-json'),
});
expect(response.status()).toBe(500);
expect(response.headers()['content-type']).toContain('application/json');
expect(await response.json()).toEqual({ code: 'INTERNAL_SERVER_ERROR', message: expect.stringContaining('SyntaxError') });
});
-415
View File
@@ -1,415 +0,0 @@
import { expect, test, type APIRequestContext } from '@playwright/test';
import {
WORKER_URL,
createTestAddress,
deleteAddress,
deleteAllMailpitMessages,
getAddressSender,
hashPassword,
onMailpitMessage,
updateAddressSender,
} from '../../fixtures/test-helpers';
async function createUser(request: APIRequestContext) {
const email = `user-send-${Date.now()}@test.example.com`;
const password = hashPassword('test-password-123');
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
data: { email, password },
});
expect(registerRes.ok()).toBe(true);
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
data: { email, password },
});
expect(loginRes.ok()).toBe(true);
const { jwt } = await loginRes.json();
const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
return { jwt, userId: payload.user_id as number };
}
async function bindAddress(
request: APIRequestContext,
userJwt: string,
addressJwt: string,
) {
const response = await request.post(`${WORKER_URL}/user_api/bind_address`, {
headers: {
Authorization: `Bearer ${addressJwt}`,
'x-user-token': userJwt,
},
});
expect(response.ok()).toBe(true);
}
test.describe('User send mail API', () => {
test('sends and manages sent items for a bound address only', async ({ request }) => {
const addresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
let userId: number | undefined;
let originalUserSettings: Record<string, unknown> | undefined;
try {
const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`);
expect(settingsRes.ok()).toBe(true);
originalUserSettings = await settingsRes.json();
const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
data: {
...originalUserSettings,
enable: true,
enableMailVerify: false,
maxAddressCount: 0,
},
});
expect(enableUserRes.ok()).toBe(true);
const user = await createUser(request);
userId = user.userId;
const bound = await createTestAddress(request, 'user-send-bound-');
const accessRequest = await createTestAddress(request, 'user-send-access-');
const outsider = await createTestAddress(request, 'usr-outsider-');
addresses.push(bound, accessRequest, outsider);
await bindAddress(request, user.jwt, bound.jwt);
await bindAddress(request, user.jwt, accessRequest.jwt);
const invalidAddressSettingsRes = await request.get(
`${WORKER_URL}/user_api/address/0/settings`,
{ headers: { 'x-user-token': user.jwt } },
);
expect(invalidAddressSettingsRes.status()).toBe(400);
const outsiderSettingsRes = await request.get(
`${WORKER_URL}/user_api/address/${outsider.address_id}/settings`,
{ headers: { 'x-user-token': user.jwt } },
);
expect(outsiderSettingsRes.status()).toBe(400);
const outsiderCredentialRes = await request.get(
`${WORKER_URL}/user_api/bind_address_jwt/${outsider.address_id}`,
{ headers: { 'x-user-token': user.jwt } },
);
expect(outsiderCredentialRes.status()).toBe(400);
const outsiderAccessRes = await request.post(
`${WORKER_URL}/user_api/address/${outsider.address_id}/request_send_mail_access`,
{ headers: { 'x-user-token': user.jwt } },
);
expect(outsiderAccessRes.status()).toBe(400);
const outsiderUserSendRes = await request.post(
`${WORKER_URL}/user_api/address/${outsider.address_id}/send_mail`,
{
headers: { 'x-user-token': user.jwt },
data: {
to_mail: 'recipient@test.example.com',
subject: 'Forbidden user send',
content: 'This message must not be sent',
is_html: false,
},
},
);
expect(outsiderUserSendRes.status()).toBe(400);
const unauthenticatedSendboxRes = await request.get(
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0`,
);
expect(unauthenticatedSendboxRes.status()).toBe(401);
const requestAccessRes = await request.post(
`${WORKER_URL}/user_api/address/${accessRequest.address_id}/request_send_mail_access`,
{ headers: { 'x-user-token': user.jwt } },
);
expect(requestAccessRes.ok()).toBe(true);
const accessSender = await getAddressSender(request, accessRequest.address);
await updateAddressSender(request, {
address: accessRequest.address,
address_id: accessSender.id,
balance: 0,
enabled: true,
});
const duplicateAccessRes = await request.post(
`${WORKER_URL}/user_api/address/${accessRequest.address_id}/request_send_mail_access`,
{ headers: { 'x-user-token': user.jwt } },
);
expect(duplicateAccessRes.status()).toBe(400);
expect(await duplicateAccessRes.text()).toContain('Already');
const addressSettingsRes = await request.get(
`${WORKER_URL}/user_api/address/${bound.address_id}/settings`,
{ headers: { 'x-user-token': user.jwt } },
);
expect(addressSettingsRes.ok()).toBe(true);
const addressSettings = await addressSettingsRes.json();
expect(addressSettings.address).toBe(bound.address);
expect(addressSettings.send_balance).toBe(10);
await deleteAllMailpitMessages(request);
const subject = `User API send ${Date.now()}`;
const listener = onMailpitMessage((mail) => mail.Subject === subject);
await listener.ready;
const sendRes = await request.post(
`${WORKER_URL}/user_api/address/${bound.address_id}/send_mail`,
{
headers: { 'x-user-token': user.jwt },
data: {
from_name: 'User Sender',
from_mail: outsider.address,
to_name: 'Recipient',
to_mail: 'recipient@test.example.com',
subject,
content: 'Sent through the user API',
is_html: false,
},
},
);
expect(sendRes.ok()).toBe(true);
const delivered = await listener.message;
expect(delivered.From.Address).toBe(bound.address);
const outsiderSubject = `Outsider send ${Date.now()}`;
const outsiderSendRes = await request.post(`${WORKER_URL}/api/send_mail`, {
headers: { Authorization: `Bearer ${outsider.jwt}` },
data: {
to_mail: 'recipient@test.example.com',
subject: outsiderSubject,
content: 'This sent item must remain inaccessible to the user',
is_html: false,
},
});
expect(outsiderSendRes.ok()).toBe(true);
const outsiderAddressSendboxRes = await request.get(
`${WORKER_URL}/api/sendbox?limit=20&offset=0`,
{ headers: { Authorization: `Bearer ${outsider.jwt}` } },
);
const outsiderAddressSendbox = await outsiderAddressSendboxRes.json();
const outsiderMail = outsiderAddressSendbox.results.find((item: { raw: string }) => (
JSON.parse(item.raw).subject === outsiderSubject
));
expect(outsiderMail).toBeTruthy();
const unauthorizedDeleteRes = await request.delete(
`${WORKER_URL}/user_api/sendbox/${outsiderMail.id}`,
{ headers: { 'x-user-token': user.jwt } },
);
expect(unauthorizedDeleteRes.ok()).toBe(true);
const outsiderSendboxAfterDeleteRes = await request.get(
`${WORKER_URL}/api/sendbox?limit=20&offset=0`,
{ headers: { Authorization: `Bearer ${outsider.jwt}` } },
);
expect((await outsiderSendboxAfterDeleteRes.json()).count).toBe(1);
const updatedSettingsRes = await request.get(
`${WORKER_URL}/user_api/address/${bound.address_id}/settings`,
{ headers: { 'x-user-token': user.jwt } },
);
expect((await updatedSettingsRes.json()).send_balance).toBe(9);
const sender = await getAddressSender(request, bound.address);
await updateAddressSender(request, {
address: bound.address,
address_id: sender.id,
balance: 0,
enabled: true,
});
const noBalanceRes = await request.post(
`${WORKER_URL}/user_api/address/${bound.address_id}/send_mail`,
{
headers: { 'x-user-token': user.jwt },
data: {
to_mail: 'recipient@test.example.com',
subject: 'No balance user send',
content: 'This message must not be sent',
is_html: false,
},
},
);
expect(noBalanceRes.status()).toBe(400);
expect(await noBalanceRes.text()).toContain('No balance');
const userSendboxRes = await request.get(
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0`,
{ headers: { 'x-user-token': user.jwt } },
);
expect(userSendboxRes.ok()).toBe(true);
const userSendbox = await userSendboxRes.json();
expect(userSendbox.count).toBe(1);
expect(userSendbox.results).toHaveLength(1);
expect(JSON.parse(userSendbox.results[0].raw).subject).toBe(subject);
const filteredSendboxRes = await request.get(
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0&address=${encodeURIComponent(bound.address)}`,
{ headers: { 'x-user-token': user.jwt } },
);
expect(filteredSendboxRes.ok()).toBe(true);
expect((await filteredSendboxRes.json()).count).toBe(1);
const outsiderFilterRes = await request.get(
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0&address=${encodeURIComponent(outsider.address)}`,
{ headers: { 'x-user-token': user.jwt } },
);
expect(outsiderFilterRes.ok()).toBe(true);
expect((await outsiderFilterRes.json()).count).toBe(0);
const deleteRes = await request.delete(
`${WORKER_URL}/user_api/sendbox/${userSendbox.results[0].id}`,
{ headers: { 'x-user-token': user.jwt } },
);
expect(deleteRes.ok()).toBe(true);
const emptySendboxRes = await request.get(
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0`,
{ headers: { 'x-user-token': user.jwt } },
);
const emptySendbox = await emptySendboxRes.json();
expect(emptySendbox.count).toBe(0);
expect(emptySendbox.results).toHaveLength(0);
} finally {
try {
await Promise.allSettled(addresses.map((address) => deleteAddress(request, address.jwt)));
if (userId !== undefined) {
await request.delete(`${WORKER_URL}/admin/users/${userId}`);
}
} finally {
if (originalUserSettings) {
await request.post(`${WORKER_URL}/admin/user_settings`, {
data: originalUserSettings,
});
}
}
}
});
test('applies unlimited balance from the user role access token', async ({ request }) => {
const addresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
const userIds: number[] = [];
let originalUserSettings: Record<string, unknown> | undefined;
try {
const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`);
expect(settingsRes.ok()).toBe(true);
originalUserSettings = await settingsRes.json();
const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
data: {
...originalUserSettings,
enable: true,
enableMailVerify: false,
maxAddressCount: 0,
},
});
expect(enableUserRes.ok()).toBe(true);
const user = await createUser(request);
userIds.push(user.userId);
const address = await createTestAddress(request, 'user-send-role-');
addresses.push(address);
await bindAddress(request, user.jwt, address.jwt);
const updateRoleRes = await request.post(`${WORKER_URL}/admin/user_roles`, {
data: { user_id: user.userId, role_text: 'case-role' },
});
expect(updateRoleRes.ok()).toBe(true);
const accessRes = await request.post(
`${WORKER_URL}/user_api/address/${address.address_id}/request_send_mail_access`,
{ headers: { 'x-user-token': user.jwt } },
);
expect(accessRes.ok()).toBe(true);
const sender = await getAddressSender(request, address.address);
await updateAddressSender(request, {
address: address.address,
address_id: sender.id,
balance: 0,
enabled: true,
});
const userSettingsRes = await request.get(`${WORKER_URL}/user_api/settings`, {
headers: { 'x-user-token': user.jwt },
});
expect(userSettingsRes.ok()).toBe(true);
const { access_token: accessToken } = await userSettingsRes.json();
expect(accessToken).toBeTruthy();
const userHeaders = {
'x-user-token': user.jwt,
'x-user-access-token': accessToken,
};
const addressSettingsRes = await request.get(
`${WORKER_URL}/user_api/address/${address.address_id}/settings`,
{ headers: userHeaders },
);
expect(addressSettingsRes.ok()).toBe(true);
expect((await addressSettingsRes.json()).send_balance).toBe(99999);
const sendRes = await request.post(
`${WORKER_URL}/user_api/address/${address.address_id}/send_mail`,
{
headers: userHeaders,
data: {
to_mail: 'recipient@test.example.com',
subject: `Unlimited role send ${Date.now()}`,
content: 'Sent without consuming address balance',
is_html: false,
},
},
);
expect(sendRes.ok()).toBe(true);
expect((await getAddressSender(request, address.address)).balance).toBe(0);
const otherUser = await createUser(request);
userIds.push(otherUser.userId);
const otherAddress = await createTestAddress(request, 'user-send-other-');
addresses.push(otherAddress);
await bindAddress(request, otherUser.jwt, otherAddress.jwt);
const otherAccessRes = await request.post(
`${WORKER_URL}/user_api/address/${otherAddress.address_id}/request_send_mail_access`,
{ headers: { 'x-user-token': otherUser.jwt } },
);
expect(otherAccessRes.ok()).toBe(true);
const otherSender = await getAddressSender(request, otherAddress.address);
await updateAddressSender(request, {
address: otherAddress.address,
address_id: otherSender.id,
balance: 0,
enabled: true,
});
const mixedHeaders = {
'x-user-token': otherUser.jwt,
'x-user-access-token': accessToken,
};
const otherSettingsRes = await request.get(
`${WORKER_URL}/user_api/address/${otherAddress.address_id}/settings`,
{ headers: mixedHeaders },
);
expect(otherSettingsRes.ok()).toBe(true);
expect((await otherSettingsRes.json()).send_balance).toBe(0);
const otherSendRes = await request.post(
`${WORKER_URL}/user_api/address/${otherAddress.address_id}/send_mail`,
{
headers: mixedHeaders,
data: {
to_mail: 'recipient@test.example.com',
subject: `Mismatched role token ${Date.now()}`,
content: 'This message must not be sent',
is_html: false,
},
},
);
expect(otherSendRes.status()).toBe(400);
expect(await otherSendRes.text()).toContain('No balance');
} finally {
await Promise.allSettled(addresses.map((address) => deleteAddress(request, address.jwt)));
await Promise.allSettled(userIds.map((userId) => (
request.delete(`${WORKER_URL}/admin/users/${userId}`)
)));
if (originalUserSettings) {
await request.post(`${WORKER_URL}/admin/user_settings`, {
data: originalUserSettings,
});
}
}
});
});
-247
View File
@@ -1,247 +0,0 @@
import { test, expect } from '@playwright/test';
import http from 'node:http';
import { createHmac, randomUUID } from 'node:crypto';
import { createTestAddress } from '../../fixtures/test-helpers';
const variants = [
{ name: 'on', url: process.env.WORKER_URL!, removeAll: false, removeLarge: true },
{ name: 'gzip', url: process.env.WORKER_GZIP_URL!, removeAll: false, removeLarge: false },
{ name: 'off', url: process.env.WORKER_URL_ENV_OFF!, removeAll: true, removeLarge: false },
];
test('attachment downloads are forbidden when webhook is disabled', async ({ request }) => {
const url = process.env.WORKER_URL_SITE_PASSWORD;
expect(url, 'Worker with webhook disabled must be configured in CI').toBeTruthy();
const expires = Math.floor(Date.now() / 1000) + 600;
const response = await request.get(`${url}/open_api/a/1/0/${expires}/${'a'.repeat(52)}`, {
headers: { 'x-lang': 'en' },
});
expect(response.status()).toBe(403);
expect(await response.text()).toBe('Webhook is not enabled, please contact the administrator');
});
for (const variant of variants) {
test.describe(`Webhook attachments: ${variant.name}`, () => {
test('links, expiry, ownership binding, deletion and removal settings', async ({ request }) => {
test.setTimeout(60_000);
expect(variant.url, 'Worker variant must be configured in CI').toBeTruthy();
const { jwt, address, address_id } = await createTestAddress(request, 'attachments', undefined, variant.url);
const jwtSecret = variant.removeAll ? 'e2e-test-secret-key-env-off' : 'e2e-test-secret-key';
const headers = { Authorization: `Bearer ${jwt}` };
const payloads: any[] = [];
const server = http.createServer((req, res) => {
const chunks: Buffer[] = [];
req.on('data', chunk => chunks.push(chunk));
req.on('end', () => {
try {
payloads.push(JSON.parse(Buffer.concat(chunks).toString()));
res.writeHead(200).end();
} catch {
res.writeHead(400).end();
}
});
});
await new Promise<void>(resolve => server.listen(0, '0.0.0.0', resolve));
const port = (server.address() as import('node:net').AddressInfo).port;
const hostname = process.env.CI ? 'e2e-runner' : 'localhost';
try {
const settings = {
enabled: true, url: `http://${hostname}:${port}`, method: 'POST',
headers: '{"Content-Type":"application/json"}',
body: '{"id":"${id}","ai":${aiExtract},"attachments":${attachments},"text":"${parsedText}","links":"${attachmentLinks}","markdownLinks":"${attachmentMarkdownLinks}"}',
};
expect((await request.post(`${variant.url}/api/webhook/settings`, { headers, data: settings })).ok()).toBe(true);
const receive = async (
files: { name: string, type: string, content: string }[], large = false,
messageId: string | null = `<${randomUUID()}@test>`, waitForWebhook = true,
) => {
const boundary = randomUUID();
const raw = [
'From: sender@test.example.com', `To: ${address}`, 'Subject: Attachment coverage',
...(messageId === null ? [] : [`Message-ID: ${messageId}`]), 'MIME-Version: 1.0',
`Content-Type: multipart/mixed; boundary="${boundary}"`, '',
`--${boundary}`, 'Content-Type: text/plain; charset=utf-8', '',
'Preserved body: "quoted" \\path $& ${from}' + (large ? 'x'.repeat(2 * 1024 * 1024) : ''),
...files.flatMap(file => [
`--${boundary}`, `Content-Type: ${file.type}`,
`Content-Disposition: attachment; filename="${file.name}"`,
'Content-Transfer-Encoding: base64', '', Buffer.from(file.content).toString('base64'),
]), `--${boundary}--`,
].join('\r\n');
const count = payloads.length;
const result = await request.post(`${variant.url}/__test/receive_mail`, {
data: { from: 'sender@test.example.com', to: address, raw },
});
expect(result.ok()).toBe(true);
expect((await result.json()).success).toBe(true);
if (!waitForWebhook) return;
await expect.poll(() => payloads.length).toBe(count + 1);
return payloads[count];
};
const files = [
{ name: 'first.png', type: 'image/png', content: 'first bytes' },
{ name: "报告 (copy)'!.txt", type: 'text/plain', content: 'second bytes' },
{ name: 'third.svg', type: 'image/svg+xml', content: '<svg onload="alert(1)"/>' },
];
const payload = await receive(files);
expect(payload.text).toContain('Preserved body: "quoted" \\path $& ${from}');
expect(payload.attachments).toHaveLength(variant.removeAll ? 0 : files.length);
expect(payload.links).toBe(payload.attachments.map((a: any) => a.url).join('\n'));
const markdownNames = ['first.png', "报告 \\(copy\\)'\\!.txt", 'third.svg'];
expect(payload.markdownLinks).toBe(payload.attachments.map((a: any, index: number) => `[${markdownNames[index]}](${a.url})`).join('\n'));
const testMarkdownMessage = async (mailId: number, expectedFiles: typeof files) => {
for (const endpoint of ['/api/webhook/test', '/admin/mail_webhook/test']) {
const count = payloads.length;
const result = await request.post(`${variant.url}${endpoint}`, {
headers,
data: {
...settings, mail_id: mailId,
body: JSON.stringify({ msgtype: 'markdown', markdown: {
content: '**主题:** ${subject}\n\n${parsedText}\n\n附件:\n${attachmentMarkdownLinks}\n\n下载地址:\n${attachmentLinks}',
} }),
},
});
expect(result.ok()).toBe(true);
await expect.poll(() => payloads.length).toBe(count + 1);
const message = payloads[count];
expect(Object.keys(message)).toEqual(['msgtype', 'markdown']);
expect(Object.keys(message.markdown)).toEqual(['content']);
expect(message.msgtype).toBe('markdown');
const content = message.markdown.content;
expect(typeof content).toBe('string');
expect(content).toContain('**主题:** Attachment coverage\n\n');
expect(content).toContain('Preserved body: "quoted" \\path $& ${from}');
const [markdownLinks, plainLinks] = content.split('\n\n附件:\n')[1].split('\n\n下载地址:\n');
const urls = plainLinks ? plainLinks.split('\n') : [];
expect(urls).toHaveLength(expectedFiles.length);
expect(markdownLinks).toBe(urls.map((url: string, index: number) => `[${markdownNames[index]}](${url})`).join('\n'));
for (const [index, url] of urls.entries()) {
expect(new URL(url).pathname.split('/')[3]).toBe(String(mailId));
const response = await request.get(url);
expect(response.status()).toBe(200);
expect(await response.text()).toBe(expectedFiles[index].content);
}
}
};
await testMarkdownMessage(Number(payload.id), variant.removeAll ? [] : files);
for (const endpoint of ['/api/webhook/test', '/admin/mail_webhook/test']) {
const count = payloads.length;
expect((await request.post(`${variant.url}${endpoint}`, { headers, data: settings })).ok()).toBe(true);
await expect.poll(() => payloads.length).toBe(count + 1);
const preview = payloads[count];
expect(preview.ai).toBeNull();
expect(Array.isArray(preview.attachments)).toBe(true);
if (endpoint.startsWith('/api/')) {
expect(preview.attachments).toHaveLength(variant.removeAll ? 0 : files.length);
}
for (const attachment of preview.attachments) {
expect((await request.get(attachment.url)).status()).toBe(200);
}
}
const detail = await request.get(`${variant.url}/api/mail/${payload.id}`, { headers });
expect(detail.ok()).toBe(true);
const row = await detail.json();
const now = Math.floor(Date.now() / 1000);
const sign = (index: number, expires = now + 600, id = Number(payload.id), recipient = address, created = row.created_at, secret = jwtSecret) => {
const digest = createHmac('sha256', secret).update(JSON.stringify([
'webhook-attachment-v1', id, recipient, created, expires, index,
])).digest();
const bits = [...digest].map(byte => byte.toString(2).padStart(8, '0')).join('');
const signature = bits.match(/.{1,5}/g)!.map(group =>
'abcdefghijklmnopqrstuvwxyz234567'[parseInt(group.padEnd(5, '0'), 2)]
).join('');
return `/open_api/a/${id}/${index}/${expires}/${signature}`;
};
const get = (path: string) => request.get(new URL(path, variant.url).href);
for (const [index, attachment] of payload.attachments.entries()) {
expect(attachment.filename).toBe(files[index].name);
expect(attachment.mimeType).toBe(files[index].type);
expect(new URL(attachment.url).origin).toBe(new URL(variant.url).origin);
const signedUrl = new URL(attachment.url);
const signature = signedUrl.pathname.split('/').at(-1)!;
expect(signature).toMatch(/^[a-z2-7]{51}[aq]$/);
signedUrl.pathname = signedUrl.pathname.replace(signature, signature.toUpperCase());
expect((await get(signedUrl.href)).status()).toBe(200);
signedUrl.pathname = signedUrl.pathname.replace(signature.toUpperCase(),
[...signature].map((character, index) => index % 2 ? character.toUpperCase() : character).join(''));
expect((await get(signedUrl.href)).status()).toBe(200);
const response = await get(attachment.url);
expect(response.status()).toBe(200);
expect(await response.text()).toBe(files[index].content);
expect(response.headers()['cache-control']).toBe('no-store');
expect(response.headers()['x-content-type-options']).toBe('nosniff');
const filename = encodeURIComponent(files[index].name).replace(/[!'()*]/g,
character => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
expect(response.headers()['content-disposition']).toBe(index === 0 ? 'inline' : `attachment; filename*=UTF-8''${filename}`);
expect(response.headers()['content-type']).toBe(index === 0 ? 'image/png' : 'application/octet-stream');
}
expect((await get(sign(0))).status()).toBe(variant.removeAll ? 404 : 200);
for (const path of [
sign(0, now - 1), sign(0, now + 86460), sign(-1), sign(999),
sign(0, now + 600, Number(payload.id), 'other@test.example.com'),
sign(0, now + 600, Number(payload.id), address, '2000-01-01 00:00:00'),
sign(0, now + 600, Number(payload.id), address, row.created_at, 'wrong-secret'),
sign(0, now + 600, Number.MAX_SAFE_INTEGER),
`/open_api/a/${payload.id}/invalid/${now + 600}/${'A'.repeat(52)}`,
`/open_api/a/${payload.id}/0/${now + 600}/${'0'.repeat(52)}`,
`/open_api/a/${payload.id}/0/${now + 600}/${'a'.repeat(51)}b`,
`/open_api/a/${payload.id}/0/${now + 600}/short`,
]) {
expect((await get(path)).status(), path).toBe(404);
}
const empty = await receive([]);
expect(empty.attachments).toEqual([]);
expect(empty.links).toBe('');
expect(empty.markdownLinks).toBe('');
await testMarkdownMessage(Number(empty.id), []);
const emptyRow = await (await request.get(`${variant.url}/api/mail/${empty.id}`, { headers })).json();
expect((await get(sign(0, now + 600, Number(empty.id), address, emptyRow.created_at))).status()).toBe(404);
const deleted = await request.delete(`${variant.url}/admin/mails/${payload.id}`);
expect(deleted.ok()).toBe(true);
expect((await get(sign(0))).status()).toBe(404);
if (variant.removeLarge) {
const large = await receive(files, true);
expect(large.attachments).toEqual([]);
expect(large.text).toContain('Preserved body');
const largeRow = await (await request.get(`${variant.url}/api/mail/${large.id}`, { headers })).json();
expect((await get(sign(0, now + 600, Number(large.id), address, largeRow.created_at))).status()).toBe(404);
}
if (!variant.removeAll) {
for (const messageId of [`<${randomUUID()}@duplicate.test>`, null]) {
const count = payloads.length;
const names = Array.from({ length: 4 }, () => `${randomUUID()}.txt`);
await Promise.all(names.map(name => receive([
{ name, type: 'text/plain', content: name },
], false, messageId, false)));
await expect.poll(() => payloads.length).toBe(count + names.length);
const received = payloads.slice(count);
expect(new Set(received.map(mail => mail.id)).size).toBe(names.length);
expect(received.map(mail => mail.attachments[0].filename).sort()).toEqual([...names].sort());
for (const mail of received) {
expect(mail.attachments).toHaveLength(1);
const attachment = mail.attachments[0];
const response = await request.get(attachment.url);
expect(response.status()).toBe(200);
expect(await response.text()).toBe(attachment.filename);
expect(new URL(attachment.url).pathname.split('/')[3]).toBe(String(mail.id));
}
}
}
} finally {
await request.delete(`${variant.url}/admin/delete_address/${address_id}`);
await new Promise<void>(resolve => server.close(() => resolve()));
}
});
});
}
@@ -1,91 +0,0 @@
import { test, expect } from '@playwright/test';
import http from 'node:http';
import { WORKER_URL, createTestAddress, seedTestMail } from '../../fixtures/test-helpers';
test('Webhook tests support random and specified mail IDs with ownership checks', async ({ request }) => {
const mailbox = await createTestAddress(request, 'webhookid');
const other = await createTestAddress(request, 'webhookother');
const headers = { Authorization: `Bearer ${mailbox.jwt}` };
const payloads: any[] = [];
const server = http.createServer((req, res) => {
const chunks: Buffer[] = [];
req.on('data', chunk => chunks.push(chunk));
req.on('end', () => {
try {
payloads.push(JSON.parse(Buffer.concat(chunks).toString()));
res.writeHead(200).end();
} catch {
res.writeHead(400).end();
}
});
});
await new Promise<void>(resolve => server.listen(0, '0.0.0.0', resolve));
const port = (server.address() as import('node:net').AddressInfo).port;
const settings = {
enabled: true, url: `http://${process.env.CI ? 'e2e-runner' : 'localhost'}:${port}`,
method: 'POST', headers: '{"Content-Type":"application/json"}',
body: '{"id":"${id}","subject":"${subject}"}',
};
try {
await seedTestMail(request, mailbox.address, { subject: 'First selected email' });
await seedTestMail(request, mailbox.address, { subject: 'Second selected email' });
const list = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, { headers });
expect(list.ok()).toBe(true);
const { results } = await list.json();
expect(results).toHaveLength(2);
const selected = results[1];
for (const endpoint of ['/api/webhook/test', '/admin/mail_webhook/test']) {
const count = payloads.length;
expect((await request.post(`${WORKER_URL}${endpoint}`, { headers, data: settings })).ok()).toBe(true);
await expect.poll(() => payloads.length).toBe(count + 1);
if (endpoint.startsWith('/api/')) {
expect(results.map((mail: any) => String(mail.id))).toContain(payloads[count].id);
}
expect((await request.post(`${WORKER_URL}${endpoint}`, {
headers, data: { ...settings, mail_id: Number(selected.id) },
})).ok()).toBe(true);
await expect.poll(() => payloads.length).toBe(count + 2);
expect(payloads[count + 1].id).toBe(String(selected.id));
for (const mail_id of [0, -1, 1.5, '1', null]) {
expect((await request.post(`${WORKER_URL}${endpoint}`, {
headers, data: { ...settings, mail_id },
})).status()).toBe(400);
}
expect((await request.post(`${WORKER_URL}${endpoint}`, {
headers, data: { ...settings, mail_id: Number.MAX_SAFE_INTEGER },
})).status()).toBe(404);
expect(payloads).toHaveLength(count + 2);
for (const [lang, invalid, missing] of [
['zh', '无效的邮件 ID', '邮件不存在'],
['en', 'Invalid mail ID', 'Mail not found'],
]) {
for (const body of ['null', '[]', '1', 'true', '"text"', '{', '']) {
const response = await request.post(`${WORKER_URL}${endpoint}`, {
headers: { ...headers, 'x-lang': lang, 'Content-Type': 'application/json' },
data: body,
});
expect(response.status()).toBe(400);
expect(await response.text()).toBe(lang === 'zh' ? '无效的请求体' : 'Invalid request body');
}
for (const [mail_id, status, message] of [[0, 400, invalid], [999999999, 404, missing]] as const) {
const response = await request.post(`${WORKER_URL}${endpoint}`, {
headers: { ...headers, 'x-lang': lang }, data: { ...settings, mail_id },
});
expect(response.status()).toBe(status);
expect(await response.text()).toBe(message);
}
}
expect(payloads).toHaveLength(count + 2);
}
const count = payloads.length;
expect((await request.post(`${WORKER_URL}/api/webhook/test`, {
headers: { Authorization: `Bearer ${other.jwt}` },
data: { ...settings, mail_id: Number(selected.id) },
})).status()).toBe(404);
expect(payloads).toHaveLength(count);
} finally {
await request.delete(`${WORKER_URL}/admin/delete_address/${mailbox.address_id}`);
await request.delete(`${WORKER_URL}/admin/delete_address/${other.address_id}`);
await new Promise<void>(resolve => server.close(() => resolve()));
}
});
+2 -92
View File
@@ -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}/__test/receive_mail`, {
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
data: {
from,
to: address,
@@ -126,96 +126,6 @@ test.describe('Webhook — triggered on incoming mail', () => {
}
});
test('signed attachment paths serve each attachment and reject tampering', async ({ request }) => {
const { server, firstRequest, url } = await startWebhookReceiver();
try {
const saveRes = await request.post(`${WORKER_URL}/api/webhook/settings`, {
headers: { Authorization: `Bearer ${jwt}` },
data: {
enabled: true,
url,
method: 'POST',
headers: JSON.stringify({ 'Content-Type': 'application/json' }),
body: '{"attachments":${attachments}}',
},
});
expect(saveRes.ok()).toBe(true);
const attachment = Buffer.from('89504e470d0a1a0a', 'hex');
const boundary = `webhook-attachment-${Date.now()}`;
const raw = [
`From: attachment-sender@test.example.com`,
`To: ${address}`,
`Subject: Webhook Attachment ${Date.now()}`,
`Message-ID: <webhook-attachment-${Date.now()}@test>`,
`MIME-Version: 1.0`,
`Content-Type: multipart/mixed; boundary="${boundary}"`,
``,
`--${boundary}`,
`Content-Type: text/plain; charset=utf-8`,
``,
`Attachment test`,
`--${boundary}`,
`Content-Type: image/png`,
`Content-Disposition: attachment; filename="test.png"`,
`Content-Transfer-Encoding: base64`,
``,
attachment.toString('base64'),
`--${boundary}`,
`Content-Type: text/plain`,
`Content-Disposition: attachment; filename="second.txt"`,
`Content-Transfer-Encoding: base64`,
``,
Buffer.from('Second attachment').toString('base64'),
`--${boundary}--`,
].join('\r\n');
const res = await request.post(`${WORKER_URL}/__test/receive_mail`, {
data: {
from: 'attachment-sender@test.example.com',
to: address,
raw,
},
});
expect(res.ok()).toBe(true);
const payload = JSON.parse((await firstRequest).body);
expect(payload.attachments).toHaveLength(2);
expect(payload.attachments[0]).toMatchObject({ filename: 'test.png', mimeType: 'image/png' });
expect(new URL(payload.attachments[0].url).origin).toBe(new URL(WORKER_URL).origin);
const attachmentPath = new URL(payload.attachments[0].url).pathname;
expect(attachmentPath).toMatch(/^\/open_api\/a\/\d+\/0\/\d+\/[a-z2-7]{51}[aq]$/);
expect(payload.attachments[1].filename).toBe('second.txt');
const attachmentRes = await request.get(payload.attachments[0].url);
expect(attachmentRes.ok()).toBe(true);
expect(attachmentRes.headers()['content-type']).toBe('image/png');
expect(attachmentRes.headers()['content-disposition']).toBe('inline');
expect(attachmentRes.headers()['x-content-type-options']).toBe('nosniff');
expect(attachmentRes.headers()['cache-control']).toBe('no-store');
expect(Buffer.from(await attachmentRes.body())).toEqual(attachment);
const secondRes = await request.get(payload.attachments[1].url);
expect(secondRes.status()).toBe(200);
expect(secondRes.headers()['content-disposition']).toBe("attachment; filename*=UTF-8''second.txt");
expect(secondRes.headers()['content-type']).toBe('application/octet-stream');
expect(await secondRes.text()).toBe('Second attachment');
const parts = attachmentPath.split('/');
parts[parts.length - 1] = (parts.at(-1)[0] === 'a' ? 'b' : 'a') + parts.at(-1).slice(1);
const tamperedPath = parts.join('/');
const tamperedRes = await request.get(`${WORKER_URL}${tamperedPath}`);
expect(tamperedRes.status()).toBe(404);
for (const index of ['1', '999', '-1']) {
const changedIndex = attachmentPath.replace('/0/', `/${index}/`);
expect((await request.get(`${WORKER_URL}${changedIndex}`)).status()).toBe(404);
}
} finally {
server.close();
}
});
test('webhook is NOT called when disabled', async ({ request }) => {
const { server, firstRequest, url } = await startWebhookReceiver();
@@ -247,7 +157,7 @@ test.describe('Webhook — triggered on incoming mail', () => {
`Should not trigger webhook`,
].join('\r\n');
const res = await request.post(`${WORKER_URL}/__test/receive_mail`, {
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
data: { from: 'sender@test.example.com', to: address, raw },
});
expect(res.ok()).toBe(true);
@@ -1,56 +0,0 @@
import { expect, test } from '@playwright/test';
import { FRONTEND_URL } from '../../fixtures/test-helpers';
test('waits for access settings before showing the Admin password dialog', async ({ page }) => {
await page.addInitScript(() => {
const testWindow = window as Window & { __adminPasswordRendered?: boolean };
testWindow.__adminPasswordRendered = false;
const detectAdminPassword = () => {
const textNodes = document.createTreeWalker(document, NodeFilter.SHOW_TEXT);
while (textNodes.nextNode()) {
if (textNodes.currentNode.textContent?.trim() === '管理员密码') {
testWindow.__adminPasswordRendered = true;
return;
}
}
};
new MutationObserver(detectAdminPassword).observe(document, {
childList: true,
subtree: true,
characterData: true,
});
});
let releaseSettings!: () => void;
const settingsPending = new Promise<void>((resolve) => {
releaseSettings = resolve;
});
let markSettingsRequested!: () => void;
const settingsRequested = new Promise<void>((resolve) => {
markSettingsRequested = resolve;
});
await page.route('**/open_api/settings', async (route) => {
markSettingsRequested();
await settingsPending;
await route.continue();
});
await page.goto(`${FRONTEND_URL}/zh/admin`);
await settingsRequested;
try {
await expect(page.getByText('管理员密码', { exact: true })).toHaveCount(0);
} finally {
releaseSettings();
}
await expect(page.getByText('快速设置', { exact: true })).toBeVisible();
await expect(page.getByText('管理员密码', { exact: true })).toHaveCount(0);
expect(await page.evaluate(() => (
window as Window & { __adminPasswordRendered?: boolean }
).__adminPasswordRendered)).toBe(false);
});
@@ -1,50 +0,0 @@
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);
}
});
@@ -1,37 +0,0 @@
import { expect, test } from '@playwright/test';
import { FRONTEND_URL, WORKER_URL } from '../../fixtures/test-helpers';
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
test('persists the selected D1 plan and restores it after reload', async ({ page, request }) => {
const seedResponse = await request.post(`${WORKER_URL}/admin/config`, {
headers: ADMIN_HEADERS,
data: { key: 'd1_storage_plan', value: 'free' },
});
expect(seedResponse.ok()).toBe(true);
await page.addInitScript(() => {
localStorage.setItem('adminAuth', 'e2e-admin-pass');
sessionStorage.setItem('adminTab', 'qucickSetup');
});
await page.goto(`${FRONTEND_URL}/en/admin`);
const storagePanel = page.locator('.storage-panel');
const planSelect = storagePanel.locator('.plan-select .n-select');
await expect(storagePanel.getByText('Current Database Size', { exact: true })).toBeVisible();
await expect(storagePanel.getByText('Database Capacity Limit', { exact: true })).toBeVisible();
await expect(storagePanel.getByText('Capacity Usage', { exact: true })).toBeVisible();
await expect(planSelect).toContainText('Free');
await planSelect.click();
await page.locator('.n-base-select-option').filter({ hasText: 'Workers Paid' }).click();
await expect(page.getByText('Workers plan saved')).toBeVisible();
await expect(storagePanel).toContainText('10.0 GB');
await page.reload();
await expect(planSelect).toContainText('Workers Paid');
await expect(storagePanel).toContainText('10.0 GB');
});
-79
View File
@@ -1,79 +0,0 @@
import { expect, request as apiRequest, test } from '@playwright/test';
import {
FRONTEND_URL,
WORKER_URL,
createTestAddress,
deleteAddress,
seedTestMail,
} from '../../fixtures/test-helpers';
test('keeps refresh unread and supports automatic and manual state changes', async ({ page }) => {
const request = await apiRequest.newContext();
let jwt: string | undefined;
try {
const mailbox = await createTestAddress(request, 'mail-read-browser');
jwt = mailbox.jwt;
const subject = `Unread browser mail ${Date.now()}`;
await seedTestMail(request, mailbox.address, { subject });
await page.goto(`${FRONTEND_URL}/en/`);
await page.evaluate(() => localStorage.setItem('mailListView', 'true'));
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
await expect(page.getByText(subject, { exact: true })).toBeVisible({ timeout: 10_000 });
await page.reload();
await expect(page.getByText(subject, { exact: true })).toBeVisible({ timeout: 10_000 });
const list = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
headers: { Authorization: `Bearer ${jwt}` },
});
const mail = (await list.json()).results.find((item: any) => item.raw.includes(subject));
expect(mail.is_unread).toBe(1);
await expect(page.locator('.mail-list-unread').filter({ hasText: subject })).toBeVisible();
await page.route('**/api/mails/*/read', async route => {
await new Promise(resolve => setTimeout(resolve, 300));
await route.continue();
});
const waitForReadUpdate = (isUnread: boolean) => page.waitForResponse(response =>
/\/api\/mails\/\d+\/read$/.test(new URL(response.url()).pathname)
&& response.request().method() === 'PATCH'
&& response.request().postDataJSON().isUnread === isUnread
);
const readResponse = waitForReadUpdate(false);
await page.getByText(subject, { exact: true }).click();
expect(await page.locator('.mail-list-unread').filter({ hasText: subject }).count()).toBe(0);
expect(await page.locator('.n-spin-content--spinning').count()).toBe(0);
await expect(page.getByRole('button', { name: 'Mark as Unread' })).not.toHaveClass(/n-button--loading/);
expect((await readResponse).ok()).toBe(true);
await expect(page.getByRole('button', { name: 'Mark as Unread' })).not.toHaveClass(/n-button--loading/);
const unreadResponse = waitForReadUpdate(true);
await page.getByRole('button', { name: 'Mark as Unread' }).click();
expect(await page.locator('.mail-list-unread').filter({ hasText: subject }).count()).toBe(1);
expect(await page.locator('.n-spin-content--spinning').count()).toBe(0);
await expect(page.getByRole('button', { name: 'Mark as Read' })).toHaveClass(/n-button--loading/);
expect((await unreadResponse).ok()).toBe(true);
await expect(page.getByRole('button', { name: 'Mark as Read' })).not.toHaveClass(/n-button--loading/);
const manualReadResponse = waitForReadUpdate(false);
await page.getByRole('button', { name: 'Mark as Read' }).click();
expect(await page.locator('.mail-list-unread').filter({ hasText: subject }).count()).toBe(0);
expect(await page.locator('.n-spin-content--spinning').count()).toBe(0);
await expect(page.getByRole('button', { name: 'Mark as Unread' })).toHaveClass(/n-button--loading/);
expect((await manualReadResponse).ok()).toBe(true);
await expect(page.getByRole('button', { name: 'Mark as Unread' })).not.toHaveClass(/n-button--loading/);
const updatedList = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
headers: { Authorization: `Bearer ${jwt}` },
});
const updatedMail = (await updatedList.json()).results.find((item: any) => item.id === mail.id);
expect(updatedMail.is_unread).toBe(0);
} finally {
try {
if (jwt) await deleteAddress(request, jwt);
} finally {
await request.dispose();
}
}
});
+2 -2
View File
@@ -62,8 +62,8 @@ test.describe('Passkey Browser Flow', () => {
// Wait for user settings to load (shows user email)
await expect(page.getByText(TEST_USER_EMAIL)).toBeVisible({ timeout: 15_000 });
// === Step 2: Click "User Settings" tab ===
await page.getByText('User Settings').click();
// === Step 2: Click "User Account Settings" tab ===
await page.getByText('User Account Settings').click();
// === Step 3: Create a passkey ===
await page.getByRole('button', { name: 'Create Passkey' }).click();
-387
View File
@@ -1,387 +0,0 @@
import { createHash } from 'crypto';
import { expect, test, type APIRequestContext } from '@playwright/test';
import {
createTestAddress,
deleteAddress,
FRONTEND_URL,
FRONTEND_URL_ENV_OFF,
WORKER_URL,
} from '../../fixtures/test-helpers';
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
const uniqueValue = (label: string) => (
`${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
);
async function createCode(
request: APIRequestContext,
type: 'role' | 'send_balance' | 'address_prefix_once',
value: string,
) {
const response = await request.post(`${WORKER_URL}/admin/redeem_codes/batch`, {
headers: ADMIN_HEADERS,
data: {
count: 1,
redeem_type: type,
value,
enabled: true,
expires_at: new Date(Date.now() + 3_600_000).toISOString(),
},
});
expect(response.ok()).toBe(true);
const body = await response.json();
expect(body.codes).toHaveLength(1);
return body.codes[0] as string;
}
async function findCode(request: APIRequestContext, type: string, code: string) {
const response = await request.get(
`${WORKER_URL}/admin/redeem_codes?redeem_type=${type}`
+ `&limit=20&offset=0&query=${encodeURIComponent(code)}`,
{ headers: ADMIN_HEADERS },
);
const body = await response.json();
return body.results.find((row: { code: string }) => row.code === code);
}
async function deleteCode(request: APIRequestContext, type: string, code: string) {
const row = await findCode(request, type, code);
if (!row) return;
await request.delete(`${WORKER_URL}/admin/redeem_codes/${row.id}`, {
headers: ADMIN_HEADERS,
});
}
async function createUser(request: APIRequestContext) {
const email = `${uniqueValue('browser-redeem-user')}@test.example.com`;
const password = createHash('sha256').update('browser-redeem-password').digest('hex');
const response = await request.post(`${WORKER_URL}/admin/users`, {
headers: ADMIN_HEADERS,
data: { email, password },
});
expect(response.ok()).toBe(true);
const usersResponse = await request.get(
`${WORKER_URL}/admin/users?limit=10&offset=0&query=${encodeURIComponent(email)}`,
{ headers: ADMIN_HEADERS },
);
const user = (await usersResponse.json()).results[0];
await request.post(`${WORKER_URL}/admin/user_roles`, {
headers: ADMIN_HEADERS,
data: { user_id: user.id, role_text: null },
});
const loginResponse = await request.post(`${WORKER_URL}/user_api/login`, {
data: { email, password },
});
expect(loginResponse.ok()).toBe(true);
return { id: user.id as number, email, jwt: (await loginResponse.json()).jwt as string };
}
async function deleteUser(request: APIRequestContext, userId: number) {
await request.post(`${WORKER_URL}/admin/user_roles`, {
headers: ADMIN_HEADERS,
data: { user_id: userId, role_text: null },
});
await request.delete(`${WORKER_URL}/admin/users/${userId}`, { headers: ADMIN_HEADERS });
}
test('the mailbox entry opens the pure redemption page and completes role redemption', async ({ page, request }) => {
const user = await createUser(request);
const code = await createCode(request, 'role', 'case-role');
try {
await page.goto(`${FRONTEND_URL}/en/`);
await expect(page.getByTestId('redeem-entry')).toBeVisible();
await page.getByTestId('redeem-entry').click();
await expect(page).toHaveURL(/\/en\/redeem$/);
await expect(page.getByTestId('redeem-code-link')).toHaveAttribute(
'href', 'https://example.com/redeem-codes',
);
await page.evaluate((jwt) => localStorage.setItem('userJwt', jwt), user.jwt);
const settingsResponse = page.waitForResponse((response) => (
new URL(response.url()).pathname === '/user_api/settings'
));
await page.reload();
await settingsResponse;
await page.getByTestId('redeem-code-input').locator('input').fill(code);
await page.getByRole('button', { name: 'Look up code' }).click();
await expect(page.getByRole('heading', { name: 'Role benefits' })).toBeVisible();
await expect(page.getByTestId('redeem-user-email')).toHaveCount(0);
await page.getByTestId('redeem-now').click();
const userEmailInput = page.getByTestId('redeem-user-email').locator('input');
await expect(userEmailInput).toHaveValue(user.email);
await userEmailInput.fill(user.email.toUpperCase());
const refreshedSettings = page.waitForResponse((response) => (
new URL(response.url()).pathname === '/user_api/settings' && response.ok()
));
await page.getByRole('button', { name: 'Confirm redemption' }).click();
const refreshedUser = await (await refreshedSettings).json();
expect(refreshedUser.user_role.role).toBe('case-role');
expect(refreshedUser.access_token).toEqual(expect.any(String));
await expect(page.getByRole('heading', { name: 'Redemption complete' })).toBeVisible();
await expect(page.getByText(user.email, { exact: false })).toBeVisible();
await expect(page.getByText('case-role', { exact: false })).toBeVisible();
const row = await findCode(request, 'role', code);
expect(JSON.parse(row.result)).toMatchObject({ user_email: user.email, role: 'case-role' });
await page.getByRole('button', { name: 'Redeem another code' }).click();
await page.getByTestId('redeem-code-input').locator('input').fill(code);
const nextQuery = page.waitForRequest((request) => (
new URL(request.url()).pathname === '/redeem_api/query'
));
await page.getByRole('button', { name: 'Look up code' }).click();
expect((await nextQuery).headers()['x-user-access-token']).toBe(refreshedUser.access_token);
} finally {
await deleteCode(request, 'role', code);
await deleteUser(request, user.id);
}
});
test('sending credits defaults to the current mailbox and keeps the target editable', async ({ page, request }) => {
const address = await createTestAddress(request, 'rdb-');
const code = await createCode(request, 'send_balance', '3');
try {
await page.goto(`${FRONTEND_URL}/en/`);
await expect(page.getByTestId('redeem-entry')).toBeVisible();
await page.getByTestId('redeem-entry').click();
await expect(page).toHaveURL(/\/en\/redeem$/);
await page.evaluate((jwt) => localStorage.setItem('jwt', jwt), address.jwt);
const settingsResponse = page.waitForResponse((response) => (
new URL(response.url()).pathname === '/api/settings'
));
await page.reload();
await settingsResponse;
await page.getByTestId('redeem-code-input').locator('input').fill(code);
await page.getByRole('button', { name: 'Look up code' }).click();
await page.getByTestId('redeem-now').click();
const targetInput = page.getByTestId('redeem-target-address').locator('input');
await expect(targetInput).toHaveValue(address.address);
await targetInput.fill(address.address.toUpperCase());
await page.getByRole('button', { name: 'Confirm redemption' }).click();
await expect(page.getByRole('heading', { name: 'Redemption complete' })).toBeVisible();
} finally {
await deleteCode(request, 'send_balance', code);
await deleteAddress(request, address.jwt);
}
});
test('the special-address page shows and retrieves the same full credentials', async ({ page, request }) => {
const code = await createCode(request, 'address_prefix_once', 'ui');
const name = `m${Math.random().toString(36).slice(2, 8)}`;
let jwt = '';
try {
await page.goto(`${FRONTEND_URL}/en/redeem`);
await page.getByTestId('redeem-code-input').locator('input').fill(code);
await page.getByRole('button', { name: 'Look up code' }).click();
await expect(page.getByRole('heading', { name: 'Custom mailbox' })).toBeVisible();
await expect(page.getByText('Unused', { exact: true })).toBeVisible();
await page.getByTestId('redeem-address-name').locator('input').fill(name);
await page.getByRole('button', { name: 'Redeem mailbox' }).click();
const addressValue = page.getByTestId('address-credential-address');
const passwordValue = page.getByTestId('address-credential-password');
const jwtValue = page.getByTestId('address-credential-jwt');
await expect(addressValue).toHaveText(`ui${name}@test.example.com`);
const firstAddress = await addressValue.innerText();
const firstPassword = await passwordValue.innerText();
const firstJwt = await jwtValue.innerText();
expect(firstPassword).not.toBe('');
expect(firstJwt).not.toBe('');
jwt = firstJwt;
await page.getByRole('button', { name: 'Redeem another code' }).click();
await page.getByTestId('redeem-code-input').locator('input').fill(code);
await page.getByRole('button', { name: 'Look up code' }).click();
await expect(page.getByText('Used', { exact: true })).toBeVisible();
await expect(page.getByText(firstAddress, { exact: false })).toHaveCount(0);
await page.getByRole('button', { name: 'View redemption result' }).click();
await expect(page.getByTestId('address-credential-address')).toHaveText(firstAddress);
await expect(page.getByTestId('address-credential-password')).toHaveText(firstPassword);
await expect(page.getByTestId('address-credential-jwt')).toHaveText(firstJwt);
} finally {
if (jwt) {
await request.delete(`${WORKER_URL}/api/delete_address`, {
headers: { Authorization: `Bearer ${jwt}` },
});
}
await deleteCode(request, 'address_prefix_once', code);
}
});
test('Admin can batch-create, search, export, and display a redemption result', async ({ page, request }) => {
let code = '';
const user = await createUser(request);
try {
await page.addInitScript(() => {
localStorage.setItem('adminAuth', 'e2e-admin-pass');
sessionStorage.setItem('adminTab', 'redeemCodes');
});
await page.goto(`${FRONTEND_URL}/en/admin`);
await expect(page.getByTestId('redeem-admin-create')).toBeVisible();
await page.getByTestId('redeem-admin-create').click();
const dialog = page.getByRole('dialog');
await dialog.getByTestId('redeem-admin-count').locator('input').fill('1');
await dialog.getByTestId('redeem-admin-role').click();
await page.locator('.n-base-select-option').filter({ hasText: 'case-role' }).click();
await dialog.getByRole('button', { name: 'Generate' }).click();
await expect(page.getByText('Select a future expiration time')).toBeVisible();
const expirationInput = dialog.locator('.n-date-picker input');
await expirationInput.fill('2099-01-01 00:00:00');
await expirationInput.press('Enter');
const createResponsePromise = page.waitForResponse((response) => (
response.request().method() === 'POST'
&& new URL(response.url()).pathname === '/admin/redeem_codes/batch'
));
const createdCodesDownload = page.waitForEvent('download');
await dialog.getByRole('button', { name: 'Generate' }).click();
const createResponse = await createResponsePromise;
code = (await createResponse.json()).codes[0];
expect((await createdCodesDownload).suggestedFilename()).toBe('redeem-codes-role.csv');
await expect(page.getByText('Redemption codes generated: 1.')).toBeVisible();
await page.getByTestId('redeem-admin-search').locator('input').fill(code);
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByText(code, { exact: true })).toBeVisible();
const exportDownload = page.waitForEvent('download');
await page.getByTestId('redeem-admin-export').click();
await expect(page.getByText('Export rows (maximum 10000)')).toBeVisible();
await page.getByRole('dialog').getByRole('button', { name: 'Download CSV' }).click();
expect((await exportDownload).suggestedFilename()).toBe('redeem-codes-role.csv');
const redeemResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
data: { code, user_email: user.email },
});
expect(redeemResponse.ok()).toBe(true);
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByText(user.email, { exact: true })).toBeVisible();
} finally {
if (code) await deleteCode(request, 'role', code);
await deleteUser(request, user.id);
}
});
test('Admin ignores a stale list response after switching redemption type', async ({ page }) => {
let releaseRole!: () => void;
let markRoleRequested!: () => void;
let markRoleCompleted!: () => void;
const roleGate = new Promise<void>((resolve) => { releaseRole = resolve; });
const roleRequested = new Promise<void>((resolve) => { markRoleRequested = resolve; });
const roleCompleted = new Promise<void>((resolve) => { markRoleCompleted = resolve; });
await page.route('**/admin/redeem_codes?*', async (route) => {
const type = new URL(route.request().url()).searchParams.get('redeem_type');
if (type === 'role') {
markRoleRequested();
await roleGate;
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
count: 1,
results: [{
id: 1,
code: 'STALE-ROLE-CODE',
redeem_type: 'role',
value: 'case-role',
result: null,
enabled: 1,
expires_at: '2099-01-01 00:00:00',
redeemed_at: null,
}],
}),
});
markRoleCompleted();
return;
}
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
count: 1,
results: [{
id: 2,
code: 'CURRENT-BALANCE-CODE',
redeem_type: 'send_balance',
value: '20',
result: null,
enabled: 1,
expires_at: '2099-01-01 00:00:00',
redeemed_at: null,
}],
}),
});
});
await page.addInitScript(() => {
localStorage.setItem('adminAuth', 'e2e-admin-pass');
sessionStorage.setItem('adminTab', 'redeemCodes');
});
await page.goto(`${FRONTEND_URL}/en/admin`);
await roleRequested;
await page.getByTestId('redeem-admin-filter-type').click();
await page.locator('.n-base-select-option').filter({ hasText: 'Sending credits' }).click();
await expect(page.getByText('CURRENT-BALANCE-CODE', { exact: true })).toBeVisible();
releaseRole();
await roleCompleted;
await expect(page.getByText('CURRENT-BALANCE-CODE', { exact: true })).toBeVisible();
await expect(page.getByText('STALE-ROLE-CODE', { exact: true })).toHaveCount(0);
});
for (const { locale, roleRequired, roleType, addressType, create, generate, prefixError } of [
{
locale: 'en', roleRequired: 'Select a role', roleType: 'Role benefits',
addressType: 'Custom mailbox', create: 'Batch Generate', generate: 'Generate',
prefixError: 'Use only letters and digits, up to 29 characters. Leave empty for no prefix.',
},
{
locale: 'zh', roleRequired: '请选择角色', roleType: '角色权益',
addressType: '专属邮箱', create: '批量生成', generate: '生成',
prefixError: '前缀仅支持英文字母和数字,最多 29 个字符;留空表示无前缀。',
},
]) {
test(`Admin validates missing role and invalid prefix in ${locale}`, async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem('adminAuth', 'e2e-admin-pass');
sessionStorage.setItem('adminTab', 'redeemCodes');
});
let createRequests = 0;
page.on('request', (request) => {
if (request.method() === 'POST'
&& new URL(request.url()).pathname === '/admin/redeem_codes/batch') {
createRequests++;
}
});
await page.goto(`${FRONTEND_URL}/${locale}/admin`);
await expect(page.getByTestId('redeem-admin-filter-type')).toContainText(roleType);
await page.getByRole('button', { name: create, exact: true }).click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: generate, exact: true }).click();
await expect(page.getByText(roleRequired, { exact: true })).toBeVisible();
await dialog.getByRole('button', { name: 'close', exact: true }).click();
await page.getByTestId('redeem-admin-filter-type').click();
await page.locator('.n-base-select-option').filter({ hasText: addressType }).click();
await page.getByRole('button', { name: create, exact: true }).click();
await dialog.locator('input[maxlength]').fill('bad-');
await dialog.getByRole('button', { name: generate, exact: true }).click();
await expect(page.getByText(prefixError, { exact: true })).toBeVisible();
expect(createRequests).toBe(0);
});
}
test('the disabled frontend hides the entry and redirects the page', async ({ page }) => {
await page.goto(`${FRONTEND_URL_ENV_OFF}/en/`);
await expect(page.getByTestId('redeem-entry')).toHaveCount(0);
await page.goto(`${FRONTEND_URL_ENV_OFF}/en/redeem`);
await expect(page).not.toHaveURL(/\/redeem$/);
await expect(page.getByTestId('redeem-entry')).toHaveCount(0);
await page.addInitScript(() => {
localStorage.setItem('adminAuth', 'e2e-admin-pass');
});
await page.goto(`${FRONTEND_URL_ENV_OFF}/en/admin`);
await expect(page.getByTestId('redeem-admin-create')).toHaveCount(0);
await expect(page.getByText('Redemption Codes', { exact: true })).toHaveCount(0);
});
-134
View File
@@ -1,134 +0,0 @@
import { test, expect, request as apiRequest, type Page } from '@playwright/test';
import {
FRONTEND_URL,
createTestAddress,
deleteAddress,
requestSendAccess,
} from '../../fixtures/test-helpers';
const expectEditorOriginsToAlign = async (page: Page) => {
const editorOriginDelta = await page.locator('.compose-textarea').evaluate((editor) => {
const textareaElement = editor.querySelector('textarea');
const placeholder = editor.querySelector('.n-input__placeholder');
if (!textareaElement) throw new Error('compose textarea element not found');
if (!placeholder) throw new Error('compose placeholder element not found');
const textareaBox = textareaElement.getBoundingClientRect();
const placeholderBox = placeholder.getBoundingClientRect();
const textareaStyle = getComputedStyle(textareaElement);
const placeholderStyle = getComputedStyle(placeholder);
return {
x: textareaBox.x + parseFloat(textareaStyle.paddingLeft)
- placeholderBox.x - parseFloat(placeholderStyle.paddingLeft),
y: textareaBox.y + parseFloat(textareaStyle.paddingTop)
- placeholderBox.y - parseFloat(placeholderStyle.paddingTop),
};
});
expect(Math.abs(editorOriginDelta.x)).toBeLessThan(1);
expect(Math.abs(editorOriginDelta.y)).toBeLessThan(1);
};
test.describe('Send mail composer', () => {
test('edits a draft, changes format, and previews HTML', async ({ page }) => {
const api = await apiRequest.newContext();
let jwt: string | undefined;
try {
const created = await createTestAddress(api, 'compose-ui');
jwt = created.jwt;
await requestSendAccess(api, jwt);
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
await page.getByText('Send Mail', { exact: true }).click();
await expect(page.getByRole('heading', { name: 'Compose email', exact: true })).toBeVisible();
await expect(page.locator('.composer-title')).toContainText(created.address);
const expectedFieldOrder = [
'send-mail-sender-address',
'send-mail-sender-name',
'send-mail-recipient-address',
'send-mail-recipient-name',
];
const fieldIds = await page.locator('.composer-form .n-grid input').evaluateAll(
(inputs) => inputs.map((input) => input.id)
);
expect(fieldIds.filter((id) => expectedFieldOrder.includes(id))).toEqual(expectedFieldOrder);
const textarea = page.locator('.compose-textarea textarea');
await expectEditorOriginsToAlign(page);
const recipient = page.getByRole('textbox', { name: /^Recipient address/ });
const subject = page.getByRole('textbox', { name: /^Subject/ });
await recipient.fill('recipient@test.example.com');
await subject.fill('Composer preview');
await page.getByText('HTML', { exact: true }).click();
const htmlContent = [
'<h1>Preview heading</h1><p>Preview body</p>',
'<script>alert("xss")</script><img src="x" onerror="alert(1)">',
].join('');
let previewDialogAppeared = false;
page.on('dialog', async (dialog) => {
previewDialogAppeared = true;
await dialog.dismiss();
});
await textarea.fill(htmlContent);
await page.getByRole('button', { name: 'Preview' }).click();
const preview = page.locator('.compose-preview');
await expect(preview).toBeVisible();
await expect(preview).toContainText('Preview heading');
await expect(preview.locator('script, [onerror]')).toHaveCount(0);
expect(previewDialogAppeared).toBe(false);
await page.getByRole('button', { name: 'Edit' }).click();
await expect(preview).toBeHidden();
await expect(recipient).toHaveValue('recipient@test.example.com');
await expect(subject).toHaveValue('Composer preview');
await expect(textarea).toHaveValue(htmlContent);
await page.setViewportSize({ width: 320, height: 800 });
await page.goto(`${FRONTEND_URL}/es/?jwt=${jwt}`);
await page.getByText('Enviar correo', { exact: true }).click();
await expect(page.getByRole('heading', { name: 'Redactar correo', exact: true })).toBeVisible();
await expect(page.getByRole('textbox', { name: /^Dirección del destinatario/ })).toBeVisible();
await expect(page.getByText('Borrador guardado en este navegador', { exact: true })).toBeVisible();
await page.getByText('HTML', { exact: true }).click();
const previewButton = page.getByRole('button', { name: 'Vista previa', exact: true });
await expect(previewButton).toBeVisible();
const previewBox = await previewButton.boundingBox();
expect(previewBox).not.toBeNull();
expect(previewBox!.x + previewBox!.width).toBeLessThanOrEqual(320);
} finally {
try {
if (jwt) await deleteAddress(api, jwt);
} finally {
await api.dispose();
}
}
});
test('keeps the Admin field order and editor placeholder aligned', async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem('adminAuth', 'e2e-admin-pass');
sessionStorage.setItem('adminTab', 'mails');
});
await page.goto(`${FRONTEND_URL}/en/admin`);
await page.getByText('Send Mail', { exact: true }).click();
await expect(page.getByRole('heading', { name: 'Compose email', exact: true })).toBeVisible();
const expectedFieldOrder = [
'admin-send-mail-sender-address',
'admin-send-mail-sender-name',
'admin-send-mail-recipient-address',
'admin-send-mail-recipient-name',
];
const fieldIds = await page.locator('.composer-form .n-grid input').evaluateAll(
(inputs) => inputs.map((input) => input.id)
);
expect(fieldIds.filter((id) => expectedFieldOrder.includes(id))).toEqual(expectedFieldOrder);
await expectEditorOriginsToAlign(page);
});
});
-417
View File
@@ -1,417 +0,0 @@
import { expect, test, type Page } from '@playwright/test';
import { createHmac } from 'node:crypto';
import { FRONTEND_URL, WORKER_URL, createTestAddress, deleteAddress, hashPassword, getAddressSender, onMailpitMessage } from '../../fixtures/test-helpers';
const accessToken = (expiresIn: number) => {
const payload = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + expiresIn })).toString('base64url');
return `e30.${payload}.signature`;
};
const openApiTestPage = async (page: Page) => {
// Isolate API calls from the application's automatic settings requests.
await page.route(`${FRONTEND_URL}/api-test`, (route) => route.fulfill({
contentType: 'text/html', body: '<!doctype html><html><body></body></html>',
}));
await page.goto(`${FRONTEND_URL}/api-test`);
};
const expiredTokenResponse = { status: 401, json: { code: 'AUTH_USER_ACCESS_TOKEN_EXPIRED', message: 'Access token expired' } };
for (const scenario of [
{ name: 'mailbox credential', path: '/api/settings', site: false, admin: false, needAuth: true },
{ name: 'account credential', path: '/user_api/settings', site: false, admin: false, needAuth: true },
{ name: 'site password', path: '/api/settings', site: true, admin: false, needAuth: true },
{ name: 'site password on admin API', path: '/admin/db_version', site: true, admin: false, needAuth: true },
{ name: 'admin password', path: '/admin/db_version', site: false, admin: true, needAuth: true },
{ name: 'admin login', path: '/open_api/admin_login', site: false, admin: true, needAuth: false },
{ name: 'site password after authentication', path: '/api/settings', site: true, admin: false, needAuth: false },
{ name: 'site password on admin API after authentication', path: '/admin/db_version', site: true, admin: false, needAuth: false },
]) {
test(`Authentication dialog distinguishes ${scenario.name}`, async ({ page }) => {
await openApiTestPage(page);
await page.route(`**${scenario.path}`, route => route.fulfill(scenario.site
? { status: 401, json: { code: 'AUTH_SITE_PASSWORD_INVALID', message: 'Site password required' } }
: scenario.admin
? { status: 401, json: { code: 'AUTH_ADMIN_CREDENTIAL_INVALID', message: 'Admin password required' } }
: { status: 401, body: 'Invalid credential' }));
const result = await page.evaluate(async ({ path, needAuth }) => {
const apiModule = '/src/api/index.js';
const storeModule = '/src/store/index.js';
const { api } = await import(apiModule);
const state = (await import(storeModule)).useGlobalState();
state.openSettings.value.needAuth = needAuth;
state.showAuth.value = false;
state.showAdminAuth.value = false;
try {
await api.fetch(path);
} catch {
return { site: state.showAuth.value, admin: state.showAdminAuth.value };
}
}, { path: scenario.path, needAuth: scenario.needAuth });
expect(result).toEqual({ site: scenario.site, admin: scenario.admin });
});
}
for (const scenario of ['expired', 'expiring', 'valid', 'no account', 'login expired', 'wrong password', 'text zh', 'text en', 'retry expired', 'retry unauthorized', 'unmatched path', 'server error', 'json client error', 'json server error'] as const) {
test(`Access token response handling: ${scenario}`, async ({ page }) => {
await openApiTestPage(page);
const initialToken = accessToken(scenario === 'valid' ? 3600 : scenario === 'expiring' ? 20 : -60);
const freshToken = accessToken(7200);
const path = scenario === 'unmatched path' ? '/open_api/settings' : '/admin/db_version';
let refreshCount = 0;
const attempts: string[] = [];
await page.route('**/user_api/settings', async (route) => {
refreshCount++;
expect(route.request().headers()['x-user-token']).toBe('account-token');
await route.fulfill(scenario === 'login expired'
? { status: 401, contentType: 'text/plain', body: 'Please login again' }
: { json: { access_token: freshToken } });
});
await page.route(`**${path}`, async (route) => {
attempts.push(route.request().headers()['x-user-access-token']);
if (scenario.startsWith('json')) {
await route.fulfill({
status: scenario === 'json client error' ? 403 : 503,
json: { code: 'OPERATION_FAILED', message: 'Operation failed' },
});
return;
}
if (scenario === 'server error') {
await route.fulfill({ status: 500, body: 'Server error' });
return;
}
if (!['valid', 'expiring', 'wrong password'].includes(scenario)
&& (attempts.length === 1 || scenario === 'retry expired')) {
await route.fulfill(scenario.startsWith('text')
? { status: 401, body: scenario === 'text zh' ? '您的访问令牌已过期, 请刷新页面' : 'Your access token has expired, please refresh the page' }
: expiredTokenResponse);
return;
}
await route.fulfill(['wrong password', 'retry unauthorized'].includes(scenario)
? { status: 401, json: { code: 'AUTH_ADMIN_CREDENTIAL_INVALID', message: 'Admin password required' } }
: { json: { current_db_version: 'test-version' } });
});
const result = await page.evaluate(async ({ initialToken, scenario, path }) => {
const apiModule = '/src/api/index.js';
const storeModule = '/src/store/index.js';
const { api } = await import(apiModule);
const state = (await import(storeModule)).useGlobalState();
state.userJwt.value = scenario === 'no account' ? '' : 'account-token';
state.userSettings.value.access_token = initialToken;
state.adminAuth.value = scenario === 'wrong password' ? 'wrong-password' : '';
state.showAdminAuth.value = false;
try {
return { data: await api.fetch(path), error: null, showAdminAuth: state.showAdminAuth.value };
} catch (error) {
return { data: null, error: String(error), showAdminAuth: state.showAdminAuth.value };
}
}, { initialToken, scenario, path });
const needsRefresh = !['valid', 'expiring', 'no account', 'wrong password', 'text zh', 'text en', 'unmatched path', 'server error', 'json client error', 'json server error'].includes(scenario);
expect(refreshCount).toBe(needsRefresh ? 1 : 0);
expect(attempts).toEqual(needsRefresh && scenario !== 'login expired' ? [initialToken, freshToken] : [initialToken]);
expect(result.showAdminAuth).toBe(['wrong password', 'retry unauthorized'].includes(scenario));
if (scenario === 'login expired') expect(result.error).toContain('Please login again');
else if (['wrong password', 'retry unauthorized'].includes(scenario)) expect(result.error).toContain('Admin password required');
else if (['retry expired', 'no account', 'unmatched path'].includes(scenario)) expect(result.error).toContain('Access token expired');
else if (scenario === 'server error') expect(result.error).toContain('Server error');
else if (scenario.startsWith('text')) {
expect(result.error).toContain(scenario === 'text zh'
? '您的访问令牌已过期, 请刷新页面'
: 'Your access token has expired, please refresh the page');
}
else if (scenario.startsWith('json')) {
expect(result.error).toContain('Operation failed');
expect(result.error).not.toContain('[object Object]');
}
else expect(result.data).toEqual({ current_db_version: 'test-version' });
});
}
test('concurrent and late responses share one access token refresh', async ({ page }) => {
await openApiTestPage(page);
const initialToken = accessToken(-60);
const freshToken = accessToken(7200);
const paths = ['/admin/db_version', '/api/settings', '/api/send_mail',
'/user_api/bind_address', '/user_api/address/1/settings'];
let refreshCount = 0;
const initialPaths: string[] = [];
const attempts: string[] = [];
let release!: () => void;
let releaseLate!: () => void;
const refreshPending = new Promise<void>((resolve) => { release = resolve; });
const lateResponse = new Promise<void>((resolve) => { releaseLate = resolve; });
await page.route('**/user_api/settings', async (route) => {
refreshCount++;
await refreshPending;
await route.fulfill({ json: { access_token: freshToken } });
});
await page.route((url) => paths.includes(url.pathname), async (route) => {
if (route.request().headers()['x-user-access-token'] === initialToken) {
const requestPath = new URL(route.request().url()).pathname;
initialPaths.push(requestPath);
if (requestPath === paths.at(-1)) await lateResponse;
await route.fulfill(expiredTokenResponse);
return;
}
attempts.push(route.request().headers()['x-user-access-token']);
await route.fulfill({ json: { success: true } });
});
const pending = page.evaluate(async ({ initialToken, paths }) => {
const apiModule = '/src/api/index.js';
const storeModule = '/src/store/index.js';
const { api } = await import(apiModule);
const state = (await import(storeModule)).useGlobalState();
state.userJwt.value = 'account-token';
state.userSettings.value.access_token = initialToken;
return Promise.all(paths.map((path) => api.fetch(path, {
method: path === '/api/send_mail' ? 'POST' : 'GET',
})));
}, { initialToken, paths });
try {
await expect.poll(() => refreshCount).toBe(1);
await expect.poll(() => initialPaths.length).toBe(paths.length);
expect(attempts).toEqual([]);
release();
await expect.poll(() => attempts.length).toBe(paths.length - 1);
} finally {
release();
releaseLate();
}
expect(await pending).toEqual(paths.map(() => ({ success: true })));
expect(refreshCount).toBe(1);
expect(attempts).toEqual(paths.map(() => freshToken));
});
for (const failure of [400, 401, 500, 'network'] as const) {
test(`failed shared refresh preserves independent mailbox requests: ${failure}`, async ({ page }) => {
await openApiTestPage(page);
const paths = ['/admin/db_version', '/user_api/bind_address', '/user_api/address/1/settings',
'/api/settings', '/api/send_mail'];
const attempts: string[] = [];
let initialResponses = 0;
page.on('response', async (response) => {
if (paths.includes(new URL(response.url()).pathname) && response.status() === 401) {
await response.finished();
initialResponses++;
}
});
let refreshCount = 0;
let release!: () => void;
const refreshPending = new Promise<void>((resolve) => { release = resolve; });
const freshToken = accessToken(7200);
await page.route('**/user_api/settings', async (route) => {
refreshCount++;
if (refreshCount > 1) {
await route.fulfill({ json: { access_token: freshToken } });
return;
}
await refreshPending;
if (failure === 'network') await route.abort('failed');
else await route.fulfill({ status: failure, body: 'Refresh failed' });
});
await page.route((url) => paths.includes(url.pathname), async (route) => {
const path = new URL(route.request().url()).pathname;
if (route.request().headers()['x-user-access-token'] && route.request().headers()['x-user-access-token'] !== freshToken) {
await route.fulfill(expiredTokenResponse);
return;
}
attempts.push(path);
await route.fulfill(path === '/api/send_mail'
? { status: 403, body: 'No send balance' }
: { json: { success: true } });
});
const pending = page.evaluate(async ({ paths, initialToken }) => {
const apiModule = '/src/api/index.js';
const storeModule = '/src/store/index.js';
const { api } = await import(apiModule);
const state = (await import(storeModule)).useGlobalState();
state.userJwt.value = 'account-token';
state.userSettings.value.access_token = initialToken;
state.adminAuth.value = '';
state.openSettings.value.needAuth = true;
state.showAuth.value = false;
state.showAdminAuth.value = false;
const results = await Promise.all(paths.map(async (path) => {
try {
return { data: await api.fetch(path, { method: path === '/api/send_mail' ? 'POST' : 'GET' }) };
} catch (error) {
return { error: String(error) };
}
}));
const flags = { showAuth: state.showAuth.value, showAdminAuth: state.showAdminAuth.value, loading: state.loading.value };
await api.fetch('/admin/db_version');
return { results, flags };
}, { paths, initialToken: accessToken(-60) });
try {
await expect.poll(() => initialResponses).toBe(paths.length);
await expect.poll(() => refreshCount).toBe(1);
expect(attempts).toEqual([]);
} finally {
release();
}
const { results, flags } = await pending;
for (const result of results.slice(0, 3)) {
expect(result.error).toContain(failure === 'network' ? 'Network Error' : 'Refresh failed');
}
expect(results[3].data).toEqual({ success: true });
expect(results[4].error).toContain('[403]: No send balance');
expect(flags).toEqual({ showAuth: false, showAdminAuth: false, loading: false });
expect(refreshCount).toBe(2);
expect(attempts.slice(0, 2).sort()).toEqual(['/api/send_mail', '/api/settings']);
expect(attempts.slice(2)).toEqual(['/admin/db_version']);
});
}
for (const changedCredential of ['account', 'mailbox'] as const) {
test(`a changed ${changedCredential} stops the waiting request after refresh`, async ({ page }) => {
await openApiTestPage(page);
const path = changedCredential === 'mailbox' ? '/api/send_mail' : '/admin/db_version';
const freshToken = accessToken(7200);
let attempts = 0;
await page.route(`**${path}`, async (route) => {
attempts++;
await route.fulfill(attempts === 1 ? expiredTokenResponse : { json: { success: true } });
});
await page.route('**/user_api/settings', async (route) => {
await page.evaluate(async (changedCredential) => {
const storeModule = '/src/store/index.js';
const state = (await import(storeModule)).useGlobalState();
if (changedCredential === 'account') {
state.userJwt.value = 'other-account';
state.userSettings.value = { access_token: 'other-access-token' };
} else {
state.jwt.value = 'other-mailbox';
}
}, changedCredential);
await route.fulfill({ json: { access_token: freshToken } });
});
const result = await page.evaluate(async ({ initialToken, path }) => {
const apiModule = '/src/api/index.js';
const storeModule = '/src/store/index.js';
const { api } = await import(apiModule);
const state = (await import(storeModule)).useGlobalState();
state.userJwt.value = 'account-token';
state.jwt.value = 'mailbox-token';
state.userSettings.value.access_token = initialToken;
state.adminAuth.value = '';
try {
await api.fetch(path, { method: path === '/api/send_mail' ? 'POST' : 'GET' });
return null;
} catch (error) {
return { error: String(error), token: state.userSettings.value.access_token, loading: state.loading.value };
}
}, { initialToken: accessToken(-60), path });
expect(result).toEqual({
error: 'Error: User session changed, please retry',
token: changedCredential === 'account' ? 'other-access-token' : freshToken,
loading: false,
});
expect(attempts).toBe(1);
});
}
for (const [scenario, query] of [
['expired account', ''],
['deleted account', ''],
['valid account', ''],
['missing account', ''],
['missing expiry', ''],
['expired account', '?refresh=1&source=e2e'],
['deleted account', '?refresh=1&source=e2e'],
['valid account', '?refresh=1&source=e2e'],
['missing account', '?refresh=1&source=e2e'],
['missing expiry', '?refresh=1&source=e2e'],
] as const) {
test(`mailbox operations use the real Worker when ${scenario}, query: ${query || 'none'}`, async ({ page, request }) => {
const address = await createTestAddress(request, 'refresh-mailbox-');
let userId: number | undefined;
try {
const email = `refresh-account-${Date.now()}@test.example.com`;
const created = await request.post(`${WORKER_URL}/admin/users`, {
data: { email, password: hashPassword('test-password-123') },
});
expect(created.ok()).toBe(true);
const users = await request.get(`${WORKER_URL}/admin/users`, {
params: { limit: 10, offset: 0, query: email },
});
expect(users.ok()).toBe(true);
userId = (await users.json()).results.find((user: any) => user.user_email === email).id;
const sign = (payload: Record<string, unknown>) => {
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
const signature = createHmac('sha256', 'e2e-test-secret-key').update(`${header}.${body}`).digest('base64url');
return `${header}.${body}.${signature}`;
};
const now = Math.floor(Date.now() / 1000);
const claims = { user_id: userId, user_email: email, iat: now - 3600 };
const userJwt = scenario === 'missing account' ? ''
: sign({ ...claims, exp: now + (scenario === 'expired account' ? -60 : 3600) });
const expiredAccessToken = sign({ ...claims, exp: scenario === 'missing expiry' ? undefined : now - 60, user_role: 'case-role' });
if (scenario === 'deleted account') {
expect((await request.delete(`${WORKER_URL}/admin/users/${userId}`)).ok()).toBe(true);
}
// Exercise the browser API module without the UI's automatic settings requests.
await page.route(`${FRONTEND_URL}/api-test`, (route) => route.fulfill({
contentType: 'text/html', body: '<!doctype html><html><body></body></html>',
}));
await page.goto(`${FRONTEND_URL}/api-test`);
const refreshStatuses: number[] = [];
let sendCount = 0;
page.on('response', (response) => {
if (new URL(response.url()).pathname === '/user_api/settings') refreshStatuses.push(response.status());
});
page.on('request', (req) => {
const url = new URL(req.url());
if (['/api/settings', '/api/send_mail'].includes(url.pathname)) expect(url.search).toBe(query);
if (url.pathname === '/api/send_mail') sendCount++;
});
const settings = await page.evaluate(async ({ userJwt, expiredAccessToken, mailboxJwt, query }) => {
const apiModule = '/src/api/index.js';
const storeModule = '/src/store/index.js';
const { api } = await import(apiModule);
const state = (await import(storeModule)).useGlobalState();
state.userJwt.value = userJwt;
state.userSettings.value.access_token = expiredAccessToken;
state.jwt.value = mailboxJwt;
state.adminAuth.value = '';
return api.fetch(`/api/settings${query}`);
}, { userJwt, expiredAccessToken, mailboxJwt: address.jwt, query });
expect(settings.address).toBe(address.address);
expect(settings.send_balance).toBe(10);
const subject = `Mailbox refresh regression ${scenario} ${Date.now()}`;
const listener = onMailpitMessage((mail) => mail.Subject === subject);
await listener.ready;
const [mail] = await Promise.all([
listener.message,
page.evaluate(async ({ subject, query }) => {
const apiModule = '/src/api/index.js';
const { api } = await import(apiModule);
await api.fetch(`/api/send_mail${query}`, {
method: 'POST',
body: { to_mail: 'recipient@test.example.com', subject, content: 'Independent mailbox credential', is_html: false },
});
}, { subject, query }),
]);
expect(mail.From.Address).toBe(address.address);
expect(sendCount).toBe(scenario === 'valid account' || scenario === 'missing expiry' ? 1 : 2);
expect((await getAddressSender(request, address.address)).balance).toBe(9);
const sent = await request.get(`${WORKER_URL}/admin/sendbox`, {
params: { address: address.address, limit: 10, offset: 0 },
});
expect(sent.ok()).toBe(true);
expect((await sent.json()).results).toHaveLength(1);
expect(refreshStatuses).toEqual(scenario === 'valid account' ? [200]
: scenario === 'missing account' || scenario === 'missing expiry' ? []
: scenario === 'expired account' ? [401, 401] : [400, 400]);
} finally {
await deleteAddress(request, address.jwt);
if (userId !== undefined) await request.delete(`${WORKER_URL}/admin/users/${userId}`);
}
});
}
@@ -100,7 +100,7 @@ test.describe('User address pagination browser flow', () => {
return url.pathname === '/user_api/bind_address'
&& url.searchParams.get('limit') === '100';
});
await page.getByText('Inbox', { exact: true }).click();
await page.getByText('Mail Box', { exact: true }).click();
const initialMailboxResponse = await initialMailboxAddressesResponse;
expect(initialMailboxResponse.ok()).toBe(true);
const mailboxAddressSelect = page.locator('.n-input-group .n-select').first();
-137
View File
@@ -1,137 +0,0 @@
import { expect, request as apiRequest, test, type APIRequestContext } from '@playwright/test';
import {
FRONTEND_URL,
WORKER_URL,
createTestAddress,
deleteAddress,
hashPassword,
} from '../../fixtures/test-helpers';
async function createUser(request: APIRequestContext) {
const email = `user-send-browser-${Date.now()}@test.example.com`;
const password = hashPassword('test-password-123');
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
data: { email, password },
});
expect(registerRes.ok()).toBe(true);
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
data: { email, password },
});
expect(loginRes.ok()).toBe(true);
const { jwt } = await loginRes.json();
const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
return { email, jwt, userId: payload.user_id as number };
}
test.describe('User send mail page', () => {
test('selects a bound address, sends mail, and opens its sent items', async ({ page }) => {
const request = await apiRequest.newContext();
const addresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
let userId: number | undefined;
let originalUserSettings: Record<string, unknown> | undefined;
try {
const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`);
expect(settingsRes.ok()).toBe(true);
originalUserSettings = await settingsRes.json();
const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
data: {
...originalUserSettings,
enable: true,
enableMailVerify: false,
maxAddressCount: 0,
},
});
expect(enableUserRes.ok()).toBe(true);
const user = await createUser(request);
userId = user.userId;
const address = await createTestAddress(request, 'usr-browser-');
const secondAddress = await createTestAddress(request, 'usr-second-');
addresses.push(address, secondAddress);
for (const boundAddress of addresses) {
const bindRes = await request.post(`${WORKER_URL}/user_api/bind_address`, {
headers: {
Authorization: `Bearer ${boundAddress.jwt}`,
'x-user-token': user.jwt,
},
});
expect(bindRes.ok()).toBe(true);
}
await page.goto(`${FRONTEND_URL}/en/`);
await page.evaluate((userJwt) => {
localStorage.setItem('userJwt', userJwt);
}, user.jwt);
await page.goto(`${FRONTEND_URL}/en/user`);
await expect(page.getByText(user.email)).toBeVisible({ timeout: 15_000 });
const credentialResponse = page.waitForResponse((response) => (
response.request().method() === 'GET'
&& new URL(response.url()).pathname
=== `/user_api/bind_address_jwt/${address.address_id}`
));
const addressRow = page.getByRole('row').filter({ hasText: address.address });
await addressRow.getByRole('button', { name: 'Address Credential' }).click();
expect((await credentialResponse).ok()).toBe(true);
await expect(page.getByRole('dialog')).toContainText(address.address);
await page.getByRole('button', { name: 'close' }).click();
const initialSettingsResponse = page.waitForResponse((response) => (
new URL(response.url()).pathname
=== `/user_api/address/${secondAddress.address_id}/settings`
));
await page.getByText('Send Mail', { exact: true }).click();
expect((await initialSettingsResponse).ok()).toBe(true);
await expect(page.locator('.composer-title h2')).toHaveText('Compose email');
const settingsResponse = page.waitForResponse((response) => (
new URL(response.url()).pathname
=== `/user_api/address/${address.address_id}/settings`
));
await page.locator('.address-picker-select').click();
await page.locator('.n-base-select-option').filter({ hasText: address.address }).click();
expect((await settingsResponse).ok()).toBe(true);
await expect(page.locator('.address-picker-select')).toContainText(address.address);
const subject = `Browser user send ${Date.now()}`;
await page.getByRole('textbox', { name: /^Recipient address/ })
.fill('recipient@test.example.com');
await page.getByRole('textbox', { name: /^Subject/ }).fill(subject);
await page.locator('.compose-textarea textarea').fill('Sent from the user page');
const sendResponse = page.waitForResponse((response) => (
response.request().method() === 'POST'
&& new URL(response.url()).pathname
=== `/user_api/address/${address.address_id}/send_mail`
));
const sendboxResponse = page.waitForResponse((response) => (
response.request().method() === 'GET'
&& new URL(response.url()).pathname === '/user_api/sendbox'
));
await page.getByRole('button', { name: 'Send', exact: true }).click();
expect((await sendResponse).ok()).toBe(true);
expect((await sendboxResponse).ok()).toBe(true);
await expect(page.locator('.n-tabs-tab--active')).toHaveText('Sent');
await expect(page.locator('.n-thing-header__title').filter({ hasText: subject }))
.toHaveText(subject, { timeout: 15_000 });
} finally {
try {
await Promise.allSettled(addresses.map((address) => deleteAddress(request, address.jwt)));
if (userId !== undefined) {
await request.delete(`${WORKER_URL}/admin/users/${userId}`);
}
} finally {
if (originalUserSettings) {
await request.post(`${WORKER_URL}/admin/user_settings`, {
data: originalUserSettings,
});
}
await request.dispose();
}
}
});
});
-74
View File
@@ -1,74 +0,0 @@
import { expect, test } from '@playwright/test';
import { FRONTEND_URL } from '../../fixtures/test-helpers';
for (const [locale, mails, webhook, testLabel, specified, mailId, invalid] of [
['zh', '邮件', '邮件 Webhook', '测试', '指定 ID', '邮件 ID', '请输入有效的正整数邮件 ID'],
['en', 'Emails', 'Mail Webhook', 'Test', 'Specify ID', 'Email ID', 'Enter a valid positive integer email ID'],
['es', 'Correos', 'Webhook de correo', 'Prueba', 'Especificar ID', 'ID del correo', 'Introduce un ID de correo válido que sea un entero positivo'],
['pt-BR', 'E-mails', 'Webhook de e-mail', 'Teste', 'Especificar ID', 'ID do e-mail', 'Digite um ID de e-mail válido que seja um número inteiro positivo'],
['ja', 'メール', 'メールWebhook', 'テスト', 'ID を指定', 'メール ID', '有効な正の整数のメール ID を入力してください'],
['de', 'E-Mails', 'Mail-Webhook', 'Test', 'ID angeben', 'E-Mail-ID', 'Gib eine gültige positive ganze Zahl als E-Mail-ID ein'],
]) {
test(`Webhook test dialog translations: ${locale}`, async ({ page }) => {
await page.route('**/admin/mail_webhook/settings', route => route.fulfill({
json: { enabled: true, url: 'https://example.com/webhook', method: 'POST', headers: '{}', body: '{}' },
}));
await page.goto(`${FRONTEND_URL}/${locale}/admin`);
await page.getByText(mails, { exact: true }).click();
await page.getByText(webhook, { exact: true }).click();
await page.getByRole('button', { name: testLabel, exact: true }).click();
const dialog = page.getByRole('dialog');
await dialog.getByText(specified, { exact: true }).click();
await expect(dialog.getByPlaceholder(mailId, { exact: true })).toBeVisible();
await dialog.getByRole('button', { name: testLabel, exact: true }).click();
await expect(page.getByText(invalid, { exact: true })).toBeVisible();
});
}
test('Webhook test dialog selects random or specified email', async ({ page }) => {
const requests: any[] = [];
let fail = false;
await page.route('**/admin/mail_webhook/settings', route => route.fulfill({
json: { enabled: true, url: 'https://example.com/webhook', method: 'POST', headers: '{}', body: '{}' },
}));
await page.route('**/admin/mail_webhook/test', route => {
requests.push(route.request().postDataJSON());
return route.fulfill({ status: fail ? 404 : 200, body: fail ? 'Mail not found' : '{"success":true}' });
});
await page.goto(`${FRONTEND_URL}/zh/admin`);
await page.getByText('邮件', { exact: true }).click();
await page.getByText('邮件 Webhook', { exact: true }).click();
const open = page.locator('#app').getByRole('button', { name: '测试', exact: true });
await open.click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByText('随机邮件', { exact: true })).toBeVisible();
await expect(dialog.getByPlaceholder('邮件 ID', { exact: true })).toHaveCount(0);
await dialog.getByRole('button', { name: '取消', exact: true }).click();
await expect(dialog).toBeHidden();
expect(requests).toHaveLength(0);
await open.click();
await dialog.getByRole('button', { name: '测试', exact: true }).click();
await expect(dialog).toBeHidden();
expect(requests).toHaveLength(1);
expect(requests[0]).not.toHaveProperty('mail_id');
await open.click();
await dialog.getByText('指定 ID', { exact: true }).click();
await dialog.getByRole('button', { name: '测试', exact: true }).click();
await expect(page.getByText('请输入有效的正整数邮件 ID', { exact: true })).toBeVisible();
expect(requests).toHaveLength(1);
await dialog.getByPlaceholder('邮件 ID', { exact: true }).fill('123');
fail = true;
await dialog.getByRole('button', { name: '测试', exact: true }).click();
await expect(page.getByText(/Mail not found/).first()).toBeVisible();
await expect(dialog).toBeVisible();
expect(requests[1].mail_id).toBe(123);
fail = false;
await dialog.getByRole('button', { name: '测试', exact: true }).click();
await expect(dialog).toBeHidden();
expect(requests[2].mail_id).toBe(123);
await open.click();
await dialog.getByText('随机邮件', { exact: true }).click();
await dialog.getByRole('button', { name: '测试', exact: true }).click();
await expect(dialog).toBeHidden();
expect(requests[3]).not.toHaveProperty('mail_id');
});
-49
View File
@@ -1,49 +0,0 @@
import assert from 'node:assert/strict';
import { once } from 'node:events';
import { after, before, test } from 'node:test';
import { WebSocketServer } from 'ws';
let server;
let onMailpitMessage;
before(async () => {
server = new WebSocketServer({ host: '127.0.0.1', port: 0 });
await once(server, 'listening');
const originalApi = process.env.MAILPIT_API;
process.env.MAILPIT_API = `http://127.0.0.1:${server.address().port}/api`;
try {
({ onMailpitMessage } = await import('../../fixtures/test-helpers.ts'));
} finally {
if (originalApi === undefined) delete process.env.MAILPIT_API;
else process.env.MAILPIT_API = originalApi;
}
});
after(async () => {
for (const client of server.clients) client.terminate();
await new Promise((resolve) => server.close(resolve));
});
const target = { Subject: 'target-message' };
const newEvent = JSON.stringify({ Type: 'new', Data: target });
const statsEvent = JSON.stringify({ Type: 'stats', Data: { total: 1 } });
for (const [name, payload] of [
['single event', newEvent],
['new event followed by stats', `${newEvent}\n${statsEvent}`],
['stats followed by new event', `${statsEvent}\n${newEvent}`],
['malformed and unrelated events before the match',
`invalid-json\n${JSON.stringify({ Type: 'new', Data: { Subject: 'other' } })}\n${newEvent}\n`],
]) {
test(name, async () => {
const connected = once(server, 'connection');
const listener = onMailpitMessage((mail) => mail.Subject === target.Subject, { timeout: 1000 });
const received = assert.doesNotReject(async () => {
assert.deepEqual(await listener.message, target);
});
const [client] = await connected;
await listener.ready;
client.send(payload);
await received;
});
}
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.DS_Store
.env
.env.*
!.env.example
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "zinc",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib"
},
"iconLibrary": "lucide"
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Temp Email Next</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
{
"name": "cloudflare-temp-email-next",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.17",
"@vitejs/plugin-react": "^5.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.3.0",
"lucide-react": "^0.561.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.6.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"typescript": "^5.9.3",
"vite": "^7.3.5"
},
"devDependencies": {
"@types/node": "^26.0.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3"
},
"packageManager": "pnpm@10.10.0"
}
+3210
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+399
View File
@@ -0,0 +1,399 @@
const API_BASE = import.meta.env.VITE_API_BASE || ""
export type DomainOption = {
label: string
value: string
}
export type OpenSettings = {
fetched: boolean
title: string
prefix: string
addressRegex: string
minAddressLen: number
maxAddressLen: number
needAuth: boolean
enableUserCreateEmail: boolean
disableAnonymousUserCreateEmail: boolean
disableCustomAddressName: boolean
enableUserDeleteEmail: boolean
enableSendMail: boolean
enableAddressPassword: boolean
defaultDomains: string[]
randomSubdomainDomains: string[]
domains: DomainOption[]
cfTurnstileSiteKey: string
enableGlobalTurnstileCheck: boolean
}
export type AddressSettings = {
fetched: boolean
address: string
send_balance: number
auto_reply?: unknown
}
export type UserOpenSettings = {
fetched: boolean
enable: boolean
enableMailVerify: boolean
oauth2ClientIDs: { clientID: string; name: string; icon?: string }[]
}
export type UserSettings = {
fetched: boolean
user_email: string
user_id: number
is_admin: boolean
access_token: string | null
new_user_token: string | null
user_role: { domains?: string[] | null; role: string; prefix?: string | null } | null
}
export type BoundAddress = {
id: number | string
name?: string
address?: string
mail_count?: number
send_count?: number
}
export type MailItem = {
id: number | string
source?: string
address?: string
subject?: string
sender?: string
html?: string
message?: string
text?: string
raw?: string
created_at?: string
metadata?: unknown
}
export type MailListResponse = {
results: MailItem[]
count: number
}
export type SendMailPayload = {
from_name: string
to_name: string
to_mail: string
subject: string
is_html: boolean
content: string
}
export const defaultOpenSettings: OpenSettings = {
fetched: false,
title: "",
prefix: "",
addressRegex: "",
minAddressLen: 1,
maxAddressLen: 30,
needAuth: false,
enableUserCreateEmail: false,
disableAnonymousUserCreateEmail: false,
disableCustomAddressName: false,
enableUserDeleteEmail: false,
enableSendMail: false,
enableAddressPassword: false,
defaultDomains: [],
randomSubdomainDomains: [],
domains: [],
cfTurnstileSiteKey: "",
enableGlobalTurnstileCheck: false,
}
export const defaultAddressSettings: AddressSettings = {
fetched: false,
address: "",
send_balance: 0,
}
export const defaultUserOpenSettings: UserOpenSettings = {
fetched: false,
enable: false,
enableMailVerify: false,
oauth2ClientIDs: [],
}
export const defaultUserSettings: UserSettings = {
fetched: false,
user_email: "",
user_id: 0,
is_admin: false,
access_token: null,
new_user_token: null,
user_role: null,
}
const hasControlChar = (value: string) => {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
if (code < 32 || code === 127) return true
}
return false
}
const safeHeaderValue = (value: string | null | undefined) => {
if (!value) return undefined
const trimmed = value.trim()
if (!trimmed || trimmed === "undefined" || trimmed === "null") return undefined
if (hasControlChar(trimmed)) return undefined
return trimmed
}
const safeBearerHeader = (jwt: string | null | undefined) => {
const safe = safeHeaderValue(jwt)
return safe ? `Bearer ${safe}` : undefined
}
const normalizeOpenSettings = (payload: Record<string, unknown>): OpenSettings => {
const domains = Array.isArray(payload.domains) ? (payload.domains as string[]) : []
const domainLabels = Array.isArray(payload.domainLabels) ? (payload.domainLabels as string[]) : []
return {
...defaultOpenSettings,
fetched: true,
title: String(payload.title || ""),
prefix: String(payload.prefix || ""),
addressRegex: String(payload.addressRegex || ""),
minAddressLen: Number(payload.minAddressLen ?? 1),
maxAddressLen: Number(payload.maxAddressLen ?? 30),
needAuth: Boolean(payload.needAuth),
enableUserCreateEmail: Boolean(payload.enableUserCreateEmail),
disableAnonymousUserCreateEmail: Boolean(payload.disableAnonymousUserCreateEmail),
disableCustomAddressName: Boolean(payload.disableCustomAddressName),
enableUserDeleteEmail: Boolean(payload.enableUserDeleteEmail),
enableSendMail: Boolean(payload.enableSendMail),
enableAddressPassword: Boolean(payload.enableAddressPassword),
defaultDomains: Array.isArray(payload.defaultDomains) ? (payload.defaultDomains as string[]) : [],
randomSubdomainDomains: Array.isArray(payload.randomSubdomainDomains)
? (payload.randomSubdomainDomains as string[])
: [],
domains: domains.map((domain, index) => ({
label: domainLabels[index] || domain,
value: domain,
})),
cfTurnstileSiteKey: String(payload.cfTurnstileSiteKey || ""),
enableGlobalTurnstileCheck: Boolean(payload.enableGlobalTurnstileCheck),
}
}
export async function hashPassword(password: string) {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(password))
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("")
}
export function parseJwtAddress(jwt: string) {
try {
const payload = JSON.parse(decodeURIComponent(atob(jwt.split(".")[1].replace(/-/g, "+").replace(/_/g, "/"))))
return typeof payload.address === "string" ? payload.address : ""
} catch {
return ""
}
}
export function formatDate(value?: string) {
if (!value) return ""
const date = new Date(`${value} UTC`)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString()
}
export class ApiClient {
constructor(
private readonly getJwt: () => string,
private readonly getCustomAuth: () => string,
private readonly getUserJwt: () => string,
) {}
async request<T>(path: string, options: RequestInit = {}) {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"x-lang": "en",
}
const customAuth = safeHeaderValue(this.getCustomAuth())
const userJwt = safeHeaderValue(this.getUserJwt())
const authorization = safeBearerHeader(this.getJwt())
if (customAuth) headers["x-custom-auth"] = customAuth
if (userJwt) headers["x-user-token"] = userJwt
if (authorization) headers.Authorization = authorization
const response = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
...headers,
...(options.headers as Record<string, string> | undefined),
},
})
const text = await response.text()
const data = text ? tryParseJson(text) : null
if (response.status >= 300) {
throw new Error(typeof data === "string" ? data : text || `[${response.status}] request failed`)
}
return data as T
}
async getOpenSettings() {
return normalizeOpenSettings(await this.request<Record<string, unknown>>("/open_api/settings"))
}
async getSettings() {
if (!safeHeaderValue(this.getJwt())) return { ...defaultAddressSettings, fetched: true }
const payload = await this.request<Record<string, unknown>>("/api/settings")
return {
fetched: true,
address: String(payload.address || ""),
send_balance: Number(payload.send_balance || 0),
auto_reply: payload.auto_reply,
} satisfies AddressSettings
}
async getUserOpenSettings() {
return {
...defaultUserOpenSettings,
...(await this.request<Partial<UserOpenSettings>>("/user_api/open_settings")),
fetched: true,
} satisfies UserOpenSettings
}
async getUserSettings() {
if (!safeHeaderValue(this.getUserJwt())) return { ...defaultUserSettings, fetched: true }
return {
...defaultUserSettings,
...(await this.request<Partial<UserSettings>>("/user_api/settings")),
fetched: true,
} satisfies UserSettings
}
async userLogin(email: string, password: string, cfToken: string) {
const response = await this.request<{ jwt: string }>("/user_api/login", {
method: "POST",
body: JSON.stringify({
email,
password: await hashPassword(password),
cf_token: cfToken,
}),
})
return response.jwt
}
async sendUserVerifyCode(email: string, cfToken: string) {
return this.request<{ expirationTtl?: number }>("/user_api/verify_code", {
method: "POST",
body: JSON.stringify({ email, cf_token: cfToken }),
})
}
async userRegister(email: string, password: string, code: string, cfToken: string) {
await this.request("/user_api/register", {
method: "POST",
body: JSON.stringify({
email,
password: await hashPassword(password),
code,
cf_token: cfToken,
}),
})
}
async listBoundAddresses() {
const response = await this.request<{ results: BoundAddress[] }>("/user_api/bind_address")
return response.results || []
}
async bindCurrentAddress() {
await this.request("/user_api/bind_address", { method: "POST" })
}
async getBoundAddressJwt(addressId: string | number) {
const response = await this.request<{ jwt: string }>(`/user_api/bind_address_jwt/${addressId}`)
return response.jwt
}
async unbindAddress(addressId: string | number) {
await this.request("/user_api/unbind_address", {
method: "POST",
body: JSON.stringify({ address_id: addressId }),
})
}
async createAddress(name: string, domain: string, cfToken: string, enableRandomSubdomain: boolean) {
return this.request<{ jwt: string; password?: string }>("/api/new_address", {
method: "POST",
body: JSON.stringify({
name,
domain,
cf_token: cfToken,
enableRandomSubdomain,
}),
})
}
async credentialLogin(credential: string, cfToken: string) {
await this.request("/open_api/credential_login", {
method: "POST",
body: JSON.stringify({ credential, cf_token: cfToken }),
})
return credential
}
async passwordLogin(email: string, password: string, cfToken: string) {
const response = await this.request<{ jwt: string }>("/api/address_login", {
method: "POST",
body: JSON.stringify({
email,
password: await hashPassword(password),
cf_token: cfToken,
}),
})
return response.jwt
}
async listMails(limit: number, offset: number) {
return this.request<MailListResponse>(`/api/parsed_mails?limit=${limit}&offset=${offset}`)
}
async deleteMail(id: string | number) {
await this.request(`/api/mails/${id}`, { method: "DELETE" })
}
async sendMail(payload: SendMailPayload) {
await this.request("/api/send_mail", {
method: "POST",
body: JSON.stringify(payload),
})
}
async requestSendAccess() {
await this.request("/api/request_send_mail_access", {
method: "POST",
body: JSON.stringify({}),
})
}
async clearInbox() {
await this.request("/api/clear_inbox", { method: "DELETE" })
}
async clearSentItems() {
await this.request("/api/clear_sent_items", { method: "DELETE" })
}
async deleteAddress() {
await this.request("/api/delete_address", { method: "DELETE" })
}
}
function tryParseJson(value: string) {
try {
return JSON.parse(value)
} catch {
return value
}
}
@@ -0,0 +1,98 @@
import { useEffect, useId, useRef, useState } from "react"
import { Button } from "./ui/button"
declare global {
interface Window {
turnstile?: {
render: (
selector: string,
options: {
sitekey: string
theme: "light" | "dark"
callback: (token: string) => void
},
) => string
remove: (id: string) => void
}
}
}
type TurnstileWidgetProps = {
siteKey: string
theme: "light" | "dark"
onToken: (token: string) => void
}
let scriptPromise: Promise<void> | null = null
function loadTurnstileScript() {
if (window.turnstile) return Promise.resolve()
if (scriptPromise) return scriptPromise
scriptPromise = new Promise((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>("script[data-turnstile]")
if (existing) {
existing.addEventListener("load", () => resolve(), { once: true })
existing.addEventListener("error", () => reject(new Error("Turnstile script failed")), { once: true })
return
}
const script = document.createElement("script")
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
script.async = true
script.defer = true
script.dataset.turnstile = "true"
script.onload = () => resolve()
script.onerror = () => reject(new Error("Turnstile script failed"))
document.head.appendChild(script)
})
return scriptPromise
}
export function TurnstileWidget({ siteKey, theme, onToken }: TurnstileWidgetProps) {
const id = `turnstile-${useId().replace(/:/g, "")}`
const widgetId = useRef("")
const [failed, setFailed] = useState(false)
useEffect(() => {
if (!siteKey) return undefined
let mounted = true
const renderWidget = async () => {
setFailed(false)
onToken("")
try {
await loadTurnstileScript()
if (!mounted || !window.turnstile) return
if (widgetId.current) window.turnstile.remove(widgetId.current)
widgetId.current = window.turnstile.render(`#${id}`, {
sitekey: siteKey,
theme,
callback: onToken,
})
} catch {
if (mounted) setFailed(true)
}
}
renderWidget()
return () => {
mounted = false
if (widgetId.current && window.turnstile) {
window.turnstile.remove(widgetId.current)
widgetId.current = ""
}
}
}, [id, onToken, siteKey, theme])
if (!siteKey) return null
return (
<div className="turnstile-box">
<div id={id} />
{failed && (
<Button variant="outline" size="sm" onClick={() => window.location.reload()}>
Reload challenge
</Button>
)}
</div>
)
}
+66
View File
@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }
+48
View File
@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
@@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
@@ -0,0 +1,30 @@
import * as React from "react"
import { CheckIcon } from "lucide-react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+158
View File
@@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
@@ -0,0 +1,255 @@
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
@@ -0,0 +1,56 @@
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+190
View File
@@ -0,0 +1,190 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
@@ -0,0 +1,26 @@
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
+143
View File
@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
side === "right" &&
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
side === "left" &&
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
side === "top" &&
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
side === "bottom" &&
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
@@ -0,0 +1,38 @@
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }
@@ -0,0 +1,35 @@
"use client"
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch inline-flex shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-[1.15rem] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+91
View File
@@ -0,0 +1,91 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import App from "./App"
import "./index.css"
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
)
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"noEmit": true
},
"include": ["vite.config.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from "vite"
import react from "@vitejs/plugin-react"
import tailwindcss from "@tailwindcss/vite"
import path from "node:path"
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
proxy: {
"/api": "http://127.0.0.1:8787",
"/open_api": "http://127.0.0.1:8787",
"/user_api": "http://127.0.0.1:8787",
"/telegram": "http://127.0.0.1:8787",
},
},
})
-1
View File
@@ -1,4 +1,3 @@
VITE_API_BASE=https://temp-email-api.xxx.xxx
VITE_DEFAULT_LANG=zh
VITE_CF_WEB_ANALY_TOKEN=
VITE_IS_TELEGRAM=false
-1
View File
@@ -14,7 +14,6 @@
<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>
+10 -10
View File
@@ -1,6 +1,6 @@
{
"name": "cloudflare_temp_email",
"version": "1.13.0",
"version": "1.11.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.20.0",
"dompurify": "^3.4.15",
"jszip": "^3.10.2",
"axios": "^1.19.0",
"dompurify": "^3.4.13",
"jszip": "^3.10.1",
"mail-parser-wasm": "^0.2.2",
"naive-ui": "^2.45.3",
"postal-mime": "^2.7.6",
"naive-ui": "^2.44.1",
"postal-mime": "^2.7.5",
"vooks": "^0.2.12",
"vue": "^3.5.42",
"vue": "^3.5.41",
"vue-clipboard3": "^2.0.0",
"vue-i18n": "^11.4.10",
"vue-i18n": "^11.4.8",
"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.11",
"vitest": "^4.1.10",
"workbox-build": "^7.4.1",
"workbox-window": "^7.4.1",
"wrangler": "^4.131.1"
"wrangler": "^4.119.0"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
}
+664 -939
View File
File diff suppressed because it is too large Load Diff
+10 -12
View File
@@ -3,7 +3,7 @@ import {
darkTheme,
} from 'naive-ui'
import { computed, onMounted, watchEffect } from 'vue'
import { useHead } from '@unhead/vue'
import { useScript } from '@unhead/vue'
import { useI18n } from 'vue-i18n'
import { useGlobalState } from './store'
import { useIsMobile } from './utils/composables'
@@ -12,13 +12,12 @@ 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 = APP_CONFIG.GOOGLE_AD_CLIENT;
const adSlot = APP_CONFIG.GOOGLE_AD_SLOT;
const adClient = import.meta.env.VITE_GOOGLE_AD_CLIENT;
const adSlot = import.meta.env.VITE_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))
@@ -32,13 +31,12 @@ watchEffect(() => {
document.documentElement.lang = isSupportedLocale(locale.value) ? locale.value : DEFAULT_LOCALE
})
// Load Google Ad script at top level (not inside onMounted)
if (showAd.value) {
useHead({
script: [{
src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`,
async: true,
crossorigin: 'anonymous',
}],
useScript({
src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`,
async: true,
crossorigin: "anonymous",
})
}
@@ -49,7 +47,7 @@ onMounted(async () => {
console.error(error);
}
const token = APP_CONFIG.CF_WEB_ANALY_TOKEN;
const token = import.meta.env.VITE_CF_WEB_ANALY_TOKEN;
const exist = document.querySelector('script[src="https://static.cloudflareinsights.com/beacon.min.js"]') !== null
if (token && !exist) {
@@ -68,7 +66,7 @@ onMounted(async () => {
// check if telegram is enabled
const enableTelegram = APP_CONFIG.IS_TELEGRAM;
const enableTelegram = import.meta.env.VITE_IS_TELEGRAM;
if (
(typeof enableTelegram === 'boolean' && enableTelegram === true)
||
-71
View File
@@ -1,71 +0,0 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const setRuntimeConfig = (config) => {
const element = document.createElement('script')
element.id = 'app-config'
element.type = 'application/json'
element.textContent = JSON.stringify(config)
document.head.appendChild(element)
}
describe('APP_CONFIG', () => {
beforeEach(() => {
vi.resetModules()
vi.stubEnv('VITE_API_BASE', 'https://build.example.com')
vi.stubEnv('VITE_DEFAULT_LANG', 'zh')
vi.stubEnv('VITE_IS_TELEGRAM', 'false')
})
afterEach(() => {
document.querySelector('#app-config')?.remove()
vi.unstubAllEnvs()
})
it('uses build settings when runtime settings are absent', async () => {
const { APP_CONFIG } = await import('../config')
expect(APP_CONFIG.API_BASE).toBe('https://build.example.com')
expect(APP_CONFIG.DEFAULT_LANG).toBe('zh')
})
it('overrides only settings provided by index.html', async () => {
setRuntimeConfig({ API_BASE: 'https://runtime.example.com', DEFAULT_LANG: 'en' })
const { APP_CONFIG } = await import('../config')
expect(APP_CONFIG.API_BASE).toBe('https://runtime.example.com')
expect(APP_CONFIG.DEFAULT_LANG).toBe('en')
})
it('allows an explicit empty runtime value', async () => {
setRuntimeConfig({ API_BASE: '' })
const { APP_CONFIG } = await import('../config')
expect(APP_CONFIG.API_BASE).toBe('')
expect(APP_CONFIG.DEFAULT_LANG).toBe('zh')
})
it('falls back to build settings for invalid runtime value types', async () => {
setRuntimeConfig({ API_BASE: {}, DEFAULT_LANG: 1, IS_TELEGRAM: [] })
const { APP_CONFIG } = await import('../config')
expect(APP_CONFIG.API_BASE).toBe('https://build.example.com')
expect(APP_CONFIG.DEFAULT_LANG).toBe('zh')
expect(APP_CONFIG.IS_TELEGRAM).toBe('false')
})
it('reads runtime settings only once', async () => {
setRuntimeConfig({ DEFAULT_LANG: 'en' })
const firstImport = await import('../config')
document.querySelector('#app-config').textContent = JSON.stringify({ DEFAULT_LANG: 'de' })
const secondImport = await import('../config')
expect(secondImport.APP_CONFIG).toBe(firstImport.APP_CONFIG)
expect(secondImport.APP_CONFIG.DEFAULT_LANG).toBe('en')
})
})
-15
View File
@@ -1,15 +0,0 @@
export const ErrorCode = {
INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
AUTH_SITE_PASSWORD_INVALID: 'AUTH_SITE_PASSWORD_INVALID',
AUTH_ADMIN_CREDENTIAL_INVALID: 'AUTH_ADMIN_CREDENTIAL_INVALID',
AUTH_USER_ACCESS_TOKEN_EXPIRED: 'AUTH_USER_ACCESS_TOKEN_EXPIRED',
isSiteAuthError: (response) => response.status === 401
&& response.data?.code === ErrorCode.AUTH_SITE_PASSWORD_INVALID,
isUserAccessTokenError: (response) => response.status === 401
&& response.data?.code === ErrorCode.AUTH_USER_ACCESS_TOKEN_EXPIRED,
isAdminAuthError: (response) => response.status === 401
&& response.data?.code === ErrorCode.AUTH_ADMIN_CREDENTIAL_INVALID,
};
+8 -25
View File
@@ -6,11 +6,8 @@ import i18n from '../i18n'
import { getFingerprint } from '../utils/fingerprint'
import { 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'
const API_BASE = APP_CONFIG.API_BASE || "";
const API_BASE = import.meta.env.VITE_API_BASE || "";
const {
loading, auth, jwt, settings, openSettings,
userOpenSettings, userSettings, announcement,
@@ -23,18 +20,8 @@ const instance = axios.create({
validateStatus: (status) => status >= 200 && status <= 500
});
const responseInterceptors = [createUserAccessTokenInterceptor(instance)];
const interceptResponse = async (path, response) => {
for (const { matches, handle } of responseInterceptors) {
if (matches(path, response)) return await handle(response);
}
return response;
};
const apiFetch = async (path, options = {}) => {
const showLoading = options.showLoading !== false;
if (showLoading) loading.value = true;
loading.value = true;
try {
// Get browser fingerprint for request tracking
const fingerprint = await getFingerprint();
@@ -58,30 +45,29 @@ const apiFetch = async (path, options = {}) => {
const authorizationHeader = safeBearerHeader(jwt.value);
if (authorizationHeader) headers['Authorization'] = authorizationHeader;
const initialResponse = await instance.request(path, {
const response = await instance.request(path, {
method: options.method || 'GET',
data: options.body || null,
headers,
});
const response = await interceptResponse(path, initialResponse);
if (ErrorCode.isAdminAuthError(response)) {
if (response.status === 401 && path.startsWith("/admin")) {
showAdminAuth.value = true;
}
if (ErrorCode.isSiteAuthError(response)) {
if (response.status === 401 && openSettings.value.needAuth) {
showAuth.value = true;
}
if (response.status >= 300) {
throw new Error(`[${response.status}]: ${response.data?.message || response.data}`);
throw new Error(`[${response.status}]: ${response.data}` || "error");
}
const data = response.data;
return data;
} catch (error) {
if (error.response) {
throw new Error(`Code ${error.response.status}: ${error.response.data?.message || error.response.data}`);
throw new Error(`Code ${error.response.status}: ${error.response.data}` || "error");
}
throw error;
} finally {
if (showLoading) loading.value = false;
loading.value = false;
}
}
@@ -113,7 +99,6 @@ 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,
@@ -123,8 +108,6 @@ const getOpenSettings = async (message, notification) => {
showGithubForUser: res["showGithubForUser"] ?? openSettings.value.showGithubForUser,
enableAddressPassword: res["enableAddressPassword"] || false,
enableAgentEmailInfo: res["enableAgentEmailInfo"] || false,
enableRedeemCode: res["enableRedeemCode"] || false,
redeemCodeUrl: res["redeemCodeUrl"] || "",
smtpImapProxyConfig: res["smtpImapProxyConfig"] || openSettings.value.smtpImapProxyConfig,
statusUrl: res["statusUrl"] || "",
enableGlobalTurnstileCheck: res["enableGlobalTurnstileCheck"] || false,
@@ -1,77 +0,0 @@
import { AxiosHeaders } from 'axios';
import { useGlobalState } from '../store';
import { safeBearerHeader, safeHeaderValue } from '../utils/headers';
import { ErrorCode } from './error-codes';
const { userJwt, userSettings, jwt } = useGlobalState();
const mailboxPaths = new Set(['/api/settings', '/api/send_mail']);
const paths = ['/admin/', ...mailboxPaths, '/user_api/bind_address', '/user_api/address/'];
const getPathname = (url) => new URL(url, window.location.origin).pathname;
const isCurrentSession = ({ headers }) =>
safeHeaderValue(headers.get('x-user-token')) === safeHeaderValue(userJwt.value)
&& safeHeaderValue(headers.get('Authorization')) === safeBearerHeader(jwt.value);
const matches = (path, response) => {
if (!ErrorCode.isUserAccessTokenError(response)) return false;
const pathname = getPathname(path);
if (!paths.some(prefix => pathname.startsWith(prefix))) return false;
const { config } = response;
if (!isCurrentSession(config)) return false;
if (!safeHeaderValue(config.headers.get('x-user-access-token'))) return false;
const token = safeHeaderValue(config.headers.get('x-user-token'));
return Boolean(token) || mailboxPaths.has(pathname);
};
async function loadUserSettings(token, client, headers) {
const response = await client.get('/user_api/settings', { headers });
if (response.status >= 300) {
throw new Error(`[${response.status}]: ${response.data?.message || response.data}`);
}
if (safeHeaderValue(userJwt.value) === token) {
Object.assign(userSettings.value, response.data);
}
}
export const createUserAccessTokenInterceptor = (client) => {
const pendingRefreshes = new Map();
async function refreshUserSettings(token, headers) {
if (pendingRefreshes.has(token)) return await pendingRefreshes.get(token);
const request = loadUserSettings(token, client, headers);
pendingRefreshes.set(token, request);
try {
await request;
} finally {
pendingRefreshes.delete(token);
}
}
async function resolveAccessToken(config) {
const token = safeHeaderValue(config.headers.get('x-user-token'));
if (!token) return;
const currentToken = safeHeaderValue(userSettings.value.access_token);
if (currentToken !== safeHeaderValue(config.headers.get('x-user-access-token'))) return currentToken;
try {
await refreshUserSettings(token, config.headers);
} catch (error) {
if (!mailboxPaths.has(getPathname(config.url))) throw error;
return;
}
return safeHeaderValue(userSettings.value.access_token);
}
return {
matches,
handle: async ({ config }) => {
const accessToken = await resolveAccessToken(config);
if (!isCurrentSession(config)) throw new Error('User session changed, please retry');
const headers = new AxiosHeaders(config.headers);
headers.delete('x-user-access-token');
if (accessToken) headers.set('x-user-access-token', accessToken);
return await client.request({ ...config, headers });
},
};
};
@@ -1,311 +0,0 @@
<script setup>
import { computed } from 'vue'
import { useScopedI18n } from '@/i18n/app'
import { APP_CONFIG } from '@/config'
import { useGlobalState } from '../store'
const props = defineProps({
address: {
type: String,
default: '',
},
jwt: {
type: String,
default: '',
},
addressPassword: {
type: String,
default: '',
},
})
const { openSettings, auth } = useGlobalState()
const { locale, t } = useScopedI18n('components.AddressCredentialModal')
const message = useMessage()
const configuredApiBaseUrl = APP_CONFIG.API_BASE || ''
const frontendBaseUrl = computed(() => window.location.origin)
const apiBaseUrl = computed(() => (configuredApiBaseUrl || frontendBaseUrl.value).replace(/\/$/, ''))
const docLocale = computed(() => locale.value === 'zh' ? 'zh' : 'en')
const agentDocUrl = computed(() => `https://temp-mail-docs.awsl.uk/${docLocale.value}/guide/feature/agent-email.html`)
const smtpImapDocUrl = computed(() => `https://temp-mail-docs.awsl.uk/${docLocale.value}/guide/feature/config-smtp-proxy.html`)
const agentSkillUrl = 'https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/skills/cf-temp-mail-agent-mail/SKILL.md'
const autoLoginUrl = computed(() => `${frontendBaseUrl.value}/?jwt=${encodeURIComponent(props.jwt)}`)
const showAgent = computed(() => !!openSettings.value.enableAgentEmailInfo)
const smtpImapConfig = computed(() => openSettings.value.smtpImapProxyConfig || {})
const smtpConfig = computed(() => smtpImapConfig.value.smtp || {})
const imapConfig = computed(() => smtpImapConfig.value.imap || {})
const showSmtpImap = computed(() => !!smtpConfig.value.host || !!imapConfig.value.host)
const securityLabel = computed(() =>
smtpConfig.value.starttls || imapConfig.value.starttls ? t('starttls') : t('plainOrProxyTls')
)
const agentConfigJson = computed(() => JSON.stringify({
base: apiBaseUrl.value,
jwt: props.jwt,
site_password: auth.value || '',
}, null, 2))
const agentText = computed(() => [
`${t('currentAddress')}: ${props.address || '-'}`,
`${t('apiBase')}: ${apiBaseUrl.value}`,
`${t('agentSkill')}: ${agentSkillUrl}`,
`${t('agentConfig')}:`,
agentConfigJson.value,
].join('\n'))
const smtpImapText = computed(() => [
`${t('smtpHost')}: ${smtpConfig.value.host || '-'}`,
`${t('smtpPort')}: ${smtpConfig.value.port || 8025}`,
`${t('imapHost')}: ${imapConfig.value.host || '-'}`,
`${t('imapPort')}: ${imapConfig.value.port || 11143}`,
`${t('security')}: ${securityLabel.value}`,
`${t('username')}: ${props.address || '-'}`,
`${t('password')}: ${props.jwt}`,
].join('\n'))
const copyText = async (text) => {
if (!text) return
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
message.success(t('copySuccess'))
return
}
const textarea = document.createElement('textarea')
try {
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
if (document.execCommand('copy')) {
message.success(t('copySuccess'))
return
}
message.error(t('copyFailed'))
} finally {
textarea.parentNode?.removeChild(textarea)
}
} catch (error) {
console.error(error)
message.error(t('copyFailed'))
}
}
</script>
<template>
<div class="credential-content">
<n-alert type="info" :show-icon="false" :bordered="false">
{{ t('tip') }}
</n-alert>
<section class="credential-panel">
<h3 class="credential-title">{{ t('addressCredential') }}</h3>
<div class="credential-section">
<div v-if="address" class="credential-field">
<span class="credential-label">{{ t('currentAddress') }}</span>
<div class="credential-copy-row">
<code data-testid="address-credential-address" class="credential-code">{{ address }}</code>
<n-button size="tiny" tertiary type="primary" @click="copyText(address)">
{{ t('copySection') }}
</n-button>
</div>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('addressCredentialLabel') }}</span>
<div class="credential-copy-row">
<code data-testid="address-credential-jwt" class="credential-code">{{ jwt }}</code>
<n-button size="tiny" tertiary type="primary" @click="copyText(jwt)">
{{ t('copySection') }}
</n-button>
</div>
</div>
<div v-if="addressPassword" class="credential-field">
<span class="credential-label">{{ t('addressPassword') }}</span>
<code data-testid="address-credential-password" class="credential-code">{{ addressPassword }}</code>
</div>
</div>
</section>
<n-collapse accordion class="credential-collapse">
<n-collapse-item v-if="showAgent" name="agent" :title="t('agentAccess')">
<template #header-extra>
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(agentText)">
{{ t('copySection') }}
</n-button>
</template>
<div class="credential-section">
<p class="credential-tip">{{ t('agentAccessTip') }}</p>
<div class="credential-field">
<span class="credential-label">{{ t('apiBase') }}</span>
<code class="credential-code">{{ apiBaseUrl }}</code>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('agentSkill') }}</span>
<code class="credential-code">
<a :href="agentSkillUrl" target="_blank" rel="noopener noreferrer">{{ agentSkillUrl }}</a>
</code>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('agentConfig') }}</span>
<pre class="credential-code credential-code-block">{{ agentConfigJson }}</pre>
</div>
<div class="credential-actions">
<n-button tag="a" :href="agentDocUrl" target="_blank" rel="noopener noreferrer" text type="primary">
{{ t('docs') }}
</n-button>
</div>
</div>
</n-collapse-item>
<n-collapse-item v-if="showSmtpImap" name="smtp-imap" :title="t('smtpImapAccess')">
<template #header-extra>
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(smtpImapText)">
{{ t('copySection') }}
</n-button>
</template>
<div class="credential-section">
<p class="credential-tip">{{ t('smtpImapTip') }}</p>
<div class="credential-grid">
<div class="credential-field">
<span class="credential-label">{{ t('smtpHost') }}</span>
<code class="credential-code">{{ smtpConfig.host || '-' }}</code>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('smtpPort') }}</span>
<code class="credential-code">{{ smtpConfig.port || 8025 }}</code>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('imapHost') }}</span>
<code class="credential-code">{{ imapConfig.host || '-' }}</code>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('imapPort') }}</span>
<code class="credential-code">{{ imapConfig.port || 11143 }}</code>
</div>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('security') }}</span>
<code class="credential-code">{{ securityLabel }}</code>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('username') }}</span>
<code class="credential-code">{{ address }}</code>
</div>
<div class="credential-field">
<span class="credential-label">{{ t('password') }}</span>
<code class="credential-code">{{ jwt }}</code>
</div>
<div class="credential-actions">
<n-button tag="a" :href="smtpImapDocUrl" target="_blank" rel="noopener noreferrer" text type="primary">
{{ t('docs') }}
</n-button>
</div>
</div>
</n-collapse-item>
<n-collapse-item name="share-link" :title="t('autoLoginLink')">
<template #header-extra>
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(autoLoginUrl)">
{{ t('copySection') }}
</n-button>
</template>
<div class="credential-section">
<div class="credential-field">
<code class="credential-code">{{ autoLoginUrl }}</code>
</div>
</div>
</n-collapse-item>
</n-collapse>
</div>
</template>
<style scoped>
.credential-collapse {
margin-top: 14px;
}
.credential-panel {
display: grid;
gap: 12px;
margin-top: 14px;
}
.credential-title {
margin: 0;
font-size: 15px;
font-weight: 600;
line-height: 1.4;
}
.credential-section {
display: grid;
gap: 12px;
text-align: left;
}
.credential-tip {
margin: 0;
color: var(--n-text-color-2);
line-height: 1.6;
}
.credential-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.credential-field {
display: grid;
gap: 6px;
min-width: 0;
}
.credential-label {
color: var(--n-text-color-2);
font-size: 12px;
font-weight: 600;
}
.credential-code {
display: block;
min-width: 0;
overflow-wrap: anywhere;
border-radius: 6px;
padding: 6px 8px;
background: var(--n-color-embedded);
font-size: 12px;
line-height: 1.5;
}
.credential-copy-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 8px;
}
.credential-code-block {
margin: 0;
white-space: pre-wrap;
}
.credential-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
@media (max-width: 640px) {
.credential-grid {
grid-template-columns: 1fr;
}
.credential-copy-row {
grid-template-columns: 1fr;
}
}
</style>

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