mirror of
https://github.com/dreamhunter2333/cloudflare_temp_email.git
synced 2026-09-10 09:57:01 +08:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
288634c774 | ||
|
|
066fcfc8c2 | ||
|
|
f88852a353 | ||
|
|
0a0a56e653 | ||
|
|
4ddd502a60 | ||
|
|
406d1956b8 | ||
|
|
66ab5a05df | ||
|
|
8e1bfb8af2 | ||
|
|
fc363cc9c5 | ||
|
|
806ec1aeab | ||
|
|
3c505db9d0 | ||
|
|
5fd181d9c0 | ||
|
|
70206c61ef | ||
|
|
f92b059aac | ||
|
|
5dbb6107dd | ||
|
|
dccca92928 | ||
|
|
005d74bfde |
+22
-2
@@ -6,21 +6,41 @@
|
|||||||
<a href="CHANGELOG_EN.md">English</a>
|
<a href="CHANGELOG_EN.md">English</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## v1.11.1(main)
|
## v1.12.0(main)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
|
- feat: |邮箱登录| 新增仅密码登录开关以禁用旧凭据,邮箱登录 JWT 有效期 30 天、低于 7 天自动续期,支持用户重置已绑定邮箱的密码
|
||||||
|
- feat: |Worker| 新增 `DISABLE_ADDRESS_UPDATED_AT`,可关闭单地址及用户批量的主动保活刷新,并禁止内置手动及定时不活跃地址清理,降低 D1 写入量
|
||||||
|
- feat: |Frontend| 新增 `VITE_DEFAULT_LANG` 构建变量,并支持通过 `index.html` 运行时配置覆盖前端设置
|
||||||
|
- feat: |兑换码| 新增角色、发信额度及专属邮箱兑换与管理,完善并发保护和表单提示
|
||||||
|
- feat: |邮件| 新增可选的已读/未读状态,支持点击邮件自动已读和手动切换状态
|
||||||
- feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限
|
- feat: |Admin| 数据库页面新增 D1 存储容量展示,支持选择并保存 Free 或 Workers Paid 套餐,对比当前数据库大小和容量上限
|
||||||
|
- feat: |Admin| 创建邮箱页面支持一键生成随机邮箱名称(issue #1126)
|
||||||
|
- feat: |Admin API| 新增 `ADMIN_API_IP_WHITELIST`,可按来源 IP 限制所有管理接口访问
|
||||||
|
- feat: |用户系统| 用户中心新增发送邮件、与收件箱一致的可按绑定地址过滤的发件箱,以及地址管理凭证弹框;提供使用用户 JWT 的地址设置、发信权限申请、发信及发件箱 API
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
|
- fix: |邮箱登录| 本地邮箱使用后端 settings 信息缓存两种登录方式,无需前端解码 JWT;Telegram 内部绑定使用不过期 token,与网页邮箱登录 JWT 分离
|
||||||
|
- fix: |邮箱鉴权| 修复旧邮箱凭证仍可访问 API、Telegram 越权解绑、重新绑定失效及外部发信保存凭证的问题,区分认证错误以准确提示站点及管理员登录,并将 E2E 测试接口移出生产代码
|
||||||
|
- fix: |Frontend| 修复 AdSense 脚本包含不受支持的 `data-onload` 和 `data-onerror` 属性
|
||||||
|
- fix: |Admin| 修复权限设置加载完成前短暂显示管理员密码输入框的问题
|
||||||
- fix: |Admin| 修复切换一级标签页时二级标签页偶发无选中项、内容不显示及指示条偏移的问题
|
- fix: |Admin| 修复切换一级标签页时二级标签页偶发无选中项、内容不显示及指示条偏移的问题
|
||||||
|
- fix: |发信页面| 统一邮箱与名称字段顺序,并修复空正文输入框的光标与占位文字错位
|
||||||
|
- fix: |用户发信| 用户地址发信接口支持角色无限额度
|
||||||
|
|
||||||
### Improvements
|
### Improvements
|
||||||
|
|
||||||
|
- fix: |Frontend| 统一邮箱、邮箱地址、用户账号与管理后台的核心界面术语,并补齐附加语言缺失的邮件状态与远程图片文案(issue #1129)
|
||||||
|
- feat: |发信页面| 优化用户和 Admin 发信页面的信息层级与响应式布局,增加正文格式工具栏、草稿状态、底部发送操作区及隔离的 HTML 预览
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
|
- test: |邮箱登录| 覆盖密码登录 JWT 有效期与续期、非法 token 类型,以及用户重置绑定邮箱密码的权限检查
|
||||||
- test: |E2E| 覆盖 D1 数据库大小响应、配置键隔离,以及数据库页面套餐选择的持久化与刷新恢复
|
- test: |E2E| 覆盖 D1 数据库大小响应、配置键隔离,以及数据库页面套餐选择的持久化与刷新恢复
|
||||||
|
- fix: |E2E| 覆盖发信页面草稿编辑、正文格式切换及 HTML 预览
|
||||||
|
- fix: |E2E| 覆盖用户 JWT 发信接口的地址归属、额度扣减、实际投递和发件箱操作,以及用户中心查看地址凭证、切换发件地址和按地址过滤发件箱的完整流程
|
||||||
|
|
||||||
## v1.11.0
|
## v1.11.0
|
||||||
|
|
||||||
@@ -226,7 +246,7 @@
|
|||||||
|
|
||||||
- test: |E2E| 新增 Docker 化端到端测试环境(Playwright + Mailpit),`cd e2e && npm test` 一条命令运行
|
- test: |E2E| 新增 Docker 化端到端测试环境(Playwright + Mailpit),`cd e2e && npm test` 一条命令运行
|
||||||
- test: |E2E| 覆盖 API 健康检查、地址生命周期、SMTP 发信、收件箱 UI、回复 HTML 邮件及 XSS 防护
|
- test: |E2E| 覆盖 API 健康检查、地址生命周期、SMTP 发信、收件箱 UI、回复 HTML 邮件及 XSS 防护
|
||||||
- test: |Worker| 新增 `/admin/test/seed_mail` 测试端点,仅 `E2E_TEST_MODE` 启用时可用
|
- test: |Worker| 新增 `/admin/test/seed_mail` 测试端点
|
||||||
|
|
||||||
### Improvements
|
### Improvements
|
||||||
|
|
||||||
|
|||||||
+22
-2
@@ -6,21 +6,41 @@
|
|||||||
<a href="CHANGELOG_EN.md">English</a>
|
<a href="CHANGELOG_EN.md">English</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## v1.11.1(main)
|
## v1.12.0(main)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
|
- feat: |Mailbox Login| Add a password-only switch that rejects legacy credentials, 30-day mailbox login JWTs renewed with less than 7 days remaining, and password reset for bound mailboxes
|
||||||
|
- feat: |Worker| Add `DISABLE_ADDRESS_UPDATED_AT` to disable individual and user-wide address activity keep-alive updates and built-in manual/scheduled inactive-address cleanup, reducing D1 writes
|
||||||
|
- feat: |Frontend| Add the `VITE_DEFAULT_LANG` build variable and support overriding frontend settings through runtime configuration in `index.html`
|
||||||
|
- feat: |Redemption Codes| Add role, sending-credit and custom-mailbox redemption with Admin management, concurrency protection and form validation
|
||||||
|
- feat: |Mail| Add optional read/unread status with click-to-read and manual status switching
|
||||||
- feat: |Admin| Add D1 storage capacity details to the database page, with persistent Free and Workers Paid plan selection and a comparison between the current database size and capacity limit
|
- feat: |Admin| Add D1 storage capacity details to the database page, with persistent Free and Workers Paid plan selection and a comparison between the current database size and capacity limit
|
||||||
|
- feat: |Admin| Add one-click random email-name generation to the address creation page (issue #1126)
|
||||||
|
- feat: |Admin API| Add `ADMIN_API_IP_WHITELIST` to restrict all admin endpoints by source IP
|
||||||
|
- feat: |User| Add mail composition, inbox-style sent-item filtering by bound address, and the shared address-credentials dialog to the user center, backed by User JWT APIs for address settings, send-access requests, sending, and sent-item management
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
|
- fix: |Mailbox Login| Cache both login methods using backend settings without decoding JWTs in the frontend; use non-expiring Telegram binding tokens independently of web mailbox login JWTs
|
||||||
|
- fix: |Mailbox Auth| Fix stale mailbox credentials retaining API access, unauthorized Telegram unbinding, ineffective rebinding and credential storage in external sent mail; distinguish authentication errors to prompt for site and Admin login correctly; move E2E test endpoints out of production code
|
||||||
|
- fix: |Frontend| Remove unsupported `data-onload` and `data-onerror` attributes from the AdSense script
|
||||||
|
- fix: |Admin| Avoid briefly showing the Admin password dialog before access settings finish loading
|
||||||
- fix: |Admin| Fix secondary tabs occasionally losing their active item, hiding content, and leaving the indicator offset after switching primary tabs
|
- fix: |Admin| Fix secondary tabs occasionally losing their active item, hiding content, and leaving the indicator offset after switching primary tabs
|
||||||
|
- fix: |Send Mail| Use a consistent address/name field order and align the empty content editor caret with its placeholder
|
||||||
|
- fix: |User Send Mail| Apply role-based unlimited sending to user-address APIs
|
||||||
|
|
||||||
### Improvements
|
### Improvements
|
||||||
|
|
||||||
|
- fix: |Frontend| Unify core mailbox, email-address, user-account, and Admin Console terminology, and fill missing additional-locale strings for mail status and remote images (issue #1129)
|
||||||
|
- feat: |Send Mail| Improve the information hierarchy and responsive layout of the user and Admin composers, with a content-format toolbar, draft status, bottom send-action area, and isolated HTML preview
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
|
- test: |Mailbox Login| Cover password JWT lifetime and renewal, invalid token types, and authorization for bound mailbox password resets
|
||||||
- test: |E2E| Cover the D1 database-size response, config-key isolation, and persistence of the database-page plan selection across reloads
|
- test: |E2E| Cover the D1 database-size response, config-key isolation, and persistence of the database-page plan selection across reloads
|
||||||
|
- fix: |E2E| Cover draft editing, content-format switching, and HTML preview in the send-mail composer
|
||||||
|
- fix: |E2E| Cover address ownership, balance decrement, delivery, and sent-item operations through the User JWT API, plus user-center credential display, sender switching, and sent-item filtering by address
|
||||||
|
|
||||||
## v1.11.0
|
## v1.11.0
|
||||||
|
|
||||||
@@ -226,7 +246,7 @@
|
|||||||
|
|
||||||
- test: |E2E| Add Dockerized E2E test environment (Playwright + Mailpit), run with `cd e2e && npm test`
|
- test: |E2E| Add Dockerized E2E test environment (Playwright + Mailpit), run with `cd e2e && npm test`
|
||||||
- test: |E2E| Cover API health check, address lifecycle, SMTP send, inbox UI, HTML reply & XSS sanitization
|
- test: |E2E| Cover API health check, address lifecycle, SMTP send, inbox UI, HTML reply & XSS sanitization
|
||||||
- test: |Worker| Add `/admin/test/seed_mail` test endpoint, only available when `E2E_TEST_MODE` is enabled
|
- test: |Worker| Add `/admin/test/seed_mail` test endpoint
|
||||||
|
|
||||||
### Improvements
|
### Improvements
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE raw_mails ADD COLUMN is_unread INTEGER;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS redeem_codes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
code TEXT UNIQUE NOT NULL,
|
||||||
|
redeem_type TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
result TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
redeemed INTEGER NOT NULL DEFAULT 0 CHECK (redeemed IN (0, 1)),
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
redeemed_at DATETIME,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_redeem_codes_type ON redeem_codes(redeem_type);
|
||||||
@@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS raw_mails (
|
|||||||
raw TEXT,
|
raw TEXT,
|
||||||
raw_blob BLOB,
|
raw_blob BLOB,
|
||||||
metadata TEXT,
|
metadata TEXT,
|
||||||
|
is_unread INTEGER,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -119,3 +120,19 @@ CREATE TABLE IF NOT EXISTS user_passkeys (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_user_passkeys_user_id ON user_passkeys(user_id);
|
CREATE INDEX IF NOT EXISTS idx_user_passkeys_user_id ON user_passkeys(user_id);
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_passkeys_user_id_passkey_id ON user_passkeys(user_id, passkey_id);
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_passkeys_user_id_passkey_id ON user_passkeys(user_id, passkey_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS redeem_codes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
code TEXT UNIQUE NOT NULL,
|
||||||
|
redeem_type TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
result TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
redeemed INTEGER NOT NULL DEFAULT 0 CHECK (redeemed IN (0, 1)),
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
redeemed_at DATETIME,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_redeem_codes_type ON redeem_codes(redeem_type);
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# Keep this version in sync with @playwright/test in package.json
|
# Keep this version in sync with @playwright/test in package.json
|
||||||
FROM mcr.microsoft.com/playwright:v1.62.1-noble
|
FROM mcr.microsoft.com/playwright:v1.63.0-noble
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y curl netcat-openbsd && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y curl netcat-openbsd && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ config.server = {\
|
|||||||
"/admin": { target: workerTarget, changeOrigin: true },\
|
"/admin": { target: workerTarget, changeOrigin: true },\
|
||||||
"/user_api": { target: workerTarget, changeOrigin: true },\
|
"/user_api": { target: workerTarget, changeOrigin: true },\
|
||||||
"/open_api": { target: workerTarget, changeOrigin: true },\
|
"/open_api": { target: workerTarget, changeOrigin: true },\
|
||||||
|
"/redeem_api": { target: workerTarget, changeOrigin: true },\
|
||||||
"/external": { target: workerTarget, changeOrigin: true },\
|
"/external": { target: workerTarget, changeOrigin: true },\
|
||||||
"/health_check": { target: workerTarget, changeOrigin: true },\
|
"/health_check": { target: workerTarget, changeOrigin: true },\
|
||||||
},\
|
},\
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ COPY worker/patches/ patches/
|
|||||||
RUN pnpm install --frozen-lockfile || (echo "WARN: frozen-lockfile failed, falling back to pnpm install" && pnpm install)
|
RUN pnpm install --frozen-lockfile || (echo "WARN: frozen-lockfile failed, falling back to pnpm install" && pnpm install)
|
||||||
|
|
||||||
COPY worker/src/ src/
|
COPY worker/src/ src/
|
||||||
|
COPY e2e/fixtures/*.ts /app/e2e/fixtures/
|
||||||
|
RUN ln -s /app/worker/node_modules /app/e2e/node_modules
|
||||||
COPY worker/tsconfig.json ./
|
COPY worker/tsconfig.json ./
|
||||||
ARG WRANGLER_TOML=e2e/fixtures/wrangler.toml.e2e
|
ARG WRANGLER_TOML=e2e/fixtures/wrangler.toml.e2e
|
||||||
COPY ${WRANGLER_TOML} wrangler.toml
|
COPY ${WRANGLER_TOML} wrangler.toml
|
||||||
|
|
||||||
EXPOSE 8787
|
EXPOSE 8787
|
||||||
|
|
||||||
CMD ["pnpm", "exec", "wrangler", "dev", "--port", "8787", "--ip", "0.0.0.0"]
|
CMD ["pnpm", "exec", "wrangler", "dev", "--port", "8787", "--ip", "0.0.0.0", "--test-scheduled"]
|
||||||
|
|||||||
+2
-1
@@ -51,7 +51,8 @@ Test results and HTML reports are exported via volumes:
|
|||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The E2E worker uses `fixtures/wrangler.toml.e2e` with:
|
The E2E worker uses `fixtures/wrangler.toml.e2e` with:
|
||||||
- `E2E_TEST_MODE = true` — enables test seed endpoint
|
|
||||||
- `DISABLE_ADMIN_PASSWORD_CHECK = true` — allows unauthenticated admin calls
|
- `DISABLE_ADMIN_PASSWORD_CHECK = true` — allows unauthenticated admin calls
|
||||||
- `DEFAULT_SEND_BALANCE = 10` — allows sending without admin approval
|
- `DEFAULT_SEND_BALANCE = 10` — allows sending without admin approval
|
||||||
- SMTP pointed at Mailpit container (`mailpit:1025`)
|
- SMTP pointed at Mailpit container (`mailpit:1025`)
|
||||||
|
|
||||||
|
Test-only endpoints under `/__test/*` are registered in `e2e/fixtures/worker.ts` and are excluded from the production Worker.
|
||||||
|
|||||||
+38
-1
@@ -42,7 +42,7 @@ services:
|
|||||||
dockerfile: e2e/Dockerfile.worker
|
dockerfile: e2e/Dockerfile.worker
|
||||||
ports:
|
ports:
|
||||||
- "8790:8790"
|
- "8790:8790"
|
||||||
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8790", "--ip", "0.0.0.0"]
|
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8790", "--ip", "0.0.0.0", "--test-scheduled"]
|
||||||
volumes:
|
volumes:
|
||||||
- ./fixtures/wrangler.toml.e2e.env-off:/app/worker/wrangler.toml:ro
|
- ./fixtures/wrangler.toml.e2e.env-off:/app/worker/wrangler.toml:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -90,6 +90,24 @@ services:
|
|||||||
start_period: 10s
|
start_period: 10s
|
||||||
retries: 20
|
retries: 20
|
||||||
|
|
||||||
|
worker-site-password:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: e2e/Dockerfile.worker
|
||||||
|
args:
|
||||||
|
WRANGLER_TOML: e2e/fixtures/wrangler.toml.e2e.site-password
|
||||||
|
ports:
|
||||||
|
- "8792:8792"
|
||||||
|
command: ["pnpm", "exec", "wrangler", "dev", "--port", "8792", "--ip", "0.0.0.0"]
|
||||||
|
depends_on:
|
||||||
|
- mailpit
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-sf", "-H", "x-custom-auth: e2e-site-pass", "http://localhost:8792/health_check"]
|
||||||
|
interval: 3s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 10s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
@@ -100,6 +118,19 @@ services:
|
|||||||
worker:
|
worker:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
|
frontend-env-off:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: e2e/Dockerfile.frontend
|
||||||
|
ports:
|
||||||
|
- "5174:5174"
|
||||||
|
command: ["pnpm", "exec", "vite", "--port", "5174", "--host", "0.0.0.0"]
|
||||||
|
environment:
|
||||||
|
VITE_WORKER_URL: http://worker-env-off:8790
|
||||||
|
depends_on:
|
||||||
|
worker-env-off:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
smtp-proxy:
|
smtp-proxy:
|
||||||
build:
|
build:
|
||||||
context: ../smtp_proxy_server
|
context: ../smtp_proxy_server
|
||||||
@@ -147,7 +178,9 @@ services:
|
|||||||
WORKER_URL_ENV_OFF: http://worker-env-off:8790
|
WORKER_URL_ENV_OFF: http://worker-env-off:8790
|
||||||
WORKER_GZIP_URL: http://worker-gzip:8788
|
WORKER_GZIP_URL: http://worker-gzip:8788
|
||||||
WORKER_URL_SEND_MAIL_DOMAIN: http://worker-send-mail-domain:8791
|
WORKER_URL_SEND_MAIL_DOMAIN: http://worker-send-mail-domain:8791
|
||||||
|
WORKER_URL_SITE_PASSWORD: http://worker-site-password:8792
|
||||||
FRONTEND_URL: https://frontend:5173
|
FRONTEND_URL: https://frontend:5173
|
||||||
|
FRONTEND_URL_ENV_OFF: https://frontend-env-off:5174
|
||||||
MAILPIT_API: http://mailpit:8025/api
|
MAILPIT_API: http://mailpit:8025/api
|
||||||
SMTP_PROXY_HOST: smtp-proxy
|
SMTP_PROXY_HOST: smtp-proxy
|
||||||
SMTP_PROXY_SMTP_PORT: "8025"
|
SMTP_PROXY_SMTP_PORT: "8025"
|
||||||
@@ -167,8 +200,12 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
worker-send-mail-domain:
|
worker-send-mail-domain:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
worker-site-password:
|
||||||
|
condition: service_healthy
|
||||||
frontend:
|
frontend:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
frontend-env-off:
|
||||||
|
condition: service_started
|
||||||
smtp-proxy:
|
smtp-proxy:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
smtp-proxy-tls:
|
smtp-proxy-tls:
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export class EmailMessage {
|
||||||
|
constructor(public from: string, public to: string, public raw: string) {}
|
||||||
|
}
|
||||||
@@ -1,37 +1,41 @@
|
|||||||
import { Context } from 'hono'
|
|
||||||
import { getBooleanValue } from '../utils'
|
|
||||||
|
|
||||||
// Direct DB insert — bypasses the email() handler.
|
// Direct DB insert — bypasses the email() handler.
|
||||||
const seedMail = async (c: Context<HonoCustomType>) => {
|
const seedMail = async (request: Request, env: Bindings) => {
|
||||||
if (!getBooleanValue(c.env.E2E_TEST_MODE)) {
|
const { address, source, raw, message_id, created_at, address_updated_at, address_created_at } = await request.json<{
|
||||||
return c.text("Not available", 404);
|
address: string; source?: string; raw: string; message_id?: string;
|
||||||
}
|
created_at?: string; address_updated_at?: string; address_created_at?: string;
|
||||||
const { address, source, raw, message_id } = await c.req.json();
|
}>();
|
||||||
if (!address || !raw) {
|
if (!address || !raw) {
|
||||||
return c.text("address and raw are required", 400);
|
return new Response("address and raw are required", { status: 400 });
|
||||||
}
|
}
|
||||||
if (raw.length > 1_000_000) {
|
if (raw.length > 1_000_000) {
|
||||||
return c.text("raw content too large", 400);
|
return new Response("raw content too large", { status: 400 });
|
||||||
}
|
}
|
||||||
if (message_id && message_id.length > 255) {
|
if (message_id && message_id.length > 255) {
|
||||||
return c.text("message_id too long", 400);
|
return new Response("message_id too long", { status: 400 });
|
||||||
|
}
|
||||||
|
if (address_updated_at !== undefined) {
|
||||||
|
await env.DB.prepare(`UPDATE address SET updated_at = ? WHERE name = ?`)
|
||||||
|
.bind(address_updated_at, address).run();
|
||||||
|
}
|
||||||
|
if (address_created_at !== undefined) {
|
||||||
|
await env.DB.prepare(`UPDATE address SET created_at = ? WHERE name = ?`)
|
||||||
|
.bind(address_created_at, address).run();
|
||||||
}
|
}
|
||||||
const msgId = message_id || `<e2e-${Date.now()}@test>`;
|
const msgId = message_id || `<e2e-${Date.now()}@test>`;
|
||||||
const { success } = await c.env.DB.prepare(
|
const { success } = await env.DB.prepare(
|
||||||
`INSERT INTO raw_mails (message_id, source, address, raw, created_at)`
|
`INSERT INTO raw_mails (message_id, source, address, raw, created_at)`
|
||||||
+ ` VALUES (?, ?, ?, ?, datetime('now'))`
|
+ ` VALUES (?, ?, ?, ?, COALESCE(?, datetime('now')))`
|
||||||
).bind(msgId, source || address, address, raw).run();
|
).bind(msgId, source || address, address, raw, created_at ?? null).run();
|
||||||
return c.json({ success });
|
return Response.json({ success });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Exercises the real email() handler with a mock ForwardableEmailMessage.
|
// Exercises the real email() handler with a mock ForwardableEmailMessage.
|
||||||
const receiveMail = async (c: Context<HonoCustomType>) => {
|
const receiveMail = async (request: Request, env: Bindings, ctx: ExecutionContext) => {
|
||||||
if (!getBooleanValue(c.env.E2E_TEST_MODE)) {
|
const { from, to, raw, ai_extract_result } = await request.json<{
|
||||||
return c.text("Not available", 404);
|
from: string; to: string; raw: string; ai_extract_result?: unknown;
|
||||||
}
|
}>();
|
||||||
const { from, to, raw, ai_extract_result } = await c.req.json();
|
|
||||||
if (!from || !to || !raw) {
|
if (!from || !to || !raw) {
|
||||||
return c.text("from, to and raw are required", 400);
|
return new Response("from, to and raw are required", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse MIME headers (unfold continuation lines, extract key:value pairs)
|
// Parse MIME headers (unfold continuation lines, extract key:value pairs)
|
||||||
@@ -53,24 +57,19 @@ const receiveMail = async (c: Context<HonoCustomType>) => {
|
|||||||
forward: async (recipient: string) => { state.forwardedTo.push(recipient); return { messageId: '' }; },
|
forward: async (recipient: string) => { state.forwardedTo.push(recipient); return { messageId: '' }; },
|
||||||
reply: async () => { state.replyCalled = true; return { messageId: '' }; },
|
reply: async () => { state.replyCalled = true; return { messageId: '' }; },
|
||||||
};
|
};
|
||||||
const { email: emailHandler } = await import('../email');
|
const { email: emailHandler } = await import('../../worker/src/email');
|
||||||
const aiExtractEnvOverrides: Partial<Bindings> = {
|
const aiExtractEnvOverrides: Partial<Bindings> = {
|
||||||
ENABLE_AI_EMAIL_EXTRACT: true,
|
ENABLE_AI_EMAIL_EXTRACT: true,
|
||||||
AI: {
|
AI: {
|
||||||
run: async () => ({ response: ai_extract_result })
|
run: async () => ({ response: ai_extract_result })
|
||||||
} as unknown as Ai,
|
} as unknown as Ai,
|
||||||
};
|
};
|
||||||
const env = ai_extract_result
|
const emailEnv = ai_extract_result
|
||||||
? { ...c.env, ...aiExtractEnvOverrides }
|
? { ...env, ...aiExtractEnvOverrides }
|
||||||
: c.env;
|
: env;
|
||||||
const executionContext: ExecutionContext = {
|
await emailHandler(mockMessage, emailEnv, ctx);
|
||||||
waitUntil: () => {},
|
|
||||||
passThroughOnException: () => {},
|
|
||||||
props: {}
|
|
||||||
};
|
|
||||||
await emailHandler(mockMessage, env, executionContext);
|
|
||||||
|
|
||||||
return c.json({
|
return Response.json({
|
||||||
success: !state.rejected,
|
success: !state.rejected,
|
||||||
replyCalled: state.replyCalled,
|
replyCalled: state.replyCalled,
|
||||||
forwardedTo: state.forwardedTo,
|
forwardedTo: state.forwardedTo,
|
||||||
@@ -7,7 +7,9 @@ export const WORKER_URL_SUBDOMAIN = process.env.WORKER_URL_SUBDOMAIN || '';
|
|||||||
export const WORKER_URL_ENV_OFF = process.env.WORKER_URL_ENV_OFF || '';
|
export const WORKER_URL_ENV_OFF = process.env.WORKER_URL_ENV_OFF || '';
|
||||||
export const WORKER_GZIP_URL = process.env.WORKER_GZIP_URL || '';
|
export const WORKER_GZIP_URL = process.env.WORKER_GZIP_URL || '';
|
||||||
export const WORKER_URL_SEND_MAIL_DOMAIN = process.env.WORKER_URL_SEND_MAIL_DOMAIN || '';
|
export const WORKER_URL_SEND_MAIL_DOMAIN = process.env.WORKER_URL_SEND_MAIL_DOMAIN || '';
|
||||||
|
export const WORKER_URL_SITE_PASSWORD = process.env.WORKER_URL_SITE_PASSWORD || '';
|
||||||
export const FRONTEND_URL = process.env.FRONTEND_URL!;
|
export const FRONTEND_URL = process.env.FRONTEND_URL!;
|
||||||
|
export const FRONTEND_URL_ENV_OFF = process.env.FRONTEND_URL_ENV_OFF || '';
|
||||||
export const MAILPIT_API = process.env.MAILPIT_API!;
|
export const MAILPIT_API = process.env.MAILPIT_API!;
|
||||||
export const TEST_DOMAIN = 'test.example.com';
|
export const TEST_DOMAIN = 'test.example.com';
|
||||||
|
|
||||||
@@ -27,10 +29,11 @@ export function hashPassword(password: string): string {
|
|||||||
export async function createTestAddress(
|
export async function createTestAddress(
|
||||||
ctx: APIRequestContext,
|
ctx: APIRequestContext,
|
||||||
name: string,
|
name: string,
|
||||||
domain: string = TEST_DOMAIN
|
domain: string = TEST_DOMAIN,
|
||||||
|
workerUrl: string = WORKER_URL,
|
||||||
): Promise<{ jwt: string; address: string; address_id: number }> {
|
): Promise<{ jwt: string; address: string; address_id: number }> {
|
||||||
const uniqueName = `${name}${Date.now()}`;
|
const uniqueName = `${name}${Date.now()}`;
|
||||||
const res = await ctx.post(`${WORKER_URL}/api/new_address`, {
|
const res = await ctx.post(`${workerUrl}/api/new_address`, {
|
||||||
data: { name: uniqueName, domain },
|
data: { name: uniqueName, domain },
|
||||||
});
|
});
|
||||||
if (!res.ok()) {
|
if (!res.ok()) {
|
||||||
@@ -75,7 +78,7 @@ export async function seedTestMail(
|
|||||||
`--${boundary}--`,
|
`--${boundary}--`,
|
||||||
].join('\r\n');
|
].join('\r\n');
|
||||||
|
|
||||||
const res = await ctx.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
const res = await ctx.post(`${WORKER_URL}/__test/receive_mail`, {
|
||||||
data: { from, to: address, raw },
|
data: { from, to: address, raw },
|
||||||
});
|
});
|
||||||
if (!res.ok()) {
|
if (!res.ok()) {
|
||||||
@@ -164,14 +167,20 @@ export function onMailpitMessage(
|
|||||||
ws.on('open', () => readyResolve());
|
ws.on('open', () => readyResolve());
|
||||||
|
|
||||||
ws.on('message', (data: WebSocket.Data) => {
|
ws.on('message', (data: WebSocket.Data) => {
|
||||||
try {
|
// Mailpit may batch multiple newline-separated events in one message.
|
||||||
const event = JSON.parse(data.toString());
|
for (const line of data.toString().split('\n')) {
|
||||||
if (event.Type === 'new' && predicate(event.Data)) {
|
if (settled) return;
|
||||||
clearTimeout(timer);
|
try {
|
||||||
ws.close();
|
const event = JSON.parse(line);
|
||||||
if (!settled) { settled = true; resolve(event.Data); }
|
if (event.Type === 'new' && predicate(event.Data)) {
|
||||||
}
|
clearTimeout(timer);
|
||||||
} catch { /* ignore parse errors */ }
|
settled = true;
|
||||||
|
ws.close();
|
||||||
|
resolve(event.Data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch { /* ignore malformed events without dropping the remaining ones */ }
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import worker from '../../worker/src/worker';
|
||||||
|
import { CONSTANTS } from '../../worker/src/constants';
|
||||||
|
import mailApi from './mail-api';
|
||||||
|
|
||||||
|
const testApi = new Hono<HonoCustomType>();
|
||||||
|
testApi.post('/seed_mail', c => mailApi.seedMail(c.req.raw, c.env));
|
||||||
|
testApi.post('/receive_mail', c => mailApi.receiveMail(c.req.raw, c.env, c.executionCtx as ExecutionContext));
|
||||||
|
testApi.get('/telegram_binding', async c => {
|
||||||
|
const address = c.req.query('address');
|
||||||
|
if (!address) return c.text('address is required', 400);
|
||||||
|
return c.json(await c.env.KV.get(`${CONSTANTS.TG_KV_PREFIX}:${address}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = new Hono<HonoCustomType>();
|
||||||
|
app.route('/__test', testApi);
|
||||||
|
app.all('*', c => worker.fetch(c.req.raw, c.env, c.executionCtx));
|
||||||
|
|
||||||
|
export default { ...worker, fetch: app.fetch };
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
name = "cloudflare_temp_email"
|
name = "cloudflare_temp_email"
|
||||||
main = "src/worker.ts"
|
main = "../e2e/fixtures/worker.ts"
|
||||||
compatibility_date = "2025-04-01"
|
compatibility_date = "2025-04-01"
|
||||||
compatibility_flags = [ "nodejs_compat" ]
|
compatibility_flags = [ "nodejs_compat" ]
|
||||||
keep_vars = true
|
keep_vars = true
|
||||||
@@ -15,16 +15,21 @@ USER_ROLES = [
|
|||||||
{ domains = [], role = "empty-role", prefix = "EMPTY" },
|
{ domains = [], role = "empty-role", prefix = "EMPTY" },
|
||||||
]
|
]
|
||||||
JWT_SECRET = "e2e-test-secret-key"
|
JWT_SECRET = "e2e-test-secret-key"
|
||||||
|
TELEGRAM_BOT_TOKEN = "e2e-telegram-test-token"
|
||||||
BLACK_LIST = ""
|
BLACK_LIST = ""
|
||||||
ENABLE_USER_CREATE_EMAIL = true
|
ENABLE_USER_CREATE_EMAIL = true
|
||||||
ENABLE_USER_DELETE_EMAIL = true
|
ENABLE_USER_DELETE_EMAIL = true
|
||||||
|
ENABLE_MAIL_READ_STATUS = true
|
||||||
|
ENABLE_REDEEM_CODE = true
|
||||||
|
REDEEM_CODE_URL = "https://example.com/redeem-codes"
|
||||||
ENABLE_AUTO_REPLY = true
|
ENABLE_AUTO_REPLY = true
|
||||||
DEFAULT_SEND_BALANCE = 10
|
DEFAULT_SEND_BALANCE = 10
|
||||||
|
NO_LIMIT_SEND_ROLE = "case-role"
|
||||||
ENABLE_ADDRESS_PASSWORD = true
|
ENABLE_ADDRESS_PASSWORD = true
|
||||||
DISABLE_ADMIN_PASSWORD_CHECK = true
|
DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||||
|
ADMIN_USER_ROLE = "admin"
|
||||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||||
ENABLE_WEBHOOK = true
|
ENABLE_WEBHOOK = true
|
||||||
E2E_TEST_MODE = true
|
|
||||||
CLEANUP_BATCH_SIZE = 10
|
CLEANUP_BATCH_SIZE = 10
|
||||||
SMTP_CONFIG = """
|
SMTP_CONFIG = """
|
||||||
{"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}}
|
{"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}}
|
||||||
@@ -38,3 +43,6 @@ id = "e2e-test-kv-00000000-0000-0000-0000-000000000000"
|
|||||||
binding = "DB"
|
binding = "DB"
|
||||||
database_name = "e2e-temp-email"
|
database_name = "e2e-temp-email"
|
||||||
database_id = "e2e-test-db-00000000-0000-0000-0000-000000000000"
|
database_id = "e2e-test-db-00000000-0000-0000-0000-000000000000"
|
||||||
|
|
||||||
|
[alias]
|
||||||
|
"cloudflare:email" = "../e2e/fixtures/email.ts"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name = "cloudflare_temp_email_env_off"
|
name = "cloudflare_temp_email_env_off"
|
||||||
main = "src/worker.ts"
|
main = "../e2e/fixtures/worker.ts"
|
||||||
compatibility_date = "2025-04-01"
|
compatibility_date = "2025-04-01"
|
||||||
compatibility_flags = [ "nodejs_compat" ]
|
compatibility_flags = [ "nodejs_compat" ]
|
||||||
keep_vars = true
|
keep_vars = true
|
||||||
@@ -13,13 +13,14 @@ JWT_SECRET = "e2e-test-secret-key-env-off"
|
|||||||
BLACK_LIST = ""
|
BLACK_LIST = ""
|
||||||
ENABLE_USER_CREATE_EMAIL = true
|
ENABLE_USER_CREATE_EMAIL = true
|
||||||
ENABLE_USER_DELETE_EMAIL = false
|
ENABLE_USER_DELETE_EMAIL = false
|
||||||
|
DISABLE_ADDRESS_UPDATED_AT = true
|
||||||
|
ENABLE_REDEEM_CODE = false
|
||||||
ENABLE_AUTO_REPLY = true
|
ENABLE_AUTO_REPLY = true
|
||||||
DEFAULT_SEND_BALANCE = 10
|
DEFAULT_SEND_BALANCE = 10
|
||||||
ENABLE_ADDRESS_PASSWORD = true
|
ENABLE_ADDRESS_PASSWORD = true
|
||||||
DISABLE_ADMIN_PASSWORD_CHECK = true
|
DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||||
ENABLE_WEBHOOK = true
|
ENABLE_WEBHOOK = true
|
||||||
E2E_TEST_MODE = true
|
|
||||||
SMTP_CONFIG = """
|
SMTP_CONFIG = """
|
||||||
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
|
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
|
||||||
"""
|
"""
|
||||||
@@ -32,3 +33,6 @@ id = "e2e-test-kv-env-off-00000000-0000-0000-0000-000000000000"
|
|||||||
binding = "DB"
|
binding = "DB"
|
||||||
database_name = "e2e-temp-email-env-off"
|
database_name = "e2e-temp-email-env-off"
|
||||||
database_id = "e2e-test-db-env-off-00000000-0000-0000-0000-000000000000"
|
database_id = "e2e-test-db-env-off-00000000-0000-0000-0000-000000000000"
|
||||||
|
|
||||||
|
[alias]
|
||||||
|
"cloudflare:email" = "../e2e/fixtures/email.ts"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name = "cloudflare_temp_email_gzip"
|
name = "cloudflare_temp_email_gzip"
|
||||||
main = "src/worker.ts"
|
main = "../e2e/fixtures/worker.ts"
|
||||||
compatibility_date = "2025-04-01"
|
compatibility_date = "2025-04-01"
|
||||||
compatibility_flags = [ "nodejs_compat" ]
|
compatibility_flags = [ "nodejs_compat" ]
|
||||||
keep_vars = true
|
keep_vars = true
|
||||||
@@ -18,7 +18,6 @@ ENABLE_ADDRESS_PASSWORD = true
|
|||||||
DISABLE_ADMIN_PASSWORD_CHECK = true
|
DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||||
ENABLE_WEBHOOK = true
|
ENABLE_WEBHOOK = true
|
||||||
E2E_TEST_MODE = true
|
|
||||||
ENABLE_MAIL_GZIP = true
|
ENABLE_MAIL_GZIP = true
|
||||||
SMTP_CONFIG = """
|
SMTP_CONFIG = """
|
||||||
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
|
{"test.example.com":{"host":"mailpit","port":1025,"secure":false}}
|
||||||
@@ -32,3 +31,6 @@ id = "e2e-test-kv-gzip-00000000-0000-0000-0000-000000000000"
|
|||||||
binding = "DB"
|
binding = "DB"
|
||||||
database_name = "e2e-temp-email-gzip"
|
database_name = "e2e-temp-email-gzip"
|
||||||
database_id = "e2e-test-db-gzip-00000000-0000-0000-0000-000000000000"
|
database_id = "e2e-test-db-gzip-00000000-0000-0000-0000-000000000000"
|
||||||
|
|
||||||
|
[alias]
|
||||||
|
"cloudflare:email" = "../e2e/fixtures/email.ts"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
name = "cloudflare_temp_email"
|
name = "cloudflare_temp_email"
|
||||||
main = "src/worker.ts"
|
main = "../e2e/fixtures/worker.ts"
|
||||||
compatibility_date = "2025-04-01"
|
compatibility_date = "2025-04-01"
|
||||||
compatibility_flags = [ "nodejs_compat" ]
|
compatibility_flags = [ "nodejs_compat" ]
|
||||||
keep_vars = true
|
keep_vars = true
|
||||||
@@ -23,7 +23,6 @@ ENABLE_ADDRESS_PASSWORD = true
|
|||||||
DISABLE_ADMIN_PASSWORD_CHECK = true
|
DISABLE_ADMIN_PASSWORD_CHECK = true
|
||||||
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||||
ENABLE_WEBHOOK = true
|
ENABLE_WEBHOOK = true
|
||||||
E2E_TEST_MODE = true
|
|
||||||
SMTP_CONFIG = """
|
SMTP_CONFIG = """
|
||||||
{"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}}
|
{"TEST.EXAMPLE.COM":{"host":"mailpit","port":1025,"secure":false}}
|
||||||
"""
|
"""
|
||||||
@@ -36,3 +35,6 @@ id = "e2e-test-kv-00000000-0000-0000-0000-000000000000"
|
|||||||
binding = "DB"
|
binding = "DB"
|
||||||
database_name = "e2e-temp-email"
|
database_name = "e2e-temp-email"
|
||||||
database_id = "e2e-test-db-00000000-0000-0000-0000-000000000000"
|
database_id = "e2e-test-db-00000000-0000-0000-0000-000000000000"
|
||||||
|
|
||||||
|
[alias]
|
||||||
|
"cloudflare:email" = "../e2e/fixtures/email.ts"
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
name = "cloudflare_temp_email_site_password"
|
||||||
|
main = "../e2e/fixtures/worker.ts"
|
||||||
|
compatibility_date = "2025-04-01"
|
||||||
|
compatibility_flags = [ "nodejs_compat" ]
|
||||||
|
keep_vars = true
|
||||||
|
|
||||||
|
[vars]
|
||||||
|
PREFIX = "tmp"
|
||||||
|
DEFAULT_DOMAINS = ["test.example.com"]
|
||||||
|
DOMAINS = ["test.example.com"]
|
||||||
|
USER_ROLES = [
|
||||||
|
{ domains = ["test.example.com"], role = "case-role", prefix = "" },
|
||||||
|
]
|
||||||
|
ADMIN_USER_ROLE = "admin"
|
||||||
|
JWT_SECRET = "e2e-site-password-secret"
|
||||||
|
PASSWORDS = '["e2e-site-pass"]'
|
||||||
|
BLACK_LIST = ""
|
||||||
|
ENABLE_REDEEM_CODE = true
|
||||||
|
ENABLE_ADDRESS_PASSWORD = true
|
||||||
|
ADDRESS_CHECK_REGEX = "^(?!.*blocked).*$"
|
||||||
|
DISABLE_ADMIN_PASSWORD_CHECK = false
|
||||||
|
ADMIN_PASSWORDS = '["e2e-admin-pass"]'
|
||||||
|
|
||||||
|
[[kv_namespaces]]
|
||||||
|
binding = "KV"
|
||||||
|
id = "e2e-test-kv-site-password-00000000-0000-0000-0000-000000000000"
|
||||||
|
|
||||||
|
[[d1_databases]]
|
||||||
|
binding = "DB"
|
||||||
|
database_name = "e2e-temp-email-site-password"
|
||||||
|
database_id = "e2e-test-db-site-password-00000000-0000-0000-0000-000000000000"
|
||||||
|
|
||||||
|
[alias]
|
||||||
|
"cloudflare:email" = "../e2e/fixtures/email.ts"
|
||||||
Generated
+42
-60
@@ -6,13 +6,13 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "cloudflare-temp-email-e2e",
|
"name": "cloudflare-temp-email-e2e",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"imapflow": "^1.7.1",
|
"imapflow": "^1.7.8",
|
||||||
"nodemailer": "^9.0.5"
|
"nodemailer": "^10.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.62.1",
|
"@playwright/test": "1.63.0",
|
||||||
"@types/nodemailer": "^8.0.1",
|
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
|
"hono": "^4.13.7",
|
||||||
"ws": "^8.21.3"
|
"ws": "^8.21.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -23,13 +23,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.62.1",
|
"version": "1.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||||
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.62.1"
|
"playwright": "1.63.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -48,16 +48,6 @@
|
|||||||
"undici-types": "~8.3.0"
|
"undici-types": "~8.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/nodemailer": {
|
|
||||||
"version": "8.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
|
|
||||||
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/ws": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.1",
|
"version": "8.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
@@ -69,13 +59,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@zone-eu/mailsplit": {
|
"node_modules/@zone-eu/mailsplit": {
|
||||||
"version": "5.4.15",
|
"version": "5.4.16",
|
||||||
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.15.tgz",
|
"resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.16.tgz",
|
||||||
"integrity": "sha512-c7ZpxauvF4AEkDJlKDYO7iMUtMuqJMBnDWNff1cyx+d7zaBVR3iFEmXhNHOVoMmVyVF3pTZLsLIJsEFKDldOAA==",
|
"integrity": "sha512-zQ9iXvlT3Wi/hazeC1MdI4rQc1UJwJ6IQ6QzSZ5KDxLZZWQSazWLOzImLFluXadKShJ9WJvI1xH+AyVS8b9azg==",
|
||||||
"license": "(MIT OR EUPL-1.1+)",
|
"license": "(MIT OR EUPL-1.1+)",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"libbase64": "1.3.0",
|
"libbase64": "1.3.0",
|
||||||
"libmime": "5.4.2",
|
"libmime": "5.4.3",
|
||||||
"libqp": "2.1.1"
|
"libqp": "2.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -89,27 +79,22 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/encoding-japanese": {
|
"node_modules/encoding-japanese": {
|
||||||
"version": "2.2.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.3.0.tgz",
|
||||||
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
|
"integrity": "sha512-eQyh1vzHz13DUkZcJO+0IOAoKXRQwKV5IBffeuYsWZyRLGiSzfzXObCqWvqFXdX0UU8qOk+lBXbkUhMCpdJe4Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.10.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/hono": {
|
||||||
"version": "2.3.2",
|
"version": "4.13.7",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz",
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
"integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": ">=16.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/iconv-lite": {
|
"node_modules/iconv-lite": {
|
||||||
@@ -129,16 +114,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/imapflow": {
|
"node_modules/imapflow": {
|
||||||
"version": "1.7.1",
|
"version": "1.7.8",
|
||||||
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.7.8.tgz",
|
||||||
"integrity": "sha512-etpyRH7wOPQGawjl/5ChgaaX5NRK2fxJAi9EFFNwv0N3FXsvjsL3Qu5dJ4i/myH61Cq2RSTb6vI5T9rXDbXD+g==",
|
"integrity": "sha512-dJoCIdZOJh26Rn2PdwEzwj0bRDgGBxxX38pio534FagIHVuR2l0SAfLr6sJo32YfuJHiSs/U2ntNDHnMg3/Hlg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zone-eu/mailsplit": "5.4.15",
|
"@zone-eu/mailsplit": "5.4.16",
|
||||||
"encoding-japanese": "2.2.0",
|
"encoding-japanese": "2.3.0",
|
||||||
"iconv-lite": "0.7.3",
|
"iconv-lite": "0.7.3",
|
||||||
"libbase64": "1.3.0",
|
"libbase64": "1.3.0",
|
||||||
"libmime": "5.4.2",
|
"libmime": "5.4.3",
|
||||||
"libqp": "2.1.1",
|
"libqp": "2.1.1",
|
||||||
"pino": "10.3.1",
|
"pino": "10.3.1",
|
||||||
"socks": "2.8.9"
|
"socks": "2.8.9"
|
||||||
@@ -160,12 +145,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/libmime": {
|
"node_modules/libmime": {
|
||||||
"version": "5.4.2",
|
"version": "5.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.4.3.tgz",
|
||||||
"integrity": "sha512-+IQnCOdPiufGBkOii+Ze8F7iniyBzOwvWDbn1DyExBpc9pT2B3IEMQi7GUc/PpqhNUh/sr1SG9UXDITQoR0VIA==",
|
"integrity": "sha512-di9BoDabBUMqjeD/wGj+hHpSgdqAph5ui7w6OdY6NpzU6O6VFLQsMOg9tqCjm/zf9OHzAM9EZxSOF7uIb8O8Hw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"encoding-japanese": "2.2.0",
|
"encoding-japanese": "2.3.0",
|
||||||
"iconv-lite": "0.7.3",
|
"iconv-lite": "0.7.3",
|
||||||
"libbase64": "1.3.0",
|
"libbase64": "1.3.0",
|
||||||
"libqp": "2.1.1"
|
"libqp": "2.1.1"
|
||||||
@@ -178,12 +163,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nodemailer": {
|
"node_modules/nodemailer": {
|
||||||
"version": "9.0.5",
|
"version": "10.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.0.0.tgz",
|
||||||
"integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
|
"integrity": "sha512-wdv+hXBg0iIPIOf108FaZyTxqjQpCvQeAJt9pHHTooPy/y3vQtI9eiImM9AIVKR3e/0FeMA1oueNfBjFJcaK+Q==",
|
||||||
"license": "MIT-0",
|
"license": "MIT-0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/on-exit-leak-free": {
|
"node_modules/on-exit-leak-free": {
|
||||||
@@ -233,28 +218,25 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.62.1",
|
"version": "1.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.62.1"
|
"playwright-core": "1.63.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"fsevents": "2.3.2"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.62.1",
|
"version": "1.63.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
+5
-4
@@ -3,17 +3,18 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"test:unit": "node --experimental-strip-types --test tests/unit/*.test.mjs",
|
||||||
"test": "docker compose up --build --abort-on-container-exit --exit-code-from e2e-runner",
|
"test": "docker compose up --build --abort-on-container-exit --exit-code-from e2e-runner",
|
||||||
"test:down": "docker compose down -v"
|
"test:down": "docker compose down -v"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.62.1",
|
"@playwright/test": "1.63.0",
|
||||||
"@types/nodemailer": "^8.0.1",
|
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
|
"hono": "^4.13.7",
|
||||||
"ws": "^8.21.3"
|
"ws": "^8.21.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"imapflow": "^1.7.1",
|
"imapflow": "^1.7.8",
|
||||||
"nodemailer": "^9.0.5"
|
"nodemailer": "^10.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,21 @@ if [ -n "${WORKER_GZIP_URL:-}" ]; then
|
|||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -n "${WORKER_URL_SITE_PASSWORD:-}" ]; then
|
||||||
|
echo "==> Waiting for site-password worker at $WORKER_URL_SITE_PASSWORD ..."
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if curl --connect-timeout 5 --max-time 10 -sf -H "x-custom-auth: e2e-site-pass" "$WORKER_URL_SITE_PASSWORD/health_check" > /dev/null 2>&1; then
|
||||||
|
echo " Site-password worker ready after ${i}s"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$i" -eq 60 ]; then
|
||||||
|
echo "ERROR: Site-password worker not ready after 60s"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Waiting for frontend at $FRONTEND_URL ..."
|
echo "==> Waiting for frontend at $FRONTEND_URL ..."
|
||||||
for i in $(seq 1 60); do
|
for i in $(seq 1 60); do
|
||||||
if curl -skf "$FRONTEND_URL" > /dev/null 2>&1; then
|
if curl -skf "$FRONTEND_URL" > /dev/null 2>&1; then
|
||||||
@@ -72,6 +87,21 @@ for i in $(seq 1 60); do
|
|||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
|
||||||
|
if [ -n "${FRONTEND_URL_ENV_OFF:-}" ]; then
|
||||||
|
echo "==> Waiting for env-off frontend at $FRONTEND_URL_ENV_OFF ..."
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if curl --connect-timeout 5 --max-time 10 -skf "$FRONTEND_URL_ENV_OFF" > /dev/null 2>&1; then
|
||||||
|
echo " Env-off frontend ready after ${i}s"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
if [ "$i" -eq 60 ]; then
|
||||||
|
echo "ERROR: Env-off frontend not ready after 60s"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Waiting for smtp-proxy-tls SMTP on $SMTP_PROXY_TLS_HOST:$SMTP_PROXY_TLS_SMTP_PORT ..."
|
echo "==> Waiting for smtp-proxy-tls SMTP on $SMTP_PROXY_TLS_HOST:$SMTP_PROXY_TLS_SMTP_PORT ..."
|
||||||
for i in $(seq 1 30); do
|
for i in $(seq 1 30); do
|
||||||
if nc -z "$SMTP_PROXY_TLS_HOST" "$SMTP_PROXY_TLS_SMTP_PORT" 2>/dev/null; then
|
if nc -z "$SMTP_PROXY_TLS_HOST" "$SMTP_PROXY_TLS_SMTP_PORT" 2>/dev/null; then
|
||||||
@@ -110,5 +140,13 @@ if [ -n "${WORKER_GZIP_URL:-}" ]; then
|
|||||||
echo " Gzip worker database initialized"
|
echo " Gzip worker database initialized"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -n "${WORKER_URL_SITE_PASSWORD:-}" ]; then
|
||||||
|
echo "==> Initializing site-password worker database"
|
||||||
|
curl --connect-timeout 5 --max-time 10 -sf -H "x-custom-auth: e2e-site-pass" -H "x-admin-auth: e2e-admin-pass" -X POST "$WORKER_URL_SITE_PASSWORD/admin/db_initialize" > /dev/null
|
||||||
|
curl --connect-timeout 5 --max-time 10 -sf -H "x-custom-auth: e2e-site-pass" -H "x-admin-auth: e2e-admin-pass" -X POST "$WORKER_URL_SITE_PASSWORD/admin/db_migration" > /dev/null
|
||||||
|
echo " Site-password worker database initialized"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "==> Running Playwright tests"
|
echo "==> Running Playwright tests"
|
||||||
|
npm run test:unit
|
||||||
exec npx playwright test "$@"
|
exec npx playwright test "$@"
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ async function receiveGzipMail(
|
|||||||
`--${boundary}--`,
|
`--${boundary}--`,
|
||||||
].join('\r\n');
|
].join('\r\n');
|
||||||
|
|
||||||
const res = await ctx.post(`${WORKER_GZIP_URL}/admin/test/receive_mail`, {
|
const res = await ctx.post(`${WORKER_GZIP_URL}/__test/receive_mail`, {
|
||||||
data: { from, to: address, raw },
|
data: { from, to: address, raw },
|
||||||
});
|
});
|
||||||
if (!res.ok()) throw new Error(`Failed to receive mail: ${res.status()} ${await res.text()}`);
|
if (!res.ok()) throw new Error(`Failed to receive mail: ${res.status()} ${await res.text()}`);
|
||||||
@@ -74,7 +74,7 @@ async function seedPlaintextMail(
|
|||||||
opts.text || 'Hello plaintext from E2E',
|
opts.text || 'Hello plaintext from E2E',
|
||||||
].join('\r\n');
|
].join('\r\n');
|
||||||
|
|
||||||
const res = await ctx.post(`${WORKER_GZIP_URL}/admin/test/seed_mail`, {
|
const res = await ctx.post(`${WORKER_GZIP_URL}/__test/seed_mail`, {
|
||||||
data: { address, source: from, raw, message_id: messageId },
|
data: { address, source: from, raw, message_id: messageId },
|
||||||
});
|
});
|
||||||
if (!res.ok()) throw new Error(`Failed to seed mail: ${res.status()} ${await res.text()}`);
|
if (!res.ok()) throw new Error(`Failed to seed mail: ${res.status()} ${await res.text()}`);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect, type APIRequestContext } from '@playwright/test';
|
||||||
import {
|
import {
|
||||||
WORKER_URL,
|
WORKER_URL,
|
||||||
|
WORKER_URL_ENV_OFF,
|
||||||
|
onMailpitMessage,
|
||||||
createTestAddress,
|
createTestAddress,
|
||||||
deleteAddress,
|
deleteAddress,
|
||||||
hashPassword,
|
hashPassword,
|
||||||
@@ -77,3 +79,325 @@ test.describe('Address activity throttling', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const OLD = '2020-01-01 00:00:00';
|
||||||
|
const RECENT = new Date(Date.now() - 3_600_000).toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
type Mailbox = { address: string; address_id: number; jwt: string; password: string };
|
||||||
|
type User = { id: number; email: string; jwt: string };
|
||||||
|
|
||||||
|
for (const { base, disabled } of [
|
||||||
|
{ base: WORKER_URL, disabled: false },
|
||||||
|
{ base: WORKER_URL_ENV_OFF, disabled: true },
|
||||||
|
]) {
|
||||||
|
test.describe(`Address activity disabled: ${disabled}`, () => {
|
||||||
|
let mailboxes: Mailbox[];
|
||||||
|
let users: User[];
|
||||||
|
let orphanAddresses: string[];
|
||||||
|
let originalUserSettings: Record<string, unknown>;
|
||||||
|
|
||||||
|
async function call(request: APIRequestContext, path: string,
|
||||||
|
options: Parameters<APIRequestContext['fetch']>[1] = {}, status = 200) {
|
||||||
|
const response = await request.fetch(`${base}${path}`, options);
|
||||||
|
expect(response.status(), await response.text()).toBe(status);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
async function list(request: APIRequestContext, path: string, query: Record<string, string>) {
|
||||||
|
return (await call(request, path, { params: { limit: '100', offset: '0', ...query } })).json();
|
||||||
|
}
|
||||||
|
async function addressRow(request: APIRequestContext, mailbox: Mailbox) {
|
||||||
|
return (await list(request, '/admin/address', { query: mailbox.address })).results
|
||||||
|
.find((row: { name: string }) => row.name === mailbox.address);
|
||||||
|
}
|
||||||
|
async function newMailbox(request: APIRequestContext) {
|
||||||
|
const response = await call(request, '/admin/new_address', {
|
||||||
|
method: 'POST', data: { name: `activity${Date.now()}${mailboxes.length}`, domain: 'test.example.com' },
|
||||||
|
});
|
||||||
|
const mailbox: Mailbox = await response.json();
|
||||||
|
mailboxes.push(mailbox);
|
||||||
|
return mailbox;
|
||||||
|
}
|
||||||
|
async function newUser(request: APIRequestContext) {
|
||||||
|
const email = `activity${Date.now()}${users.length}@test.example.com`;
|
||||||
|
const password = hashPassword('test-password-123');
|
||||||
|
await call(request, '/admin/users', { method: 'POST', data: { email, password } });
|
||||||
|
const response = await call(request, '/user_api/login', { method: 'POST', data: { email, password } });
|
||||||
|
const { jwt } = await response.json();
|
||||||
|
const { user_id: id } = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
|
||||||
|
const user = { id, email, jwt };
|
||||||
|
users.push(user);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
const addressAuth = (mailbox: Mailbox) => ({ Authorization: `Bearer ${mailbox.jwt}` });
|
||||||
|
const userAuth = (user: User) => ({ 'x-user-token': user.jwt });
|
||||||
|
async function bind(request: APIRequestContext, mailbox: Mailbox, user: User) {
|
||||||
|
await call(request, '/admin/users/bind_address', {
|
||||||
|
method: 'POST', data: { user_id: user.id, address_id: mailbox.address_id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function seed(request: APIRequestContext, mailbox: Mailbox, updatedAt: string | null = OLD, createdAt?: string) {
|
||||||
|
await call(request, '/__test/seed_mail', {
|
||||||
|
method: 'POST', data: {
|
||||||
|
address: mailbox.address, raw: `From: sender@test.example.com\r\nTo: ${mailbox.address}\r\nSubject: activity\r\n\r\nBody`,
|
||||||
|
address_updated_at: updatedAt, address_created_at: createdAt, created_at: OLD,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect((await addressRow(request, mailbox)).updated_at).toBe(updatedAt);
|
||||||
|
}
|
||||||
|
async function expectActivity(request: APIRequestContext, mailbox: Mailbox, previous: string | null) {
|
||||||
|
if (disabled || previous === RECENT) {
|
||||||
|
await waitForNextTimestamp();
|
||||||
|
expect((await addressRow(request, mailbox)).updated_at).toBe(previous);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await expect.poll(async () => (await addressRow(request, mailbox)).updated_at).not.toBe(previous);
|
||||||
|
expect((await addressRow(request, mailbox)).updated_at).toBeTruthy();
|
||||||
|
}
|
||||||
|
async function send(request: APIRequestContext, mailbox: Mailbox, user?: User) {
|
||||||
|
const subject = `Activity ${disabled} ${Date.now()}`;
|
||||||
|
const listener = onMailpitMessage(mail => mail.Subject === subject);
|
||||||
|
await listener.ready;
|
||||||
|
const [, delivered] = await Promise.all([
|
||||||
|
call(request, user ? `/user_api/address/${mailbox.address_id}/send_mail` : '/api/send_mail', {
|
||||||
|
method: 'POST', headers: user ? userAuth(user) : addressAuth(mailbox),
|
||||||
|
data: { to_mail: 'recipient@test.example.com', subject, content: 'Activity test', is_html: false },
|
||||||
|
}),
|
||||||
|
listener.message,
|
||||||
|
]);
|
||||||
|
expect(delivered.From.Address).toBe(mailbox.address);
|
||||||
|
}
|
||||||
|
async function relatedCounts(request: APIRequestContext, mailbox: Mailbox, user: User) {
|
||||||
|
const address = await addressRow(request, mailbox);
|
||||||
|
const inbox = await list(request, '/admin/mails', { address: mailbox.address });
|
||||||
|
const sent = await list(request, '/admin/sendbox', { address: mailbox.address });
|
||||||
|
const sender = await list(request, '/admin/address_sender', { address: mailbox.address });
|
||||||
|
let replyMailbox = mailbox;
|
||||||
|
if (!address) {
|
||||||
|
await call(request, '/api/auto_reply', { headers: addressAuth(mailbox) }, 401);
|
||||||
|
// A new credential lets us check that no old auto-reply data survived cleanup.
|
||||||
|
const [name, domain] = mailbox.address.split('@');
|
||||||
|
replyMailbox = await (await call(request, '/admin/new_address', {
|
||||||
|
method: 'POST', data: { name, domain, enablePrefix: false },
|
||||||
|
})).json();
|
||||||
|
}
|
||||||
|
const reply = await (await call(request, '/api/auto_reply', { headers: addressAuth(replyMailbox) })).json();
|
||||||
|
if (!address) await call(request, `/admin/delete_address/${replyMailbox.address_id}`, { method: 'DELETE' });
|
||||||
|
const bound = await (await call(request, '/user_api/bind_address', { headers: userAuth(user) })).json();
|
||||||
|
return [Number(!!address), inbox.count, sent.count, sender.count, Number(!!reply.subject),
|
||||||
|
bound.results.filter((row: { name: string }) => row.name === mailbox.address).length];
|
||||||
|
}
|
||||||
|
async function prepareRelated(request: APIRequestContext) {
|
||||||
|
const mailbox = await newMailbox(request);
|
||||||
|
const user = await newUser(request);
|
||||||
|
await bind(request, mailbox, user);
|
||||||
|
await call(request, '/api/auto_reply', {
|
||||||
|
method: 'POST', headers: addressAuth(mailbox),
|
||||||
|
data: { auto_reply: { subject: 'Reply', message: 'Body', enabled: false } },
|
||||||
|
});
|
||||||
|
await send(request, mailbox);
|
||||||
|
await seed(request, mailbox);
|
||||||
|
expect(await relatedCounts(request, mailbox, user)).toEqual([1, 1, 1, 1, 1, 1]);
|
||||||
|
return { mailbox, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeAll(async ({ request }) => {
|
||||||
|
expect(base).toBeTruthy();
|
||||||
|
originalUserSettings = await (await call(request, '/admin/user_settings')).json();
|
||||||
|
await call(request, '/admin/user_settings', {
|
||||||
|
method: 'POST', data: { ...originalUserSettings, enable: true, enableMailVerify: false },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test.afterAll(async ({ request }) => {
|
||||||
|
await call(request, '/admin/user_settings', { method: 'POST', data: originalUserSettings });
|
||||||
|
});
|
||||||
|
test.beforeEach(() => {
|
||||||
|
expect(base).toBeTruthy();
|
||||||
|
mailboxes = [];
|
||||||
|
users = [];
|
||||||
|
orphanAddresses = [];
|
||||||
|
});
|
||||||
|
test.afterEach(async ({ request }) => {
|
||||||
|
for (const mailbox of mailboxes) {
|
||||||
|
const row = await addressRow(request, mailbox);
|
||||||
|
if (row) await call(request, `/admin/delete_address/${row.id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
for (const user of users) await call(request, `/admin/users/${user.id}`, { method: 'DELETE' });
|
||||||
|
for (const address of orphanAddresses) {
|
||||||
|
const mails = await list(request, '/admin/mails', { address });
|
||||||
|
for (const mail of mails.results) await call(request, `/admin/mails/${mail.id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repeated user settings reads respect activity tracking and ownership', async ({ request }) => {
|
||||||
|
const user = await newUser(request);
|
||||||
|
const otherUser = await newUser(request);
|
||||||
|
for (const [index, previous] of [OLD, null, RECENT, OLD, OLD].entries()) {
|
||||||
|
const mailbox = await newMailbox(request);
|
||||||
|
if (index < 3) await bind(request, mailbox, user);
|
||||||
|
if (index === 4) await bind(request, mailbox, otherUser);
|
||||||
|
await seed(request, mailbox, previous);
|
||||||
|
}
|
||||||
|
await call(request, '/user_api/settings', { headers: userAuth(user) });
|
||||||
|
await expectActivity(request, mailboxes[0], OLD);
|
||||||
|
const after = await Promise.all(mailboxes.map(mailbox => addressRow(request, mailbox)));
|
||||||
|
expect(after.map(row => row.updated_at).slice(2)).toEqual([RECENT, OLD, OLD]);
|
||||||
|
if (disabled) expect(after[1].updated_at).toBeNull();
|
||||||
|
else expect(after[1].updated_at).toBeTruthy();
|
||||||
|
await call(request, '/user_api/settings', { headers: userAuth(user) });
|
||||||
|
await waitForNextTimestamp();
|
||||||
|
expect(await Promise.all(mailboxes.map(mailbox => addressRow(request, mailbox)))).toEqual(after);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const path of ['/api/settings', '/api/mails?limit=20&offset=0', '/api/parsed_mails?limit=20&offset=0']) {
|
||||||
|
test(`${path} respects the switch for stale, null and recent activity`, async ({ request }) => {
|
||||||
|
const mailbox = await newMailbox(request);
|
||||||
|
const other = await newMailbox(request);
|
||||||
|
await seed(request, other);
|
||||||
|
for (const previous of [OLD, null, RECENT]) {
|
||||||
|
await seed(request, mailbox, previous);
|
||||||
|
await call(request, path, { headers: addressAuth(mailbox) });
|
||||||
|
await expectActivity(request, mailbox, previous);
|
||||||
|
expect((await addressRow(request, other)).updated_at).toBe(OLD);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('later inbox pages do not refresh activity', async ({ request }) => {
|
||||||
|
const mailbox = await newMailbox(request);
|
||||||
|
await seed(request, mailbox);
|
||||||
|
for (const path of ['/api/mails?limit=20&offset=20', '/api/parsed_mails?limit=20&offset=20']) {
|
||||||
|
await call(request, path, { headers: addressAuth(mailbox) });
|
||||||
|
}
|
||||||
|
await waitForNextTimestamp();
|
||||||
|
expect((await addressRow(request, mailbox)).updated_at).toBe(OLD);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('incoming mail is stored and both send APIs respect activity tracking', async ({ request }) => {
|
||||||
|
const mailbox = await newMailbox(request);
|
||||||
|
const user = await newUser(request);
|
||||||
|
await bind(request, mailbox, user);
|
||||||
|
await seed(request, mailbox);
|
||||||
|
const raw = `From: sender@test.example.com\r\nTo: ${mailbox.address}\r\nSubject: incoming\r\n\r\nBody`;
|
||||||
|
const received = await call(request, '/__test/receive_mail', {
|
||||||
|
method: 'POST', data: { from: 'sender@test.example.com', to: mailbox.address, raw },
|
||||||
|
});
|
||||||
|
expect((await received.json()).success).toBe(true);
|
||||||
|
expect((await list(request, '/admin/mails', { address: mailbox.address })).count).toBe(2);
|
||||||
|
expect((await addressRow(request, mailbox)).updated_at).toBe(OLD);
|
||||||
|
for (const sender of [undefined, user]) {
|
||||||
|
await seed(request, mailbox);
|
||||||
|
await send(request, mailbox, sender);
|
||||||
|
await expectActivity(request, mailbox, OLD);
|
||||||
|
}
|
||||||
|
const sender = await list(request, '/admin/address_sender', { address: mailbox.address });
|
||||||
|
expect(sender.results[0].balance).toBe(8);
|
||||||
|
expect((await list(request, '/admin/sendbox', { address: mailbox.address })).count).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('password generation, password changes and admin resets keep working', async ({ request }) => {
|
||||||
|
const mailbox = await newMailbox(request);
|
||||||
|
expect(mailbox.password).toBeTruthy();
|
||||||
|
expect((await addressRow(request, mailbox)).updated_at).toBeTruthy();
|
||||||
|
await call(request, '/api/address_login', {
|
||||||
|
method: 'POST', data: { email: mailbox.address, password: hashPassword(mailbox.password) },
|
||||||
|
});
|
||||||
|
for (const path of ['/api/address_change_password', `/admin/address/${mailbox.address_id}/reset_password`]) {
|
||||||
|
await seed(request, mailbox);
|
||||||
|
const password = hashPassword(`password-${path}`);
|
||||||
|
await call(request, path, {
|
||||||
|
method: 'POST', headers: addressAuth(mailbox), data: { password, new_password: password },
|
||||||
|
});
|
||||||
|
expect((await addressRow(request, mailbox)).updated_at).not.toBe(OLD);
|
||||||
|
await call(request, '/api/address_login', { method: 'POST', data: { email: mailbox.address, password } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('address transfer still initializes its timestamp and changes ownership', async ({ request }) => {
|
||||||
|
const mailbox = await newMailbox(request);
|
||||||
|
const user = await newUser(request);
|
||||||
|
const target = await newUser(request);
|
||||||
|
await bind(request, mailbox, user);
|
||||||
|
await seed(request, mailbox);
|
||||||
|
await call(request, '/user_api/transfer_address', {
|
||||||
|
method: 'POST', headers: userAuth(user), data: { address_id: mailbox.address_id, target_user_email: target.email },
|
||||||
|
});
|
||||||
|
const transferred = await addressRow(request, mailbox);
|
||||||
|
expect(transferred.id).not.toBe(mailbox.address_id);
|
||||||
|
expect(transferred.updated_at).toBeTruthy();
|
||||||
|
expect(transferred.updated_at).not.toBe(OLD);
|
||||||
|
const bindings = await (await call(request, '/user_api/bind_address', { headers: userAuth(target) })).json();
|
||||||
|
expect(bindings.results.map((row: { name: string }) => row.name)).toContain(mailbox.address);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('manual inactivity cleanup protects the address and all related data when disabled', async ({ request }) => {
|
||||||
|
const { mailbox, user } = await prepareRelated(request);
|
||||||
|
for (const lang of ['en', 'zh']) {
|
||||||
|
const response = await call(request, '/admin/cleanup', {
|
||||||
|
method: 'POST', headers: { 'x-lang': lang }, data: { cleanType: 'inactiveAddress', cleanDays: 1 },
|
||||||
|
}, disabled ? 500 : 200);
|
||||||
|
if (disabled) expect(await response.text()).toBe(lang === 'zh'
|
||||||
|
? '清理失败,请检查清理配置;禁用地址活跃时间更新时,无法按不活跃时间清理。'
|
||||||
|
: 'Cleanup failed. Check your cleanup settings; inactive-address cleanup is unavailable when address activity updates are disabled.');
|
||||||
|
else expect(await response.json()).toEqual({ success: true });
|
||||||
|
expect(await relatedCounts(request, mailbox, user)).toEqual(disabled ? [1, 1, 1, 1, 1, 1] : [0, 0, 0, 0, 0, 0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scheduled inactivity cleanup respects the switch and continues later cleanup', async ({ request }) => {
|
||||||
|
const { mailbox, user } = await prepareRelated(request);
|
||||||
|
const created = await newMailbox(request);
|
||||||
|
await seed(request, created, RECENT, OLD);
|
||||||
|
const original = await (await call(request, '/admin/auto_cleanup')).json();
|
||||||
|
try {
|
||||||
|
await call(request, '/admin/auto_cleanup', { method: 'POST', data: {
|
||||||
|
enableInactiveAddressAutoCleanup: true, cleanInactiveAddressDays: 1,
|
||||||
|
enableAddressAutoCleanup: true, cleanAddressDays: 1,
|
||||||
|
} });
|
||||||
|
await call(request, '/__scheduled');
|
||||||
|
expect(await relatedCounts(request, mailbox, user)).toEqual(disabled ? [1, 1, 1, 1, 1, 1] : [0, 0, 0, 0, 0, 0]);
|
||||||
|
expect(await addressRow(request, created)).toBeUndefined();
|
||||||
|
} finally {
|
||||||
|
await call(request, '/admin/auto_cleanup', { method: 'POST', data: original || {} });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const cleanType of ['addressCreated', 'unboundAddress', 'emptyAddress', 'mails', 'mails_unknow', 'sendbox']) {
|
||||||
|
test(`${cleanType} cleanup remains available`, async ({ request }) => {
|
||||||
|
const mailbox = await newMailbox(request);
|
||||||
|
await seed(request, mailbox, RECENT, OLD);
|
||||||
|
let queryAddress = mailbox.address;
|
||||||
|
if (cleanType === 'emptyAddress') await call(request, `/admin/clear_inbox/${mailbox.address_id}`, { method: 'DELETE' });
|
||||||
|
if (cleanType === 'mails_unknow') {
|
||||||
|
queryAddress = `unknown${Date.now()}@test.example.com`;
|
||||||
|
orphanAddresses.push(queryAddress);
|
||||||
|
await call(request, '/__test/seed_mail', {
|
||||||
|
method: 'POST', data: { address: queryAddress, raw: 'old unknown mail', created_at: OLD },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (cleanType === 'sendbox') {
|
||||||
|
await send(request, mailbox);
|
||||||
|
await waitForNextTimestamp();
|
||||||
|
}
|
||||||
|
await call(request, '/admin/cleanup', {
|
||||||
|
method: 'POST', data: { cleanType, cleanDays: cleanType === 'sendbox' ? 0 : 1 },
|
||||||
|
});
|
||||||
|
if (['addressCreated', 'unboundAddress', 'emptyAddress'].includes(cleanType)) {
|
||||||
|
expect(await addressRow(request, mailbox)).toBeUndefined();
|
||||||
|
} else {
|
||||||
|
const path = cleanType === 'sendbox' ? '/admin/sendbox' : '/admin/mails';
|
||||||
|
expect((await list(request, path, { address: queryAddress })).count).toBe(0);
|
||||||
|
expect(await addressRow(request, mailbox)).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('invalid cleanup requests keep their original error details', async ({ request }) => {
|
||||||
|
for (const { data, message } of [
|
||||||
|
{ data: { cleanType: 'invalid', cleanDays: 1 }, message: 'Operation failed: Invalid cleanType' },
|
||||||
|
{ data: { cleanType: 'mails', cleanDays: -1 }, message: 'Operation failed: Invalid cleanType or cleanDays' },
|
||||||
|
]) {
|
||||||
|
const response = await call(request, '/admin/cleanup', { method: 'POST', data }, 500);
|
||||||
|
expect(await response.text()).toBe(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
|
||||||
|
import { WORKER_URL, TEST_DOMAIN, createTestAddress, deleteAddress, seedTestMail } from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
function signToken(payload: Record<string, unknown>) {
|
||||||
|
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
||||||
|
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||||
|
const signature = createHmac('sha256', 'e2e-test-secret-key')
|
||||||
|
.update(`${header}.${body}`).digest('base64url');
|
||||||
|
return `${header}.${body}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('password login tokens renew below seven days; legacy credentials do not renew', async ({ request }) => {
|
||||||
|
const mailbox = await createTestAddress(request, 'password-renewal');
|
||||||
|
const day = 24 * 60 * 60;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const identity = { address: mailbox.address, address_id: mailbox.address_id };
|
||||||
|
try {
|
||||||
|
for (const remainingDays of [8, 6]) {
|
||||||
|
const token = signToken({
|
||||||
|
...identity, type: 'address_password_login',
|
||||||
|
iat: now - (30 - remainingDays) * day, exp: now + remainingDays * day,
|
||||||
|
});
|
||||||
|
const response = await request.get(`${WORKER_URL}/api/settings`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
const settings = await response.json();
|
||||||
|
expect(settings.type).toBe('address_password_login');
|
||||||
|
if (remainingDays > 7) {
|
||||||
|
expect(settings.new_address_token).toBeNull();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
expect(settings.new_address_token).toEqual(expect.any(String));
|
||||||
|
const renewed = await request.get(`${WORKER_URL}/api/settings`, {
|
||||||
|
headers: { Authorization: `Bearer ${settings.new_address_token}` },
|
||||||
|
});
|
||||||
|
expect(renewed.ok()).toBe(true);
|
||||||
|
const renewedSettings = await renewed.json();
|
||||||
|
expect(renewedSettings).toMatchObject({ ...identity, type: 'address_password_login', new_address_token: null });
|
||||||
|
expect(renewedSettings.exp - renewedSettings.iat).toBe(30 * day);
|
||||||
|
}
|
||||||
|
const legacy = await request.get(`${WORKER_URL}/api/settings`, {
|
||||||
|
headers: { Authorization: `Bearer ${mailbox.jwt}` },
|
||||||
|
});
|
||||||
|
expect(legacy.ok()).toBe(true);
|
||||||
|
expect(await legacy.json()).toMatchObject({ ...identity, new_address_token: null });
|
||||||
|
for (const claims of [
|
||||||
|
{ type: 'telegram_binding' },
|
||||||
|
{ type: 'unknown' },
|
||||||
|
{ type: 'address_password_login' },
|
||||||
|
{ type: 'address_password_login', iat: now - 30 * day, exp: now - 1 },
|
||||||
|
{ type: 'address_password_login', iat: now, exp: now + 31 * day },
|
||||||
|
]) {
|
||||||
|
const response = await request.get(`${WORKER_URL}/api/settings`, {
|
||||||
|
headers: { Authorization: `Bearer ${signToken({ ...identity, ...claims })}` },
|
||||||
|
});
|
||||||
|
expect(response.status(), JSON.stringify(claims)).toBe(401);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await deleteAddress(request, mailbox.jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function expectRejected(request: APIRequestContext, jwt: string) {
|
||||||
|
for (const [method, path] of [
|
||||||
|
['GET', '/api/settings'],
|
||||||
|
['GET', '/api/mails?limit=20&offset=0'],
|
||||||
|
['GET', '/api/mail/1'],
|
||||||
|
['GET', '/api/parsed_mails?limit=20&offset=0'],
|
||||||
|
['GET', '/api/parsed_mail/1'],
|
||||||
|
['GET', '/api/sendbox?limit=20&offset=0'],
|
||||||
|
['GET', '/api/auto_reply'],
|
||||||
|
['POST', '/api/webhook/settings'],
|
||||||
|
['POST', '/api/attachment/get_url'],
|
||||||
|
['POST', '/api/address_change_password'],
|
||||||
|
['POST', '/api/request_send_mail_access'],
|
||||||
|
['POST', '/api/send_mail'],
|
||||||
|
['PATCH', '/api/mails/1/read'],
|
||||||
|
['DELETE', '/api/mails/1'],
|
||||||
|
['DELETE', '/api/sendbox/1'],
|
||||||
|
['DELETE', '/api/clear_inbox'],
|
||||||
|
['DELETE', '/api/clear_sent_items'],
|
||||||
|
['DELETE', '/api/delete_address'],
|
||||||
|
]) {
|
||||||
|
const response = await request.fetch(`${WORKER_URL}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
...(method === 'POST' || method === 'PATCH' ? { data: {} } : {}),
|
||||||
|
});
|
||||||
|
expect(response.status(), `${method} ${path}`).toBe(401);
|
||||||
|
}
|
||||||
|
const login = await request.post(`${WORKER_URL}/open_api/credential_login`, {
|
||||||
|
data: { credential: jwt },
|
||||||
|
});
|
||||||
|
expect(login.status()).toBe(401);
|
||||||
|
const send = await request.post(`${WORKER_URL}/external/api/send_mail`, {
|
||||||
|
headers: { 'x-lang': 'en' },
|
||||||
|
data: { token: jwt },
|
||||||
|
});
|
||||||
|
expect(send.status()).toBe(400);
|
||||||
|
expect(await send.text()).toBe('Failed to send mail Invalid address credential');
|
||||||
|
const bind = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${jwt}`,
|
||||||
|
'x-user-token': signToken({ user_id: 1, exp: Math.floor(Date.now() / 1000) + 60 }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(bind.status()).toBe(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('deleted credentials cannot access or delete a recreated mailbox', async ({ request }) => {
|
||||||
|
const name = `credential${Date.now()}`;
|
||||||
|
const create = async () => {
|
||||||
|
const response = await request.post(`${WORKER_URL}/api/new_address`, {
|
||||||
|
data: { name, domain: TEST_DOMAIN },
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
const original = await create();
|
||||||
|
await deleteAddress(request, original.jwt);
|
||||||
|
await expectRejected(request, original.jwt);
|
||||||
|
|
||||||
|
const recreated = await create();
|
||||||
|
try {
|
||||||
|
expect(recreated.address).toBe(original.address);
|
||||||
|
expect(recreated.address_id).not.toBe(original.address_id);
|
||||||
|
await seedTestMail(request, recreated.address, { subject: 'New owner mail' });
|
||||||
|
await expectRejected(request, original.jwt);
|
||||||
|
const mails = await request.get(`${WORKER_URL}/api/mails?limit=20&offset=0`, {
|
||||||
|
headers: { Authorization: `Bearer ${recreated.jwt}` },
|
||||||
|
});
|
||||||
|
expect(mails.ok()).toBe(true);
|
||||||
|
expect((await mails.json()).count).toBe(1);
|
||||||
|
} finally {
|
||||||
|
await deleteAddress(request, recreated.jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('valid numeric/string IDs work; missing, invalid and mismatched IDs are rejected', async ({ request }) => {
|
||||||
|
const mailbox = await createTestAddress(request, 'credential-id');
|
||||||
|
try {
|
||||||
|
for (const address_id of [mailbox.address_id, String(mailbox.address_id)]) {
|
||||||
|
const token = signToken({ address: mailbox.address, address_id });
|
||||||
|
const settings = await request.get(`${WORKER_URL}/api/settings`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
expect(settings.ok()).toBe(true);
|
||||||
|
const login = await request.post(`${WORKER_URL}/open_api/credential_login`, {
|
||||||
|
data: { credential: token },
|
||||||
|
});
|
||||||
|
expect(login.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
for (const payload of [
|
||||||
|
{ address: mailbox.address },
|
||||||
|
...[0, -1, 1.5, true, null, '', '1e3', {}, Number.MAX_SAFE_INTEGER + 1]
|
||||||
|
.map(address_id => ({ address: mailbox.address, address_id })),
|
||||||
|
{ address: `other@${TEST_DOMAIN}`, address_id: mailbox.address_id },
|
||||||
|
{ address_id: mailbox.address_id },
|
||||||
|
]) {
|
||||||
|
const jwt = signToken(payload);
|
||||||
|
const response = await request.get(`${WORKER_URL}/api/mails?limit=20&offset=0`, {
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
});
|
||||||
|
expect(response.status(), JSON.stringify(payload)).toBe(401);
|
||||||
|
const login = await request.post(`${WORKER_URL}/open_api/credential_login`, {
|
||||||
|
data: { credential: jwt },
|
||||||
|
});
|
||||||
|
expect(login.status(), JSON.stringify(payload)).toBe(401);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await deleteAddress(request, mailbox.jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -30,6 +30,10 @@ test.describe('Address Password Login', () => {
|
|||||||
headers: { Authorization: `Bearer ${loginBody.jwt}` },
|
headers: { Authorization: `Bearer ${loginBody.jwt}` },
|
||||||
});
|
});
|
||||||
expect(settingsRes.ok()).toBe(true);
|
expect(settingsRes.ok()).toBe(true);
|
||||||
|
const settings = await settingsRes.json();
|
||||||
|
expect(settings.type).toBe('address_password_login');
|
||||||
|
expect(settings.exp - settings.iat).toBe(30 * 24 * 60 * 60);
|
||||||
|
expect(settings.new_address_token).toBeNull();
|
||||||
} finally {
|
} finally {
|
||||||
await deleteAddress(request, jwt);
|
await deleteAddress(request, jwt);
|
||||||
}
|
}
|
||||||
@@ -164,4 +168,44 @@ test.describe('Address Password Login', () => {
|
|||||||
await deleteAddress(request, jwt);
|
await deleteAddress(request, jwt);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('users can reset a mailbox password only while it is bound to them', async ({ request }) => {
|
||||||
|
const { jwt, address, address_id } = await createTestAddress(request, 'pwd-user-reset');
|
||||||
|
const email = `pwd-reset-user-${Date.now()}@test.example.com`;
|
||||||
|
const password = hashPassword('password-reset-user');
|
||||||
|
const newPassword = hashPassword('replacement-mailbox-password');
|
||||||
|
try {
|
||||||
|
const enable = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||||
|
data: { enable: true, enableMailVerify: false },
|
||||||
|
});
|
||||||
|
expect(enable.ok()).toBe(true);
|
||||||
|
const register = await request.post(`${WORKER_URL}/user_api/register`, { data: { email, password } });
|
||||||
|
expect(register.ok()).toBe(true);
|
||||||
|
const login = await request.post(`${WORKER_URL}/user_api/login`, { data: { email, password } });
|
||||||
|
expect(login.ok()).toBe(true);
|
||||||
|
const { jwt: userJwt } = await login.json();
|
||||||
|
const headers = { 'x-user-token': userJwt };
|
||||||
|
const reset = (value = newPassword) => request.post(`${WORKER_URL}/user_api/address/${address_id}/reset_password`, {
|
||||||
|
headers, data: { new_password: value },
|
||||||
|
});
|
||||||
|
expect((await reset()).status()).toBe(403);
|
||||||
|
const bind = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||||
|
headers: { ...headers, Authorization: `Bearer ${jwt}` },
|
||||||
|
});
|
||||||
|
expect(bind.ok()).toBe(true);
|
||||||
|
expect((await reset('plaintext')).status()).toBe(400);
|
||||||
|
expect((await reset()).ok()).toBe(true);
|
||||||
|
const mailboxLogin = await request.post(`${WORKER_URL}/api/address_login`, {
|
||||||
|
data: { email: address, password: newPassword },
|
||||||
|
});
|
||||||
|
expect(mailboxLogin.ok()).toBe(true);
|
||||||
|
const unbind = await request.post(`${WORKER_URL}/user_api/unbind_address`, {
|
||||||
|
headers, data: { address_id },
|
||||||
|
});
|
||||||
|
expect(unbind.ok()).toBe(true);
|
||||||
|
expect((await reset()).status()).toBe(403);
|
||||||
|
} finally {
|
||||||
|
await deleteAddress(request, jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ async function seedTestMailWithReply(
|
|||||||
text,
|
text,
|
||||||
].join('\r\n');
|
].join('\r\n');
|
||||||
|
|
||||||
const res = await ctx.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
const res = await ctx.post(`${WORKER_URL}/__test/receive_mail`, {
|
||||||
data: { from, to: address, raw },
|
data: { from, to: address, raw },
|
||||||
});
|
});
|
||||||
if (!res.ok()) {
|
if (!res.ok()) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ test.describe('Bounded cleanup', () => {
|
|||||||
test('cleans at most one batch and continues on the next run', async ({ request }) => {
|
test('cleans at most one batch and continues on the next run', async ({ request }) => {
|
||||||
const address = `cleanup-batch-${Date.now()}@test.example.com`;
|
const address = `cleanup-batch-${Date.now()}@test.example.com`;
|
||||||
const seedResponses = await Promise.all(Array.from({ length: 11 }, (_, index) =>
|
const seedResponses = await Promise.all(Array.from({ length: 11 }, (_, index) =>
|
||||||
request.post(`${WORKER_URL}/admin/test/seed_mail`, {
|
request.post(`${WORKER_URL}/__test/seed_mail`, {
|
||||||
data: {
|
data: {
|
||||||
address,
|
address,
|
||||||
raw: 'old cleanup mail',
|
raw: 'old cleanup mail',
|
||||||
@@ -40,7 +40,7 @@ test.describe('Bounded cleanup', () => {
|
|||||||
const afterSecondCleanup = await listMails(request, address);
|
const afterSecondCleanup = await listMails(request, address);
|
||||||
expect(afterSecondCleanup.count).toBe(0);
|
expect(afterSecondCleanup.count).toBe(0);
|
||||||
|
|
||||||
const recentMailResponse = await request.post(`${WORKER_URL}/admin/test/seed_mail`, {
|
const recentMailResponse = await request.post(`${WORKER_URL}/__test/seed_mail`, {
|
||||||
data: {
|
data: {
|
||||||
address,
|
address,
|
||||||
raw: 'recent cleanup mail',
|
raw: 'recent cleanup mail',
|
||||||
@@ -63,7 +63,7 @@ test.describe('Bounded cleanup', () => {
|
|||||||
test('deletes one address batch and its related data', async ({ request }) => {
|
test('deletes one address batch and its related data', async ({ request }) => {
|
||||||
const oldAddress = await createTestAddress(request, 'cleanup-old');
|
const oldAddress = await createTestAddress(request, 'cleanup-old');
|
||||||
|
|
||||||
const seedResponse = await request.post(`${WORKER_URL}/admin/test/seed_mail`, {
|
const seedResponse = await request.post(`${WORKER_URL}/__test/seed_mail`, {
|
||||||
data: {
|
data: {
|
||||||
address: oldAddress.address,
|
address: oldAddress.address,
|
||||||
raw: 'address cleanup mail',
|
raw: 'address cleanup mail',
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ test.describe('Email forward domain normalization', () => {
|
|||||||
`Forward domain normalization test`,
|
`Forward domain normalization test`,
|
||||||
].join('\r\n');
|
].join('\r\n');
|
||||||
|
|
||||||
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
const res = await request.post(`${WORKER_URL}/__test/receive_mail`, {
|
||||||
data: { from: 'sender@test.example.com', to, raw },
|
data: { from: 'sender@test.example.com', to, raw },
|
||||||
});
|
});
|
||||||
expect(res.ok()).toBe(true);
|
expect(res.ok()).toBe(true);
|
||||||
@@ -118,7 +118,7 @@ test.describe('Email forward domain normalization', () => {
|
|||||||
`Forward domain boundary test`,
|
`Forward domain boundary test`,
|
||||||
].join('\r\n');
|
].join('\r\n');
|
||||||
|
|
||||||
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
const res = await request.post(`${WORKER_URL}/__test/receive_mail`, {
|
||||||
data: { from: 'sender@test.example.com', to, raw },
|
data: { from: 'sender@test.example.com', to, raw },
|
||||||
});
|
});
|
||||||
expect(res.ok()).toBe(true);
|
expect(res.ok()).toBe(true);
|
||||||
@@ -161,7 +161,7 @@ test.describe('Email forward domain normalization', () => {
|
|||||||
`Forward catch-all domain test`,
|
`Forward catch-all domain test`,
|
||||||
].join('\r\n');
|
].join('\r\n');
|
||||||
|
|
||||||
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
const res = await request.post(`${WORKER_URL}/__test/receive_mail`, {
|
||||||
data: { from: 'sender@test.example.com', to: address, raw },
|
data: { from: 'sender@test.example.com', to: address, raw },
|
||||||
});
|
});
|
||||||
expect(res.ok()).toBe(true);
|
expect(res.ok()).toBe(true);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ test.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
expect(res.status()).toBe(401);
|
expect(res.status()).toBe(401);
|
||||||
|
expect(await res.json()).toMatchObject({ code: 'AUTH_SITE_PASSWORD_INVALID', message: expect.any(String) });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ test.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
expect(res.status()).toBe(401);
|
expect(res.status()).toBe(401);
|
||||||
|
expect(await res.json()).toMatchObject({ code: 'AUTH_ADMIN_CREDENTIAL_INVALID', message: expect.any(String) });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('empty password returns 401', async ({ request }) => {
|
test('empty password returns 401', async ({ request }) => {
|
||||||
@@ -53,6 +55,7 @@ test.describe('Turnstile Login Endpoints (ENABLE_GLOBAL_TURNSTILE_CHECK disabled
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
expect(res.status()).toBe(401);
|
expect(res.status()).toBe(401);
|
||||||
|
expect(await res.json()).toMatchObject({ code: 'AUTH_ADMIN_CREDENTIAL_INVALID', message: expect.any(String) });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ test.describe('Mail Deletion', () => {
|
|||||||
`--${boundary}--`,
|
`--${boundary}--`,
|
||||||
].join('\r\n');
|
].join('\r\n');
|
||||||
|
|
||||||
const seedRes = await request.post(`${WORKER_URL_ENV_OFF}/admin/test/receive_mail`, {
|
const seedRes = await request.post(`${WORKER_URL_ENV_OFF}/__test/receive_mail`, {
|
||||||
data: { from, to: address, raw },
|
data: { from, to: address, raw },
|
||||||
});
|
});
|
||||||
expect(seedRes.ok()).toBe(true);
|
expect(seedRes.ok()).toBe(true);
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
WORKER_URL,
|
||||||
|
WORKER_URL_ENV_OFF,
|
||||||
|
createTestAddress,
|
||||||
|
deleteAddress,
|
||||||
|
seedTestMail,
|
||||||
|
} from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
const headers = (jwt: string) => ({ Authorization: `Bearer ${jwt}` });
|
||||||
|
|
||||||
|
test.describe('Mail read status', () => {
|
||||||
|
test('keeps historical mail read and switches one new mail state', async ({ request }) => {
|
||||||
|
const mailbox = await createTestAddress(request, 'mail-read');
|
||||||
|
try {
|
||||||
|
const historical = await request.post(`${WORKER_URL}/__test/seed_mail`, {
|
||||||
|
data: {
|
||||||
|
address: mailbox.address,
|
||||||
|
source: 'sender@test.example.com',
|
||||||
|
raw: 'From: sender@test.example.com\r\nSubject: Historical\r\n\r\nHistorical',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(historical.ok()).toBe(true);
|
||||||
|
await seedTestMail(request, mailbox.address, { subject: 'New unread mail' });
|
||||||
|
|
||||||
|
const list = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||||
|
headers: headers(mailbox.jwt),
|
||||||
|
});
|
||||||
|
const mails = (await list.json()).results;
|
||||||
|
expect(mails.find((mail: any) => mail.raw.includes('Historical')).is_unread).toBeNull();
|
||||||
|
const unreadMail = mails.find((mail: any) => mail.raw.includes('New unread mail'));
|
||||||
|
expect(unreadMail.is_unread).toBe(1);
|
||||||
|
|
||||||
|
const markRead = await request.patch(`${WORKER_URL}/api/mails/${unreadMail.id}/read`, {
|
||||||
|
headers: headers(mailbox.jwt),
|
||||||
|
data: { isUnread: false },
|
||||||
|
});
|
||||||
|
expect((await markRead.json()).success).toBe(true);
|
||||||
|
|
||||||
|
const updated = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||||
|
headers: headers(mailbox.jwt),
|
||||||
|
});
|
||||||
|
const updatedMail = (await updated.json()).results.find((mail: any) => mail.id === unreadMail.id);
|
||||||
|
expect(updatedMail.is_unread).toBe(0);
|
||||||
|
|
||||||
|
const markUnread = await request.patch(`${WORKER_URL}/api/mails/${unreadMail.id}/read`, {
|
||||||
|
headers: headers(mailbox.jwt),
|
||||||
|
data: { isUnread: true },
|
||||||
|
});
|
||||||
|
expect((await markUnread.json()).success).toBe(true);
|
||||||
|
|
||||||
|
const restored = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||||
|
headers: headers(mailbox.jwt),
|
||||||
|
});
|
||||||
|
const restoredMail = (await restored.json()).results.find((mail: any) => mail.id === unreadMail.id);
|
||||||
|
expect(restoredMail.is_unread).toBe(1);
|
||||||
|
|
||||||
|
const invalid = await request.patch(`${WORKER_URL}/api/mails/${unreadMail.id}/read`, {
|
||||||
|
headers: headers(mailbox.jwt),
|
||||||
|
data: { isUnread: 'yes' },
|
||||||
|
});
|
||||||
|
expect(invalid.status()).toBe(400);
|
||||||
|
} finally {
|
||||||
|
await deleteAddress(request, mailbox.jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scopes the update and keeps disabled instances unchanged', async ({ request }) => {
|
||||||
|
const first = await createTestAddress(request, 'mail-read-first');
|
||||||
|
const second = await createTestAddress(request, 'mail-read-second');
|
||||||
|
try {
|
||||||
|
await seedTestMail(request, second.address, { subject: 'Second mail' });
|
||||||
|
const secondList = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||||
|
headers: headers(second.jwt),
|
||||||
|
});
|
||||||
|
const secondMail = (await secondList.json()).results[0];
|
||||||
|
|
||||||
|
await request.patch(`${WORKER_URL}/api/mails/${secondMail.id}/read`, {
|
||||||
|
headers: headers(first.jwt),
|
||||||
|
data: { isUnread: false },
|
||||||
|
});
|
||||||
|
const unchanged = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||||
|
headers: headers(second.jwt),
|
||||||
|
});
|
||||||
|
expect((await unchanged.json()).results[0].is_unread).toBe(1);
|
||||||
|
|
||||||
|
const disabledMailbox = await createTestAddress(
|
||||||
|
request,
|
||||||
|
'mail-read-disabled',
|
||||||
|
'test.example.com',
|
||||||
|
WORKER_URL_ENV_OFF,
|
||||||
|
);
|
||||||
|
const oldList = await request.get(`${WORKER_URL_ENV_OFF}/api/mails?limit=10&offset=0`, {
|
||||||
|
headers: headers(disabledMailbox.jwt),
|
||||||
|
});
|
||||||
|
expect(oldList.ok()).toBe(true);
|
||||||
|
const disabledUpdate = await request.patch(`${WORKER_URL_ENV_OFF}/api/mails/1/read`, {
|
||||||
|
headers: headers(disabledMailbox.jwt),
|
||||||
|
data: { isUnread: false },
|
||||||
|
});
|
||||||
|
expect(disabledUpdate.status()).toBe(403);
|
||||||
|
} finally {
|
||||||
|
await deleteAddress(request, first.jwt);
|
||||||
|
await deleteAddress(request, second.jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
WORKER_URL_ENV_OFF,
|
||||||
|
WORKER_URL_SITE_PASSWORD,
|
||||||
|
} from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
const SITE_HEADERS = { 'x-custom-auth': 'e2e-site-pass' };
|
||||||
|
const ADMIN_HEADERS = { ...SITE_HEADERS, 'x-admin-auth': 'e2e-admin-pass' };
|
||||||
|
const futureExpiration = () => new Date(Date.now() + 3_600_000).toISOString();
|
||||||
|
|
||||||
|
const signTestToken = (payload: Record<string, unknown>, secret = 'e2e-site-password-secret') => {
|
||||||
|
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
||||||
|
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||||
|
const signature = createHmac('sha256', secret).update(`${header}.${body}`).digest('base64url');
|
||||||
|
return `${header}.${body}.${signature}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tokenPayload = (role: string) => ({
|
||||||
|
user_id: 1,
|
||||||
|
user_role: role,
|
||||||
|
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Redemption feature access boundaries', () => {
|
||||||
|
test('the disabled switch hides every user and Admin endpoint', async ({ request }) => {
|
||||||
|
const settingsResponse = await request.get(`${WORKER_URL_ENV_OFF}/open_api/settings`);
|
||||||
|
expect(settingsResponse.ok()).toBe(true);
|
||||||
|
expect((await settingsResponse.json()).enableRedeemCode).toBe(false);
|
||||||
|
|
||||||
|
const requests = [
|
||||||
|
request.post(`${WORKER_URL_ENV_OFF}/redeem_api/query`, { data: { code: 'anything' } }),
|
||||||
|
request.post(`${WORKER_URL_ENV_OFF}/redeem_api/result`, { data: { code: 'anything' } }),
|
||||||
|
request.post(`${WORKER_URL_ENV_OFF}/redeem_api/redeem`, {
|
||||||
|
data: { code: 'anything', user_email: 'user@test.example.com' },
|
||||||
|
}),
|
||||||
|
request.get(`${WORKER_URL_ENV_OFF}/admin/redeem_codes?redeem_type=role`),
|
||||||
|
request.get(`${WORKER_URL_ENV_OFF}/admin/redeem_codes/export?redeem_type=role&limit=1`),
|
||||||
|
request.post(`${WORKER_URL_ENV_OFF}/admin/redeem_codes/batch`, {
|
||||||
|
data: {
|
||||||
|
count: 1,
|
||||||
|
redeem_type: 'role',
|
||||||
|
value: 'case-role',
|
||||||
|
enabled: true,
|
||||||
|
expires_at: futureExpiration(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
request.put(`${WORKER_URL_ENV_OFF}/admin/redeem_codes/1`, {
|
||||||
|
data: {
|
||||||
|
redeem_type: 'role',
|
||||||
|
value: 'case-role',
|
||||||
|
enabled: true,
|
||||||
|
expires_at: futureExpiration(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
request.delete(`${WORKER_URL_ENV_OFF}/admin/redeem_codes/1`),
|
||||||
|
];
|
||||||
|
const responses = await Promise.all(requests);
|
||||||
|
for (const response of responses) {
|
||||||
|
expect(response.status()).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('site password takes priority over otherwise public redemption APIs', async ({ request }) => {
|
||||||
|
const settingsResponse = await request.get(`${WORKER_URL_SITE_PASSWORD}/open_api/settings`);
|
||||||
|
expect(settingsResponse.ok()).toBe(true);
|
||||||
|
expect(await settingsResponse.json()).toMatchObject({
|
||||||
|
needAuth: true,
|
||||||
|
enableRedeemCode: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const blockedAdminResponse = await request.post(
|
||||||
|
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/batch`,
|
||||||
|
{
|
||||||
|
headers: { 'x-admin-auth': 'e2e-admin-pass' },
|
||||||
|
data: {
|
||||||
|
count: 1,
|
||||||
|
redeem_type: 'role',
|
||||||
|
value: 'case-role',
|
||||||
|
enabled: true,
|
||||||
|
expires_at: futureExpiration(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(blockedAdminResponse.status()).toBe(401);
|
||||||
|
expect(await blockedAdminResponse.json()).toMatchObject({ code: 'AUTH_SITE_PASSWORD_INVALID' });
|
||||||
|
|
||||||
|
const createResponse = await request.post(
|
||||||
|
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/batch`,
|
||||||
|
{
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: {
|
||||||
|
count: 1,
|
||||||
|
redeem_type: 'role',
|
||||||
|
value: 'case-role',
|
||||||
|
enabled: true,
|
||||||
|
expires_at: futureExpiration(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(createResponse.ok()).toBe(true);
|
||||||
|
const code = (await createResponse.json()).codes[0] as string;
|
||||||
|
|
||||||
|
for (const path of ['query', 'result', 'redeem']) {
|
||||||
|
const missingPassword = await request.post(
|
||||||
|
`${WORKER_URL_SITE_PASSWORD}/redeem_api/${path}`,
|
||||||
|
{ data: { code } },
|
||||||
|
);
|
||||||
|
expect(missingPassword.status()).toBe(401);
|
||||||
|
expect(await missingPassword.json()).toMatchObject({ code: 'AUTH_SITE_PASSWORD_INVALID' });
|
||||||
|
const wrongPassword = await request.post(
|
||||||
|
`${WORKER_URL_SITE_PASSWORD}/redeem_api/${path}`,
|
||||||
|
{ headers: { 'x-custom-auth': 'wrong' }, data: { code } },
|
||||||
|
);
|
||||||
|
expect(wrongPassword.status()).toBe(401);
|
||||||
|
expect(await wrongPassword.json()).toMatchObject({ code: 'AUTH_SITE_PASSWORD_INVALID' });
|
||||||
|
}
|
||||||
|
const validPassword = await request.post(`${WORKER_URL_SITE_PASSWORD}/redeem_api/query`, {
|
||||||
|
headers: SITE_HEADERS,
|
||||||
|
data: { code },
|
||||||
|
});
|
||||||
|
expect(validPassword.ok()).toBe(true);
|
||||||
|
expect(await validPassword.json()).toEqual({
|
||||||
|
redeem_type: 'role', value: 'case-role', status: 'unused',
|
||||||
|
});
|
||||||
|
|
||||||
|
const listResponse = await request.get(
|
||||||
|
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes?redeem_type=role`
|
||||||
|
+ `&limit=20&offset=0&query=${encodeURIComponent(code)}`,
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
const row = (await listResponse.json()).results[0];
|
||||||
|
const deleteResponse = await request.delete(
|
||||||
|
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/${row.id}`,
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
expect(deleteResponse.ok()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('special-address redemption preserves the configured address regex', async ({ request }) => {
|
||||||
|
const createResponse = await request.post(
|
||||||
|
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/batch`,
|
||||||
|
{
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: {
|
||||||
|
count: 1,
|
||||||
|
redeem_type: 'address_prefix_once',
|
||||||
|
value: '',
|
||||||
|
enabled: true,
|
||||||
|
expires_at: futureExpiration(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(createResponse.ok()).toBe(true);
|
||||||
|
const code = (await createResponse.json()).codes[0] as string;
|
||||||
|
|
||||||
|
const redeemResponse = await request.post(
|
||||||
|
`${WORKER_URL_SITE_PASSWORD}/redeem_api/redeem`,
|
||||||
|
{
|
||||||
|
headers: SITE_HEADERS,
|
||||||
|
data: { code, name: 'blocked', domain: 'test.example.com' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(redeemResponse.status()).toBe(400);
|
||||||
|
|
||||||
|
const queryResponse = await request.post(
|
||||||
|
`${WORKER_URL_SITE_PASSWORD}/redeem_api/query`,
|
||||||
|
{ headers: SITE_HEADERS, data: { code } },
|
||||||
|
);
|
||||||
|
expect(queryResponse.ok()).toBe(true);
|
||||||
|
|
||||||
|
const listResponse = await request.get(
|
||||||
|
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes?redeem_type=address_prefix_once`
|
||||||
|
+ `&limit=20&offset=0&query=${encodeURIComponent(code)}`,
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
const row = (await listResponse.json()).results[0];
|
||||||
|
const deleteResponse = await request.delete(
|
||||||
|
`${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes/${row.id}`,
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
expect(deleteResponse.ok()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Redemption Admin authentication', () => {
|
||||||
|
const baseUrl = `${WORKER_URL_SITE_PASSWORD}/admin/redeem_codes`;
|
||||||
|
const listUrl = `${baseUrl}?redeem_type=address_prefix_once&limit=100&offset=0`;
|
||||||
|
const codeData = () => ({
|
||||||
|
count: 1,
|
||||||
|
redeem_type: 'address_prefix_once',
|
||||||
|
value: 'auth',
|
||||||
|
enabled: true,
|
||||||
|
expires_at: futureExpiration(),
|
||||||
|
});
|
||||||
|
const deniedCredentials: { name: string; headers: () => Record<string, string> }[] = [
|
||||||
|
{ name: 'site password alone', headers: () => ({}) },
|
||||||
|
{ name: 'wrong Admin password', headers: () => ({ 'x-admin-auth': 'wrong' }) },
|
||||||
|
{
|
||||||
|
name: 'mailbox JWT',
|
||||||
|
headers: () => ({
|
||||||
|
Authorization: `Bearer ${signTestToken({ address: 'mail@test.example.com', address_id: 1 })}`,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'user account JWT',
|
||||||
|
headers: () => ({
|
||||||
|
'x-user-token': signTestToken({ user_id: 1, exp: Math.floor(Date.now() / 1000) + 3600 }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'non-Admin role token',
|
||||||
|
headers: () => ({ 'x-user-access-token': signTestToken(tokenPayload('case-role')) }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'forged Admin role token',
|
||||||
|
headers: () => ({ 'x-user-access-token': signTestToken(tokenPayload('admin'), 'wrong-secret') }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'expired Admin role token',
|
||||||
|
headers: () => ({
|
||||||
|
'x-user-access-token': signTestToken({ ...tokenPayload('admin'), exp: Math.floor(Date.now() / 1000) - 60 }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Admin role token without expiration',
|
||||||
|
headers: () => ({ 'x-user-access-token': signTestToken({ user_id: 1, user_role: 'admin' }) }),
|
||||||
|
},
|
||||||
|
{ name: 'malformed role token', headers: () => ({ 'x-user-access-token': 'not-a-jwt' }) },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const credentials of deniedCredentials) {
|
||||||
|
test(`rejects ${credentials.name} on all five endpoints without changing data`, async ({ request }) => {
|
||||||
|
const original = codeData();
|
||||||
|
const created = await request.post(`${baseUrl}/batch`, { headers: ADMIN_HEADERS, data: original });
|
||||||
|
expect(created.ok()).toBe(true);
|
||||||
|
const code = (await created.json()).codes[0] as string;
|
||||||
|
const beforeResponse = await request.get(listUrl, { headers: ADMIN_HEADERS });
|
||||||
|
expect(beforeResponse.ok()).toBe(true);
|
||||||
|
const before = await beforeResponse.json();
|
||||||
|
const row = before.results.find((item: { code: string }) => item.code === code);
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
try {
|
||||||
|
const headers = { ...SITE_HEADERS, ...credentials.headers() };
|
||||||
|
const responses = await Promise.all([
|
||||||
|
request.get(listUrl, { headers }),
|
||||||
|
request.get(`${baseUrl}/export?redeem_type=address_prefix_once&limit=100`, { headers }),
|
||||||
|
request.post(`${baseUrl}/batch`, { headers, data: original }),
|
||||||
|
request.put(`${baseUrl}/${row.id}`, { headers, data: { ...original, value: 'changed' } }),
|
||||||
|
request.delete(`${baseUrl}/${row.id}`, { headers }),
|
||||||
|
]);
|
||||||
|
for (const response of responses) {
|
||||||
|
expect(response.status(), response.url()).toBe(401);
|
||||||
|
expect(await response.json()).toMatchObject({
|
||||||
|
code: credentials.name === 'expired Admin role token'
|
||||||
|
? 'AUTH_USER_ACCESS_TOKEN_EXPIRED' : 'AUTH_ADMIN_CREDENTIAL_INVALID',
|
||||||
|
});
|
||||||
|
expect(await response.text()).not.toContain(code);
|
||||||
|
}
|
||||||
|
const after = await request.get(listUrl, { headers: ADMIN_HEADERS });
|
||||||
|
expect(after.ok()).toBe(true);
|
||||||
|
expect(await after.json()).toEqual(before);
|
||||||
|
} finally {
|
||||||
|
const deleted = await request.delete(`${baseUrl}/${row.id}`, { headers: ADMIN_HEADERS });
|
||||||
|
expect(deleted.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const authType of ['password', 'role token'] as const) {
|
||||||
|
test(`accepts a valid Admin ${authType} for all five endpoints`, async ({ request }) => {
|
||||||
|
const headers: Record<string, string> = authType === 'password'
|
||||||
|
? ADMIN_HEADERS
|
||||||
|
: { ...SITE_HEADERS, 'x-user-access-token': signTestToken(tokenPayload('admin')) };
|
||||||
|
const original = codeData();
|
||||||
|
const created = await request.post(`${baseUrl}/batch`, { headers, data: original });
|
||||||
|
expect(created.ok()).toBe(true);
|
||||||
|
const code = (await created.json()).codes[0] as string;
|
||||||
|
const listResponse = await request.get(listUrl, { headers });
|
||||||
|
expect(listResponse.ok()).toBe(true);
|
||||||
|
const row = (await listResponse.json()).results.find((item: { code: string }) => item.code === code);
|
||||||
|
expect(row).toBeDefined();
|
||||||
|
let deleted = false;
|
||||||
|
try {
|
||||||
|
const exported = await request.get(`${baseUrl}/export?redeem_type=address_prefix_once&limit=100`, { headers });
|
||||||
|
expect(exported.ok()).toBe(true);
|
||||||
|
expect(exported.headers()['content-type']).toContain('text/csv');
|
||||||
|
expect(await exported.text()).toContain(code);
|
||||||
|
|
||||||
|
const updated = await request.put(`${baseUrl}/${row.id}`, { headers, data: { ...original, value: 'updated' } });
|
||||||
|
expect(updated.ok()).toBe(true);
|
||||||
|
const afterUpdate = await request.get(`${listUrl}&query=${encodeURIComponent(code)}`, { headers });
|
||||||
|
expect(afterUpdate.ok()).toBe(true);
|
||||||
|
expect((await afterUpdate.json()).results).toEqual([
|
||||||
|
expect.objectContaining({ id: row.id, code, value: 'updated' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const deletion = await request.delete(`${baseUrl}/${row.id}`, { headers });
|
||||||
|
expect(deletion.ok()).toBe(true);
|
||||||
|
deleted = true;
|
||||||
|
const afterDelete = await request.get(`${listUrl}&query=${encodeURIComponent(code)}`, { headers });
|
||||||
|
expect(afterDelete.ok()).toBe(true);
|
||||||
|
expect(await afterDelete.json()).toEqual({ results: [], count: 0 });
|
||||||
|
} finally {
|
||||||
|
if (!deleted) {
|
||||||
|
const cleanup = await request.delete(`${baseUrl}/${row.id}`, { headers: ADMIN_HEADERS });
|
||||||
|
expect(cleanup.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||||
|
import { deleteAddress, WORKER_URL } from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
|
||||||
|
const MAX_BATCH_SIZE = 500;
|
||||||
|
const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||||
|
|
||||||
|
type RedeemType = 'role' | 'send_balance' | 'address_prefix_once';
|
||||||
|
|
||||||
|
async function createCodes(
|
||||||
|
request: APIRequestContext,
|
||||||
|
{
|
||||||
|
count = 1,
|
||||||
|
type = 'role',
|
||||||
|
value = 'case-role',
|
||||||
|
enabled = true,
|
||||||
|
expiresAt = new Date(Date.now() + 3_600_000).toISOString(),
|
||||||
|
}: {
|
||||||
|
count?: unknown;
|
||||||
|
type?: string;
|
||||||
|
value?: unknown;
|
||||||
|
enabled?: unknown;
|
||||||
|
expiresAt?: unknown;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return await request.post(`${WORKER_URL}/admin/redeem_codes/batch`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: {
|
||||||
|
count,
|
||||||
|
redeem_type: type,
|
||||||
|
value,
|
||||||
|
enabled,
|
||||||
|
expires_at: expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listCodes(
|
||||||
|
request: APIRequestContext,
|
||||||
|
type: RedeemType,
|
||||||
|
query = '',
|
||||||
|
) {
|
||||||
|
const response = await request.get(
|
||||||
|
`${WORKER_URL}/admin/redeem_codes?redeem_type=${type}&limit=100&offset=0`
|
||||||
|
+ (query ? `&query=${encodeURIComponent(query)}` : ''),
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCode(request: APIRequestContext, id: number) {
|
||||||
|
const response = await request.delete(`${WORKER_URL}/admin/redeem_codes/${id}`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Redemption code Admin API', () => {
|
||||||
|
test('rejects malformed JSON when creating codes', async ({ request }) => {
|
||||||
|
const response = await request.post(`${WORKER_URL}/admin/redeem_codes/batch`, {
|
||||||
|
headers: { ...ADMIN_HEADERS, 'content-type': 'application/json' },
|
||||||
|
data: '{',
|
||||||
|
});
|
||||||
|
expect(response.status()).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports the enabled switch and exposes the migrated table', async ({ request }) => {
|
||||||
|
const settingsResponse = await request.get(`${WORKER_URL}/open_api/settings`);
|
||||||
|
expect(settingsResponse.ok()).toBe(true);
|
||||||
|
expect((await settingsResponse.json()).enableRedeemCode).toBe(true);
|
||||||
|
|
||||||
|
const listResponse = await request.get(
|
||||||
|
`${WORKER_URL}/admin/redeem_codes?redeem_type=role&limit=20&offset=0`,
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
expect(listResponse.ok()).toBe(true);
|
||||||
|
await expect(listResponse.json()).resolves.toMatchObject({
|
||||||
|
results: expect.any(Array),
|
||||||
|
count: expect.any(Number),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('batch generates UUIDs, filters by type, updates, and deletes', async ({ request }) => {
|
||||||
|
const createResponse = await createCodes(request, { count: 2 });
|
||||||
|
expect(createResponse.ok()).toBe(true);
|
||||||
|
const created = await createResponse.json();
|
||||||
|
expect(created).toMatchObject({ success: true, created: 2 });
|
||||||
|
expect(created.codes).toHaveLength(2);
|
||||||
|
expect(new Set(created.codes).size).toBe(2);
|
||||||
|
expect(created.codes.every((code: string) => UUID_V4_PATTERN.test(code))).toBe(true);
|
||||||
|
const [firstCode, secondCode] = created.codes as string[];
|
||||||
|
const queryPrefix = firstCode.slice(0, 8);
|
||||||
|
|
||||||
|
const roleList = await listCodes(request, 'role', queryPrefix);
|
||||||
|
expect(roleList.count).toBe(1);
|
||||||
|
expect(roleList.results[0]).toMatchObject({
|
||||||
|
code: firstCode,
|
||||||
|
redeem_type: 'role',
|
||||||
|
value: 'case-role',
|
||||||
|
enabled: 1,
|
||||||
|
redeemed: false,
|
||||||
|
result: null,
|
||||||
|
});
|
||||||
|
const balanceList = await listCodes(request, 'send_balance', queryPrefix);
|
||||||
|
expect(balanceList.count).toBe(0);
|
||||||
|
|
||||||
|
const firstId = roleList.results[0].id as number;
|
||||||
|
const expiresAt = new Date(Date.now() + 3_600_000).toISOString();
|
||||||
|
const updateResponse = await request.put(`${WORKER_URL}/admin/redeem_codes/${firstId}`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: {
|
||||||
|
redeem_type: 'role',
|
||||||
|
value: 'empty-role',
|
||||||
|
enabled: false,
|
||||||
|
expires_at: expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(updateResponse.ok()).toBe(true);
|
||||||
|
|
||||||
|
const updatedList = await listCodes(request, 'role', firstCode);
|
||||||
|
expect(updatedList.count).toBe(1);
|
||||||
|
expect(updatedList.results[0]).toMatchObject({
|
||||||
|
id: firstId,
|
||||||
|
code: firstCode,
|
||||||
|
value: 'empty-role',
|
||||||
|
enabled: 0,
|
||||||
|
});
|
||||||
|
expect(updatedList.results[0].expires_at).toEqual(expect.any(String));
|
||||||
|
|
||||||
|
const secondList = await listCodes(request, 'role', secondCode);
|
||||||
|
await deleteCode(request, firstId);
|
||||||
|
await deleteCode(request, secondList.results[0].id);
|
||||||
|
expect((await listCodes(request, 'role', firstCode)).count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizes typed values and accepts both empty and maximum usable prefixes', async ({ request }) => {
|
||||||
|
const emptyResponse = await createCodes(request, {
|
||||||
|
type: 'address_prefix_once',
|
||||||
|
value: ' ',
|
||||||
|
});
|
||||||
|
expect(emptyResponse.ok()).toBe(true);
|
||||||
|
const emptyCode = (await emptyResponse.json()).codes[0] as string;
|
||||||
|
const maxResponse = await createCodes(request, {
|
||||||
|
type: 'address_prefix_once',
|
||||||
|
value: ` ${'A'.repeat(29)} `,
|
||||||
|
});
|
||||||
|
expect(maxResponse.ok()).toBe(true);
|
||||||
|
const maxCode = (await maxResponse.json()).codes[0] as string;
|
||||||
|
const balanceResponse = await createCodes(request, {
|
||||||
|
type: 'send_balance',
|
||||||
|
value: ' 42 ',
|
||||||
|
});
|
||||||
|
expect(balanceResponse.ok()).toBe(true);
|
||||||
|
const balanceCode = (await balanceResponse.json()).codes[0] as string;
|
||||||
|
|
||||||
|
const emptyRow = (await listCodes(request, 'address_prefix_once', emptyCode)).results[0];
|
||||||
|
const maxRow = (await listCodes(request, 'address_prefix_once', maxCode)).results[0];
|
||||||
|
const balanceRow = (await listCodes(request, 'send_balance', balanceCode)).results[0];
|
||||||
|
expect(emptyRow.value).toBe('');
|
||||||
|
expect(maxRow.value).toBe('a'.repeat(29));
|
||||||
|
expect(balanceRow.value).toBe('42');
|
||||||
|
await deleteCode(request, emptyRow.id);
|
||||||
|
await deleteCode(request, maxRow.id);
|
||||||
|
await deleteCode(request, balanceRow.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects every invalid batch shape and typed business value', async ({ request }) => {
|
||||||
|
const invalidRequests = [
|
||||||
|
{ type: 'unknown', value: 'x' },
|
||||||
|
{ type: 'role', value: '' },
|
||||||
|
{ type: 'role', value: 'missing-role' },
|
||||||
|
{ type: 'role', value: 'admin' },
|
||||||
|
{ type: 'send_balance', value: '0' },
|
||||||
|
{ type: 'send_balance', value: '-1' },
|
||||||
|
{ type: 'send_balance', value: '1.5' },
|
||||||
|
{ type: 'send_balance', value: '1000000001' },
|
||||||
|
{ type: 'address_prefix_once', value: 'bad-' },
|
||||||
|
{ type: 'address_prefix_once', value: 'a'.repeat(30) },
|
||||||
|
{ count: 0 },
|
||||||
|
{ count: -1 },
|
||||||
|
{ count: 1.5 },
|
||||||
|
{ count: MAX_BATCH_SIZE + 1 },
|
||||||
|
{ count: '1' },
|
||||||
|
{ count: null },
|
||||||
|
{ enabled: 'yes' },
|
||||||
|
{ value: 1 },
|
||||||
|
{ expiresAt: null },
|
||||||
|
{ expiresAt: '' },
|
||||||
|
{ expiresAt: 'not-a-date' },
|
||||||
|
{ expiresAt: new Date(Date.now() - 60_000).toISOString() },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const invalidRequest of invalidRequests) {
|
||||||
|
const response = await createCodes(request, invalidRequest);
|
||||||
|
expect(response.status(), JSON.stringify(invalidRequest)).toBe(400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects invalid updates and IDs', async ({ request }) => {
|
||||||
|
const expiresAt = new Date(Date.now() + 3_600_000).toISOString();
|
||||||
|
const createResponse = await createCodes(request, { count: 2 });
|
||||||
|
expect(createResponse.ok()).toBe(true);
|
||||||
|
const [firstCode, secondCode] = (await createResponse.json()).codes as string[];
|
||||||
|
const firstRow = (await listCodes(request, 'role', firstCode)).results[0];
|
||||||
|
const secondRow = (await listCodes(request, 'role', secondCode)).results[0];
|
||||||
|
|
||||||
|
const invalidId = await request.put(`${WORKER_URL}/admin/redeem_codes/${firstRow.id}x`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: { redeem_type: 'role', value: 'case-role', enabled: true, expires_at: expiresAt },
|
||||||
|
});
|
||||||
|
expect(invalidId.status()).toBe(400);
|
||||||
|
|
||||||
|
const invalidRole = await request.put(`${WORKER_URL}/admin/redeem_codes/${firstRow.id}`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: { redeem_type: 'role', value: 'admin', enabled: true, expires_at: expiresAt },
|
||||||
|
});
|
||||||
|
expect(invalidRole.status()).toBe(400);
|
||||||
|
|
||||||
|
const wrongType = await request.put(`${WORKER_URL}/admin/redeem_codes/${firstRow.id}`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: {
|
||||||
|
redeem_type: 'send_balance',
|
||||||
|
value: '1',
|
||||||
|
enabled: true,
|
||||||
|
expires_at: expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(wrongType.status()).toBe(404);
|
||||||
|
|
||||||
|
const invalidDelete = await request.delete(`${WORKER_URL}/admin/redeem_codes/${firstRow.id}x`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
});
|
||||||
|
expect(invalidDelete.status()).toBe(400);
|
||||||
|
|
||||||
|
await deleteCode(request, firstRow.id);
|
||||||
|
await deleteCode(request, secondRow.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not update a redeemed code', async ({ request }) => {
|
||||||
|
let code = '';
|
||||||
|
let addressResult: { jwt?: string } | undefined;
|
||||||
|
try {
|
||||||
|
const createResponse = await createCodes(request, {
|
||||||
|
type: 'address_prefix_once',
|
||||||
|
value: 'locked',
|
||||||
|
});
|
||||||
|
expect(createResponse.ok()).toBe(true);
|
||||||
|
code = (await createResponse.json()).codes[0] as string;
|
||||||
|
|
||||||
|
const redeemResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, name: 'mail', domain: 'test.example.com' },
|
||||||
|
});
|
||||||
|
expect(redeemResponse.ok()).toBe(true);
|
||||||
|
addressResult = await redeemResponse.json();
|
||||||
|
|
||||||
|
const row = (await listCodes(request, 'address_prefix_once', code)).results[0];
|
||||||
|
expect(row.redeemed).toBe(true);
|
||||||
|
expect(row.redeemed_at).toEqual(expect.any(String));
|
||||||
|
const updateResponse = await request.put(`${WORKER_URL}/admin/redeem_codes/${row.id}`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: {
|
||||||
|
redeem_type: 'address_prefix_once',
|
||||||
|
value: 'changed',
|
||||||
|
enabled: false,
|
||||||
|
expires_at: new Date(Date.now() + 3_600_000).toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(updateResponse.status()).toBe(409);
|
||||||
|
|
||||||
|
const unchanged = (await listCodes(request, 'address_prefix_once', code)).results[0];
|
||||||
|
expect(unchanged).toMatchObject({
|
||||||
|
code,
|
||||||
|
value: 'locked',
|
||||||
|
enabled: 1,
|
||||||
|
redeemed: true,
|
||||||
|
});
|
||||||
|
await deleteCode(request, row.id);
|
||||||
|
} finally {
|
||||||
|
if (addressResult?.jwt) await deleteAddress(request, addressResult.jwt);
|
||||||
|
const row = (await listCodes(request, 'address_prefix_once', code)).results[0];
|
||||||
|
if (row) await deleteCode(request, row.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exports one selected type with its business columns and enforces the limit', async ({ request }) => {
|
||||||
|
const roleResponse = await createCodes(request, {});
|
||||||
|
expect(roleResponse.ok()).toBe(true);
|
||||||
|
const roleCode = (await roleResponse.json()).codes[0] as string;
|
||||||
|
const balanceResponse = await createCodes(request, {
|
||||||
|
type: 'send_balance',
|
||||||
|
value: '25',
|
||||||
|
});
|
||||||
|
expect(balanceResponse.ok()).toBe(true);
|
||||||
|
const balanceCode = (await balanceResponse.json()).codes[0] as string;
|
||||||
|
|
||||||
|
const roleExport = await request.get(
|
||||||
|
`${WORKER_URL}/admin/redeem_codes/export?redeem_type=role&limit=100`,
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
expect(roleExport.ok()).toBe(true);
|
||||||
|
expect(roleExport.headers()['content-type']).toContain('text/csv');
|
||||||
|
expect(roleExport.headers()['content-disposition']).toContain('redeem-codes-role.csv');
|
||||||
|
const roleCsv = await roleExport.text();
|
||||||
|
expect(roleCsv).toContain('role,redeemed_user_id');
|
||||||
|
expect(roleCsv).toContain(roleCode);
|
||||||
|
expect(roleCsv).not.toContain(balanceCode);
|
||||||
|
|
||||||
|
const balanceExport = await request.get(
|
||||||
|
`${WORKER_URL}/admin/redeem_codes/export?redeem_type=send_balance&limit=100`,
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
const balanceCsv = await balanceExport.text();
|
||||||
|
expect(balanceCsv).toContain('amount,target_address');
|
||||||
|
expect(balanceCsv).toContain(balanceCode);
|
||||||
|
expect(balanceCsv).not.toContain(roleCode);
|
||||||
|
|
||||||
|
for (const query of [
|
||||||
|
'redeem_type=unknown&limit=1',
|
||||||
|
'redeem_type=role&limit=0',
|
||||||
|
'redeem_type=role&limit=10001',
|
||||||
|
'redeem_type=role&limit=1x',
|
||||||
|
'redeem_type=role',
|
||||||
|
]) {
|
||||||
|
const response = await request.get(`${WORKER_URL}/admin/redeem_codes/export?${query}`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
});
|
||||||
|
expect(response.status(), query).toBe(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleRow = (await listCodes(request, 'role', roleCode)).results[0];
|
||||||
|
const balanceRow = (await listCodes(request, 'send_balance', balanceCode)).results[0];
|
||||||
|
await deleteCode(request, roleRow.id);
|
||||||
|
await deleteCode(request, balanceRow.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,628 @@
|
|||||||
|
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
WORKER_URL,
|
||||||
|
createTestAddress,
|
||||||
|
deleteAddress,
|
||||||
|
getAddressSender,
|
||||||
|
hashPassword,
|
||||||
|
} from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
|
||||||
|
|
||||||
|
type RedeemType = 'role' | 'send_balance' | 'address_prefix_once';
|
||||||
|
type AddressRedeemResult = {
|
||||||
|
type: 'address_prefix_once';
|
||||||
|
address: string;
|
||||||
|
address_id: number;
|
||||||
|
jwt: string;
|
||||||
|
password?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const uniqueValue = (label: string) => (
|
||||||
|
`${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
async function createCode(
|
||||||
|
request: APIRequestContext,
|
||||||
|
type: RedeemType,
|
||||||
|
value: string,
|
||||||
|
options: { enabled?: boolean; expiresAt?: string } = {},
|
||||||
|
) {
|
||||||
|
const response = await request.post(`${WORKER_URL}/admin/redeem_codes/batch`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: {
|
||||||
|
count: 1,
|
||||||
|
redeem_type: type,
|
||||||
|
value,
|
||||||
|
enabled: options.enabled ?? true,
|
||||||
|
expires_at: options.expiresAt ?? new Date(Date.now() + 3_600_000).toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.codes).toHaveLength(1);
|
||||||
|
return body.codes[0] as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listCode(request: APIRequestContext, type: RedeemType, code: string) {
|
||||||
|
const response = await request.get(
|
||||||
|
`${WORKER_URL}/admin/redeem_codes?redeem_type=${type}`
|
||||||
|
+ `&limit=100&offset=0&query=${encodeURIComponent(code)}`,
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
const body = await response.json();
|
||||||
|
return body.results.find((row: { code: string }) => row.code === code);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCode(request: APIRequestContext, type: RedeemType, code: string) {
|
||||||
|
const row = await listCode(request, type, code);
|
||||||
|
if (!row) return;
|
||||||
|
const response = await request.delete(`${WORKER_URL}/admin/redeem_codes/${row.id}`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryCode(request: APIRequestContext, code: unknown) {
|
||||||
|
return await request.post(`${WORKER_URL}/redeem_api/query`, { data: { code } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryResult(request: APIRequestContext, code: unknown) {
|
||||||
|
return await request.post(`${WORKER_URL}/redeem_api/result`, { data: { code } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(request: APIRequestContext) {
|
||||||
|
const email = `${uniqueValue('redeem-user')}@test.example.com`;
|
||||||
|
const createResponse = await request.post(`${WORKER_URL}/admin/users`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: { email, password: hashPassword('redeem-user-password') },
|
||||||
|
});
|
||||||
|
expect(createResponse.ok()).toBe(true);
|
||||||
|
const listResponse = await request.get(
|
||||||
|
`${WORKER_URL}/admin/users?limit=10&offset=0&query=${encodeURIComponent(email)}`,
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
const user = (await listResponse.json()).results.find(
|
||||||
|
(row: { user_email: string }) => row.user_email === email,
|
||||||
|
);
|
||||||
|
expect(user).toBeTruthy();
|
||||||
|
await setUserRole(request, user.id, null);
|
||||||
|
return { id: user.id as number, email };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setUserRole(request: APIRequestContext, userId: number, role: string | null) {
|
||||||
|
const response = await request.post(`${WORKER_URL}/admin/user_roles`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: { user_id: userId, role_text: role },
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser(request: APIRequestContext, userId: number) {
|
||||||
|
await setUserRole(request, userId, null);
|
||||||
|
const response = await request.delete(`${WORKER_URL}/admin/users/${userId}`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Redemption code query and guards', () => {
|
||||||
|
test('previews each supported type without consuming it', async ({ request }) => {
|
||||||
|
const roleCode = await createCode(request, 'role', 'case-role');
|
||||||
|
const balanceCode = await createCode(request, 'send_balance', '12');
|
||||||
|
const addressCode = await createCode(request, 'address_prefix_once', 'vip');
|
||||||
|
try {
|
||||||
|
const roleResponse = await queryCode(request, roleCode);
|
||||||
|
expect(roleResponse.ok()).toBe(true);
|
||||||
|
expect(await roleResponse.json()).toEqual({
|
||||||
|
redeem_type: 'role', value: 'case-role', status: 'unused',
|
||||||
|
});
|
||||||
|
|
||||||
|
const balanceResponse = await queryCode(request, balanceCode);
|
||||||
|
expect(balanceResponse.ok()).toBe(true);
|
||||||
|
expect(await balanceResponse.json()).toEqual({
|
||||||
|
redeem_type: 'send_balance', value: '12', status: 'unused',
|
||||||
|
});
|
||||||
|
|
||||||
|
const addressResponse = await queryCode(request, addressCode);
|
||||||
|
expect(addressResponse.ok()).toBe(true);
|
||||||
|
expect(await addressResponse.json()).toEqual({
|
||||||
|
redeem_type: 'address_prefix_once',
|
||||||
|
value: 'vip',
|
||||||
|
status: 'unused',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await listCode(request, 'role', roleCode)).redeemed_at).toBeNull();
|
||||||
|
expect((await listCode(request, 'send_balance', balanceCode)).redeemed_at).toBeNull();
|
||||||
|
expect((await listCode(request, 'address_prefix_once', addressCode)).redeemed_at).toBeNull();
|
||||||
|
expect((await listCode(request, 'role', roleCode)).redeemed).toBe(false);
|
||||||
|
expect((await listCode(request, 'send_balance', balanceCode)).redeemed).toBe(false);
|
||||||
|
expect((await listCode(request, 'address_prefix_once', addressCode)).redeemed).toBe(false);
|
||||||
|
expect((await queryResult(request, addressCode)).status()).toBe(400);
|
||||||
|
} finally {
|
||||||
|
await deleteCode(request, 'role', roleCode);
|
||||||
|
await deleteCode(request, 'send_balance', balanceCode);
|
||||||
|
await deleteCode(request, 'address_prefix_once', addressCode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects missing, malformed, unknown, and disabled codes, but reports expired codes', async ({ request }) => {
|
||||||
|
const disabledCode = await createCode(request, 'role', 'case-role', { enabled: false });
|
||||||
|
const expiredCode = await createCode(request, 'role', 'case-role', {
|
||||||
|
expiresAt: new Date(Date.now() + 5_000).toISOString(),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5_500));
|
||||||
|
for (const code of [undefined, null, '', 'x'.repeat(257), uniqueValue('missing')]) {
|
||||||
|
expect((await queryCode(request, code)).status()).toBe(400);
|
||||||
|
}
|
||||||
|
expect((await queryCode(request, disabledCode)).status()).toBe(400);
|
||||||
|
const expiredResponse = await queryCode(request, expiredCode);
|
||||||
|
expect(expiredResponse.ok()).toBe(true);
|
||||||
|
expect(await expiredResponse.json()).toEqual({
|
||||||
|
redeem_type: 'role', value: 'case-role', status: 'expired',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await deleteCode(request, 'role', disabledCode);
|
||||||
|
await deleteCode(request, 'role', expiredCode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects malformed JSON request bodies', async ({ request }) => {
|
||||||
|
const options = {
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
data: '{',
|
||||||
|
};
|
||||||
|
for (const path of ['/query', '/result', '/redeem']) {
|
||||||
|
const response = await request.post(`${WORKER_URL}/redeem_api${path}`, options);
|
||||||
|
expect(response.status()).toBe(400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses the code type and does not consume when its required target is missing', async ({ request }) => {
|
||||||
|
const code = await createCode(request, 'send_balance', '9');
|
||||||
|
try {
|
||||||
|
const mismatchResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, user_email: 'nobody@test.example.com' },
|
||||||
|
});
|
||||||
|
expect(mismatchResponse.status()).toBe(400);
|
||||||
|
expect((await queryCode(request, code)).ok()).toBe(true);
|
||||||
|
expect((await listCode(request, 'send_balance', code)).redeemed_at).toBeNull();
|
||||||
|
} finally {
|
||||||
|
await deleteCode(request, 'send_balance', code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Role redemption', () => {
|
||||||
|
test('concurrent different roles consume only the winning code', async ({ request }) => {
|
||||||
|
const user = await createUser(request);
|
||||||
|
const roles = ['case-role', 'empty-role'];
|
||||||
|
const codes = await Promise.all(roles.map((role) => createCode(request, 'role', role)));
|
||||||
|
try {
|
||||||
|
const responses = await Promise.all(codes.map((code) => (
|
||||||
|
request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, user_email: user.email },
|
||||||
|
})
|
||||||
|
)));
|
||||||
|
expect(responses.filter((response) => response.ok())).toHaveLength(1);
|
||||||
|
expect(responses.filter((response) => response.status() === 409)).toHaveLength(1);
|
||||||
|
const winner = responses.findIndex((response) => response.ok());
|
||||||
|
const loser = 1 - winner;
|
||||||
|
const rows = await Promise.all(codes.map((code) => listCode(request, 'role', code)));
|
||||||
|
expect(rows[winner].redeemed).toBe(true);
|
||||||
|
expect(rows[loser]).toMatchObject({ redeemed: false, result: null, redeemed_at: null });
|
||||||
|
const users = await request.get(
|
||||||
|
`${WORKER_URL}/admin/users?limit=10&offset=0&query=${encodeURIComponent(user.email)}`,
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
expect((await users.json()).results[0].role_text).toBe(roles[winner]);
|
||||||
|
} finally {
|
||||||
|
for (const code of codes) await deleteCode(request, 'role', code);
|
||||||
|
await deleteUser(request, user.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applies a configured role by case-insensitive account email and consumes once', async ({ request }) => {
|
||||||
|
const user = await createUser(request);
|
||||||
|
const code = await createCode(request, 'role', 'case-role');
|
||||||
|
try {
|
||||||
|
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, user_email: user.email.toUpperCase() },
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
success: true,
|
||||||
|
type: 'role',
|
||||||
|
role: 'case-role',
|
||||||
|
user_email: user.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userResponse = await request.get(
|
||||||
|
`${WORKER_URL}/admin/users?limit=10&offset=0&query=${encodeURIComponent(user.email)}`,
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
expect((await userResponse.json()).results[0].role_text).toBe('case-role');
|
||||||
|
|
||||||
|
const row = await listCode(request, 'role', code);
|
||||||
|
expect(row.redeemed).toBe(true);
|
||||||
|
expect(row.redeemed_at).toEqual(expect.any(String));
|
||||||
|
expect(JSON.parse(row.result)).toMatchObject({
|
||||||
|
type: 'role',
|
||||||
|
user_id: user.id,
|
||||||
|
user_email: user.email,
|
||||||
|
role: 'case-role',
|
||||||
|
});
|
||||||
|
const publicResult = await queryResult(request, code);
|
||||||
|
expect(publicResult.ok()).toBe(true);
|
||||||
|
expect(await publicResult.json()).toEqual({
|
||||||
|
type: 'role', role: 'case-role', user_email: user.email,
|
||||||
|
});
|
||||||
|
|
||||||
|
const repeatedResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, user_email: user.email },
|
||||||
|
});
|
||||||
|
expect(repeatedResponse.status()).toBe(400);
|
||||||
|
expect(await (await queryCode(request, code)).json()).toEqual({
|
||||||
|
redeem_type: 'role', value: 'case-role', status: 'redeemed',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await deleteCode(request, 'role', code);
|
||||||
|
await deleteUser(request, user.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts the same existing role', async ({ request }) => {
|
||||||
|
const user = await createUser(request);
|
||||||
|
const code = await createCode(request, 'role', 'case-role');
|
||||||
|
try {
|
||||||
|
await setUserRole(request, user.id, 'case-role');
|
||||||
|
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, user_email: user.email },
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await deleteCode(request, 'role', code);
|
||||||
|
await deleteUser(request, user.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the code unused when the account is missing or has a conflicting role', async ({ request }) => {
|
||||||
|
const missingCode = await createCode(request, 'role', 'case-role');
|
||||||
|
const conflictCode = await createCode(request, 'role', 'case-role');
|
||||||
|
const user = await createUser(request);
|
||||||
|
try {
|
||||||
|
const missingResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code: missingCode, user_email: `${uniqueValue('missing')}@test.example.com` },
|
||||||
|
});
|
||||||
|
expect(missingResponse.status()).toBe(400);
|
||||||
|
expect((await queryCode(request, missingCode)).ok()).toBe(true);
|
||||||
|
|
||||||
|
await setUserRole(request, user.id, 'empty-role');
|
||||||
|
const conflictResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code: conflictCode, user_email: user.email },
|
||||||
|
});
|
||||||
|
expect(conflictResponse.status()).toBe(409);
|
||||||
|
expect((await queryCode(request, conflictCode)).ok()).toBe(true);
|
||||||
|
expect((await listCode(request, 'role', conflictCode)).result).toBeNull();
|
||||||
|
} finally {
|
||||||
|
await deleteCode(request, 'role', missingCode);
|
||||||
|
await deleteCode(request, 'role', conflictCode);
|
||||||
|
await deleteUser(request, user.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Sending-credit redemption', () => {
|
||||||
|
test('preserves default credits before the mailbox is first opened', async ({ request }) => {
|
||||||
|
const address = await createTestAddress(request, 'rcfresh');
|
||||||
|
const code = await createCode(request, 'send_balance', '7');
|
||||||
|
try {
|
||||||
|
const before = await request.get(
|
||||||
|
`${WORKER_URL}/admin/address_sender?limit=1&offset=0&address=${encodeURIComponent(address.address)}`,
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
expect((await before.json()).results).toHaveLength(0);
|
||||||
|
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, address: address.address },
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
expect(await response.json()).toMatchObject({ amount: 7, balance: 17 });
|
||||||
|
for (let index = 0; index < 2; index++) {
|
||||||
|
const settings = await request.get(`${WORKER_URL}/api/settings`, {
|
||||||
|
headers: { Authorization: `Bearer ${address.jwt}` },
|
||||||
|
});
|
||||||
|
expect(settings.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
expect((await getAddressSender(request, address.address)).balance).toBe(17);
|
||||||
|
} finally {
|
||||||
|
await deleteCode(request, 'send_balance', code);
|
||||||
|
await deleteAddress(request, address.jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('coalesces NULL balance, preserves disabled state, records result, and consumes once', async ({ request }) => {
|
||||||
|
const address = await createTestAddress(request, 'rcnull');
|
||||||
|
const code = await createCode(request, 'send_balance', '7');
|
||||||
|
try {
|
||||||
|
const settingsResponse = await request.get(`${WORKER_URL}/api/settings`, {
|
||||||
|
headers: { Authorization: `Bearer ${address.jwt}` },
|
||||||
|
});
|
||||||
|
expect(settingsResponse.ok()).toBe(true);
|
||||||
|
const sender = await getAddressSender(request, address.address);
|
||||||
|
const nullResponse = await request.post(`${WORKER_URL}/admin/address_sender`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: {
|
||||||
|
address: address.address,
|
||||||
|
address_id: sender.id,
|
||||||
|
balance: null,
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(nullResponse.ok()).toBe(true);
|
||||||
|
|
||||||
|
const redeemResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, address: address.address.toUpperCase() },
|
||||||
|
});
|
||||||
|
expect(redeemResponse.ok()).toBe(true);
|
||||||
|
expect(await redeemResponse.json()).toEqual({
|
||||||
|
success: true,
|
||||||
|
type: 'send_balance',
|
||||||
|
address: address.address,
|
||||||
|
amount: 7,
|
||||||
|
balance: 7,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedSender = await getAddressSender(request, address.address);
|
||||||
|
expect(updatedSender.balance).toBe(7);
|
||||||
|
expect(updatedSender.enabled).toBe(0);
|
||||||
|
expect(JSON.parse((await listCode(request, 'send_balance', code)).result)).toMatchObject({
|
||||||
|
type: 'send_balance',
|
||||||
|
address: address.address,
|
||||||
|
amount: 7,
|
||||||
|
});
|
||||||
|
const publicResult = await queryResult(request, code);
|
||||||
|
expect(publicResult.ok()).toBe(true);
|
||||||
|
expect(await publicResult.json()).toEqual({
|
||||||
|
type: 'send_balance', address: address.address, amount: 7,
|
||||||
|
});
|
||||||
|
|
||||||
|
const repeatedResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, address: address.address },
|
||||||
|
});
|
||||||
|
expect(repeatedResponse.status()).toBe(400);
|
||||||
|
expect(await (await queryCode(request, code)).json()).toEqual({
|
||||||
|
redeem_type: 'send_balance', value: '7', status: 'redeemed',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await deleteCode(request, 'send_balance', code);
|
||||||
|
await deleteAddress(request, address.jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not consume when the target address is missing', async ({ request }) => {
|
||||||
|
const code = await createCode(request, 'send_balance', '5');
|
||||||
|
try {
|
||||||
|
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, address: `${uniqueValue('missing')}@test.example.com` },
|
||||||
|
});
|
||||||
|
expect(response.status()).toBe(400);
|
||||||
|
expect((await queryCode(request, code)).ok()).toBe(true);
|
||||||
|
expect((await listCode(request, 'send_balance', code)).result).toBeNull();
|
||||||
|
} finally {
|
||||||
|
await deleteCode(request, 'send_balance', code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('concurrent requests add the balance exactly once', async ({ request }) => {
|
||||||
|
const address = await createTestAddress(request, 'rcrace');
|
||||||
|
const code = await createCode(request, 'send_balance', '5');
|
||||||
|
try {
|
||||||
|
await request.get(`${WORKER_URL}/api/settings`, {
|
||||||
|
headers: { Authorization: `Bearer ${address.jwt}` },
|
||||||
|
});
|
||||||
|
const before = await getAddressSender(request, address.address);
|
||||||
|
const responses = await Promise.all([
|
||||||
|
request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, address: address.address },
|
||||||
|
}),
|
||||||
|
request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, address: address.address },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(responses.filter((response) => response.ok())).toHaveLength(1);
|
||||||
|
expect(responses.filter((response) => [400, 409].includes(response.status()))).toHaveLength(1);
|
||||||
|
expect((await getAddressSender(request, address.address)).balance).toBe(before.balance + 5);
|
||||||
|
} finally {
|
||||||
|
await deleteCode(request, 'send_balance', code);
|
||||||
|
await deleteAddress(request, address.jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Special-address redemption', () => {
|
||||||
|
test('uses the redemption prefix without the system prefix and repeats the complete original result', async ({ request }) => {
|
||||||
|
const code = await createCode(request, 'address_prefix_once', 'vip');
|
||||||
|
const name = `a${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
let result: AddressRedeemResult | undefined;
|
||||||
|
try {
|
||||||
|
const firstResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, name, domain: 'TEST.EXAMPLE.COM' },
|
||||||
|
});
|
||||||
|
expect(firstResponse.ok()).toBe(true);
|
||||||
|
result = await firstResponse.json();
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
type: 'address_prefix_once',
|
||||||
|
address: `vip${name}@test.example.com`,
|
||||||
|
address_id: expect.any(Number),
|
||||||
|
jwt: expect.any(String),
|
||||||
|
password: expect.any(String),
|
||||||
|
});
|
||||||
|
expect(result.address).not.toContain('tmpvip');
|
||||||
|
|
||||||
|
const repeatedResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, name: 'ignored', domain: 'manual.example.com' },
|
||||||
|
});
|
||||||
|
expect(repeatedResponse.ok()).toBe(true);
|
||||||
|
expect(await repeatedResponse.json()).toEqual(result);
|
||||||
|
|
||||||
|
const previewResponse = await queryCode(request, code);
|
||||||
|
expect(previewResponse.ok()).toBe(true);
|
||||||
|
const preview = await previewResponse.json();
|
||||||
|
expect(preview).toEqual({
|
||||||
|
redeem_type: 'address_prefix_once', value: 'vip', status: 'redeemed',
|
||||||
|
});
|
||||||
|
const publicResult = await queryResult(request, code);
|
||||||
|
expect(publicResult.ok()).toBe(true);
|
||||||
|
expect(await publicResult.json()).toEqual({
|
||||||
|
type: 'address_prefix_once',
|
||||||
|
address: result.address,
|
||||||
|
jwt: result.jwt,
|
||||||
|
password: result.password,
|
||||||
|
});
|
||||||
|
const row = await listCode(request, 'address_prefix_once', code);
|
||||||
|
expect(JSON.parse(row.result)).toEqual(result);
|
||||||
|
} finally {
|
||||||
|
if (result?.jwt) await deleteAddress(request, result.jwt);
|
||||||
|
await deleteCode(request, 'address_prefix_once', code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports an empty prefix and the longest usable prefix', async ({ request }) => {
|
||||||
|
const emptyCode = await createCode(request, 'address_prefix_once', '');
|
||||||
|
const maxCode = await createCode(request, 'address_prefix_once', 'a'.repeat(29));
|
||||||
|
const emptyName = `n${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
let emptyResult: AddressRedeemResult | undefined;
|
||||||
|
let maxResult: AddressRedeemResult | undefined;
|
||||||
|
try {
|
||||||
|
const emptyResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code: emptyCode, name: emptyName, domain: 'test.example.com' },
|
||||||
|
});
|
||||||
|
expect(emptyResponse.ok()).toBe(true);
|
||||||
|
emptyResult = await emptyResponse.json();
|
||||||
|
expect(emptyResult.address).toBe(`${emptyName}@test.example.com`);
|
||||||
|
|
||||||
|
const maxResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code: maxCode, name: 'z', domain: 'test.example.com' },
|
||||||
|
});
|
||||||
|
expect(maxResponse.ok()).toBe(true);
|
||||||
|
maxResult = await maxResponse.json();
|
||||||
|
expect(maxResult.address.split('@')[0]).toBe(`${'a'.repeat(29)}z`);
|
||||||
|
} finally {
|
||||||
|
if (emptyResult?.jwt) await deleteAddress(request, emptyResult.jwt);
|
||||||
|
if (maxResult?.jwt) await deleteAddress(request, maxResult.jwt);
|
||||||
|
await deleteCode(request, 'address_prefix_once', emptyCode);
|
||||||
|
await deleteCode(request, 'address_prefix_once', maxCode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps length and domain validation without consuming failed codes', async ({ request }) => {
|
||||||
|
const lengthCode = await createCode(request, 'address_prefix_once', 'a'.repeat(29));
|
||||||
|
const domainCode = await createCode(request, 'address_prefix_once', 'vip');
|
||||||
|
try {
|
||||||
|
const lengthResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code: lengthCode, name: 'zz', domain: 'test.example.com' },
|
||||||
|
});
|
||||||
|
expect(lengthResponse.status()).toBe(400);
|
||||||
|
|
||||||
|
const domainResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code: domainCode, name: 'valid', domain: 'invalid.example.com' },
|
||||||
|
});
|
||||||
|
expect(domainResponse.status()).toBe(400);
|
||||||
|
|
||||||
|
expect((await queryCode(request, lengthCode)).ok()).toBe(true);
|
||||||
|
expect((await queryCode(request, domainCode)).ok()).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await deleteCode(request, 'address_prefix_once', lengthCode);
|
||||||
|
await deleteCode(request, 'address_prefix_once', domainCode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generates a valid random suffix when the user omits the name', async ({ request }) => {
|
||||||
|
const code = await createCode(request, 'address_prefix_once', 'rnd');
|
||||||
|
let result: AddressRedeemResult | undefined;
|
||||||
|
try {
|
||||||
|
const response = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, domain: 'test.example.com' },
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
result = await response.json();
|
||||||
|
const localPart = result.address.split('@')[0];
|
||||||
|
expect(localPart.startsWith('rnd')).toBe(true);
|
||||||
|
expect(localPart.length).toBeLessThanOrEqual(30);
|
||||||
|
expect(localPart.length).toBeGreaterThan(3);
|
||||||
|
} finally {
|
||||||
|
if (result?.jwt) await deleteAddress(request, result.jwt);
|
||||||
|
await deleteCode(request, 'address_prefix_once', code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not return an address result after its original address is removed', async ({ request }) => {
|
||||||
|
const code = await createCode(request, 'address_prefix_once', 'removed');
|
||||||
|
try {
|
||||||
|
const firstResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, name: 'mail', domain: 'test.example.com' },
|
||||||
|
});
|
||||||
|
const first = await firstResponse.json();
|
||||||
|
await deleteAddress(request, first.jwt);
|
||||||
|
|
||||||
|
const repeatResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, name: 'ignored', domain: 'test.example.com' },
|
||||||
|
});
|
||||||
|
expect(repeatResponse.status()).toBe(400);
|
||||||
|
const resultResponse = await request.post(`${WORKER_URL}/redeem_api/result`, {
|
||||||
|
data: { code },
|
||||||
|
});
|
||||||
|
expect(resultResponse.status()).toBe(400);
|
||||||
|
} finally {
|
||||||
|
await deleteCode(request, 'address_prefix_once', code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('concurrent creations converge on one complete result', async ({ request }) => {
|
||||||
|
const code = await createCode(request, 'address_prefix_once', 'race');
|
||||||
|
let result: AddressRedeemResult | undefined;
|
||||||
|
try {
|
||||||
|
const responses = await Promise.all([
|
||||||
|
request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, name: `a${Math.random().toString(36).slice(2, 7)}`, domain: 'test.example.com' },
|
||||||
|
}),
|
||||||
|
request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, name: `b${Math.random().toString(36).slice(2, 7)}`, domain: 'test.example.com' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(responses.every((response) => response.ok())).toBe(true);
|
||||||
|
const results = await Promise.all(responses.map((response) => response.json()));
|
||||||
|
expect(results[1]).toEqual(results[0]);
|
||||||
|
result = results[0];
|
||||||
|
expect(result.password).toEqual(expect.any(String));
|
||||||
|
expect(JSON.parse((await listCode(request, 'address_prefix_once', code)).result)).toEqual(result);
|
||||||
|
} finally {
|
||||||
|
if (result?.jwt) await deleteAddress(request, result.jwt);
|
||||||
|
await deleteCode(request, 'address_prefix_once', code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('expiry stops retrieval of an existing result', async ({ request }) => {
|
||||||
|
const expiredCode = await createCode(request, 'address_prefix_once', 'exp', {
|
||||||
|
expiresAt: new Date(Date.now() + 5_000).toISOString(),
|
||||||
|
});
|
||||||
|
let expiredResult: AddressRedeemResult | undefined;
|
||||||
|
try {
|
||||||
|
expiredResult = await (await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code: expiredCode, name: 'mail', domain: 'test.example.com' },
|
||||||
|
})).json();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5_500));
|
||||||
|
|
||||||
|
expect((await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code: expiredCode },
|
||||||
|
})).status()).toBe(400);
|
||||||
|
expect(await (await queryCode(request, expiredCode)).json()).toMatchObject({ status: 'expired' });
|
||||||
|
expect((await queryResult(request, expiredCode)).status()).toBe(400);
|
||||||
|
} finally {
|
||||||
|
if (expiredResult?.jwt) await deleteAddress(request, expiredResult.jwt);
|
||||||
|
await deleteCode(request, 'address_prefix_once', expiredCode);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,4 +51,33 @@ test.describe('Send Mail via SMTP', () => {
|
|||||||
// Cleanup
|
// Cleanup
|
||||||
await deleteAddress(request, jwt);
|
await deleteAddress(request, jwt);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('external sending stores mail fields without bearer credentials', async ({ request }) => {
|
||||||
|
const { jwt, address } = await createTestAddress(request, 'external-sender');
|
||||||
|
const mail = {
|
||||||
|
from_name: 'External sender', to_name: 'Recipient', to_mail: 'recipient@test.example.com',
|
||||||
|
subject: `External ${Date.now()}`, content: 'External message', is_html: false,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const listener = onMailpitMessage(message => message.Subject === mail.subject);
|
||||||
|
await listener.ready;
|
||||||
|
const response = await request.post(`${WORKER_URL}/external/api/send_mail`, {
|
||||||
|
data: { ...mail, token: jwt, extra_credential: 'must-not-be-stored' },
|
||||||
|
});
|
||||||
|
expect(response.ok(), await response.text()).toBe(true);
|
||||||
|
expect((await listener.message).From.Address).toBe(address);
|
||||||
|
const sendbox = await request.get(`${WORKER_URL}/api/sendbox?limit=20&offset=0`, {
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
});
|
||||||
|
expect(sendbox.ok()).toBe(true);
|
||||||
|
const { results } = await sendbox.json();
|
||||||
|
expect(results).toHaveLength(1);
|
||||||
|
const stored = JSON.parse(results[0].raw);
|
||||||
|
expect(stored).toMatchObject(mail);
|
||||||
|
expect(stored).not.toHaveProperty('token');
|
||||||
|
expect(stored).not.toHaveProperty('extra_credential');
|
||||||
|
} finally {
|
||||||
|
await deleteAddress(request, jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ test.describe('Telegram AI extraction rendering', () => {
|
|||||||
'Telegram AI extraction realtime body',
|
'Telegram AI extraction realtime body',
|
||||||
].join('\r\n');
|
].join('\r\n');
|
||||||
|
|
||||||
const receiveRes = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
const receiveRes = await request.post(`${WORKER_URL}/__test/receive_mail`, {
|
||||||
data: {
|
data: {
|
||||||
from: 'sender@test.example.com',
|
from: 'sender@test.example.com',
|
||||||
to: address,
|
to: address,
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
|
||||||
|
import { WORKER_URL, createTestAddress } from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
function initData(userId: number) {
|
||||||
|
const fields = {
|
||||||
|
auth_date: String(Math.floor(Date.now() / 1000)),
|
||||||
|
user: JSON.stringify({ id: userId }),
|
||||||
|
};
|
||||||
|
const key = createHmac('sha256', 'WebAppData').update('e2e-telegram-test-token').digest();
|
||||||
|
const hash = createHmac('sha256', key)
|
||||||
|
.update(Object.entries(fields).map(([name, value]) => `${name}=${value}`).join('\n'))
|
||||||
|
.digest('hex');
|
||||||
|
return new URLSearchParams({ ...fields, hash }).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bind(request: APIRequestContext, userId: number, jwt: string) {
|
||||||
|
const response = await request.post(`${WORKER_URL}/telegram/bind_address`, {
|
||||||
|
data: { initData: initData(userId), jwt },
|
||||||
|
});
|
||||||
|
expect(response.ok(), await response.text()).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unbind(request: APIRequestContext, userId: number, address: string, status = 200) {
|
||||||
|
const response = await request.post(`${WORKER_URL}/telegram/unbind_address`, {
|
||||||
|
data: { initData: initData(userId), address },
|
||||||
|
});
|
||||||
|
expect(response.status(), await response.text()).toBe(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addressList(request: APIRequestContext, userId: number) {
|
||||||
|
const response = await request.post(`${WORKER_URL}/telegram/get_bind_address`, {
|
||||||
|
data: { initData: initData(userId) },
|
||||||
|
});
|
||||||
|
expect(response.ok(), await response.text()).toBe(true);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectPushOwner(request: APIRequestContext, address: string, userId: number | null) {
|
||||||
|
const response = await request.get(`${WORKER_URL}/__test/telegram_binding`, {
|
||||||
|
params: { address },
|
||||||
|
});
|
||||||
|
expect(response.ok(), await response.text()).toBe(true);
|
||||||
|
expect(await response.json()).toBe(userId === null ? null : String(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Telegram users can remove their own bindings after another user binds the mailbox', async ({ request }) => {
|
||||||
|
const mailbox = await createTestAddress(request, 'tg-owner');
|
||||||
|
const owner = Date.now();
|
||||||
|
const other = owner + 1;
|
||||||
|
try {
|
||||||
|
await bind(request, owner, mailbox.jwt);
|
||||||
|
await unbind(request, other, mailbox.address, 400);
|
||||||
|
await expectPushOwner(request, mailbox.address, owner);
|
||||||
|
await unbind(request, owner, mailbox.address);
|
||||||
|
await expectPushOwner(request, mailbox.address, null);
|
||||||
|
|
||||||
|
await bind(request, owner, mailbox.jwt);
|
||||||
|
await bind(request, other, mailbox.jwt);
|
||||||
|
await expectPushOwner(request, mailbox.address, other);
|
||||||
|
await unbind(request, owner, mailbox.address);
|
||||||
|
expect(await addressList(request, owner)).toEqual([]);
|
||||||
|
expect(await addressList(request, other)).toEqual([{ address: mailbox.address, jwt: mailbox.jwt }]);
|
||||||
|
await expectPushOwner(request, mailbox.address, other);
|
||||||
|
await unbind(request, other, mailbox.address);
|
||||||
|
expect(await addressList(request, other)).toEqual([]);
|
||||||
|
await expectPushOwner(request, mailbox.address, null);
|
||||||
|
|
||||||
|
await bind(request, owner, mailbox.jwt);
|
||||||
|
await bind(request, other, mailbox.jwt);
|
||||||
|
await bind(request, owner, mailbox.jwt);
|
||||||
|
expect(await addressList(request, owner)).toEqual([{ address: mailbox.address, jwt: mailbox.jwt }]);
|
||||||
|
await expectPushOwner(request, mailbox.address, owner);
|
||||||
|
await unbind(request, other, mailbox.address);
|
||||||
|
await expectPushOwner(request, mailbox.address, owner);
|
||||||
|
await unbind(request, owner, mailbox.address);
|
||||||
|
expect(await addressList(request, owner)).toEqual([]);
|
||||||
|
await expectPushOwner(request, mailbox.address, null);
|
||||||
|
} finally {
|
||||||
|
await request.delete(`${WORKER_URL}/api/delete_address`, {
|
||||||
|
headers: { Authorization: `Bearer ${mailbox.jwt}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stale Telegram credentials cannot unbind; internal mailbox cleanup still works', async ({ request }) => {
|
||||||
|
const original = await createTestAddress(request, 'tg-stale');
|
||||||
|
const owner = Date.now();
|
||||||
|
await bind(request, owner, original.jwt);
|
||||||
|
const deletion = await request.delete(`${WORKER_URL}/admin/delete_address/${original.address_id}`);
|
||||||
|
expect(deletion.ok()).toBe(true);
|
||||||
|
const [name, domain] = original.address.split('@');
|
||||||
|
const creation = await request.post(`${WORKER_URL}/admin/new_address`, {
|
||||||
|
data: { name, domain, enablePrefix: false },
|
||||||
|
});
|
||||||
|
expect(creation.ok()).toBe(true);
|
||||||
|
const recreated = await creation.json();
|
||||||
|
try {
|
||||||
|
expect(recreated.address_id).not.toBe(original.address_id);
|
||||||
|
await unbind(request, owner, recreated.address, 400);
|
||||||
|
await expectPushOwner(request, recreated.address, owner);
|
||||||
|
const response = await request.delete(`${WORKER_URL}/api/delete_address`, {
|
||||||
|
headers: { Authorization: `Bearer ${recreated.jwt}` },
|
||||||
|
});
|
||||||
|
expect(response.ok(), await response.text()).toBe(true);
|
||||||
|
await expectPushOwner(request, recreated.address, null);
|
||||||
|
|
||||||
|
const listing = await request.post(`${WORKER_URL}/telegram/get_bind_address`, {
|
||||||
|
data: { initData: initData(owner) },
|
||||||
|
});
|
||||||
|
expect(listing.ok()).toBe(true);
|
||||||
|
expect(await listing.json()).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
await request.delete(`${WORKER_URL}/admin/delete_address/${recreated.address_id}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Telegram unbind accepts a current credential after a stale credential for the same address', async ({ request }) => {
|
||||||
|
const original = await createTestAddress(request, 'tg-recreated');
|
||||||
|
const unrelated = await createTestAddress(request, 'tg-retained');
|
||||||
|
const owner = Date.now();
|
||||||
|
await bind(request, owner, original.jwt);
|
||||||
|
await bind(request, owner, unrelated.jwt);
|
||||||
|
const deletion = await request.delete(`${WORKER_URL}/admin/delete_address/${original.address_id}`);
|
||||||
|
expect(deletion.ok()).toBe(true);
|
||||||
|
const [name, domain] = original.address.split('@');
|
||||||
|
const creation = await request.post(`${WORKER_URL}/admin/new_address`, {
|
||||||
|
data: { name, domain, enablePrefix: false },
|
||||||
|
});
|
||||||
|
expect(creation.ok()).toBe(true);
|
||||||
|
const recreated = await creation.json();
|
||||||
|
try {
|
||||||
|
expect(recreated.address_id).not.toBe(original.address_id);
|
||||||
|
await bind(request, owner, recreated.jwt);
|
||||||
|
await unbind(request, owner, recreated.address);
|
||||||
|
await expectPushOwner(request, recreated.address, null);
|
||||||
|
expect(await addressList(request, owner)).toEqual([{ address: unrelated.address, jwt: unrelated.jwt }]);
|
||||||
|
await expectPushOwner(request, unrelated.address, owner);
|
||||||
|
} finally {
|
||||||
|
for (const mailbox of [recreated, unrelated]) {
|
||||||
|
await request.delete(`${WORKER_URL}/api/delete_address`, {
|
||||||
|
headers: { Authorization: `Bearer ${mailbox.jwt}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import { WORKER_URL, WORKER_URL_SITE_PASSWORD } from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
for (const scenario of ['expired role', 'valid role', 'invalid signature', 'missing expiry', 'admin password', 'wrong password'] as const) {
|
||||||
|
test(`Admin role token errors remain distinct from password errors: ${scenario}`, async ({ request }) => {
|
||||||
|
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
||||||
|
const payload = Buffer.from(JSON.stringify({
|
||||||
|
user_id: 1, user_role: 'admin',
|
||||||
|
exp: scenario === 'missing expiry' ? undefined
|
||||||
|
: Math.floor(Date.now() / 1000) + (scenario === 'valid role' || scenario === 'invalid signature' ? 3600 : -60),
|
||||||
|
})).toString('base64url');
|
||||||
|
const signature = createHmac('sha256', scenario === 'invalid signature' ? 'wrong-secret' : 'e2e-site-password-secret')
|
||||||
|
.update(`${header}.${payload}`).digest('base64url');
|
||||||
|
const headers: Record<string, string> = { 'x-custom-auth': 'e2e-site-pass', 'x-lang': 'en' };
|
||||||
|
if (scenario !== 'wrong password') headers['x-user-access-token'] = `${header}.${payload}.${signature}`;
|
||||||
|
if (scenario === 'admin password') headers['x-admin-auth'] = 'e2e-admin-pass';
|
||||||
|
if (scenario === 'wrong password') headers['x-admin-auth'] = 'wrong-password';
|
||||||
|
const response = await request.get(`${WORKER_URL_SITE_PASSWORD}/admin/db_version`, { headers });
|
||||||
|
if (scenario === 'expired role') {
|
||||||
|
expect(response.status()).toBe(401);
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
code: 'AUTH_USER_ACCESS_TOKEN_EXPIRED',
|
||||||
|
message: 'Your access token has expired, please refresh the page',
|
||||||
|
});
|
||||||
|
} else if (scenario === 'missing expiry') {
|
||||||
|
expect(response.status()).toBe(401);
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
code: 'AUTH_ADMIN_CREDENTIAL_INVALID',
|
||||||
|
message: 'Your access token has expired, please refresh the page',
|
||||||
|
});
|
||||||
|
} else if (scenario === 'valid role' || scenario === 'admin password') {
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
} else {
|
||||||
|
expect(response.status()).toBe(401);
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
code: 'AUTH_ADMIN_CREDENTIAL_INVALID',
|
||||||
|
message: 'You need to provide the admin password to access this page',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('generic authentication errors preserve their text messages and CORS headers', async ({ request }) => {
|
||||||
|
for (const { path, message } of [
|
||||||
|
{ path: '/api/settings', message: 'Invalid address credential' },
|
||||||
|
{ path: '/user_api/settings', message: 'Your token has expired, please login again' },
|
||||||
|
]) {
|
||||||
|
const response = await request.get(`${WORKER_URL}${path}`, { headers: { 'x-lang': 'en' } });
|
||||||
|
expect(response.status()).toBe(401);
|
||||||
|
expect(response.headers()['content-type']).toContain('text/plain');
|
||||||
|
expect(response.headers()['access-control-allow-origin']).toBe('*');
|
||||||
|
expect(await response.text()).toBe(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uncaught server errors return JSON with the original error detail', async ({ request }) => {
|
||||||
|
const response = await request.post(`${WORKER_URL}/open_api/admin_login`, {
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
data: Buffer.from('{invalid-json'),
|
||||||
|
});
|
||||||
|
expect(response.status()).toBe(500);
|
||||||
|
expect(response.headers()['content-type']).toContain('application/json');
|
||||||
|
expect(await response.json()).toEqual({ code: 'INTERNAL_SERVER_ERROR', message: expect.stringContaining('SyntaxError') });
|
||||||
|
});
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
WORKER_URL,
|
||||||
|
createTestAddress,
|
||||||
|
deleteAddress,
|
||||||
|
deleteAllMailpitMessages,
|
||||||
|
getAddressSender,
|
||||||
|
hashPassword,
|
||||||
|
onMailpitMessage,
|
||||||
|
updateAddressSender,
|
||||||
|
} from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
async function createUser(request: APIRequestContext) {
|
||||||
|
const email = `user-send-${Date.now()}@test.example.com`;
|
||||||
|
const password = hashPassword('test-password-123');
|
||||||
|
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||||
|
data: { email, password },
|
||||||
|
});
|
||||||
|
expect(registerRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||||
|
data: { email, password },
|
||||||
|
});
|
||||||
|
expect(loginRes.ok()).toBe(true);
|
||||||
|
const { jwt } = await loginRes.json();
|
||||||
|
const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
|
||||||
|
return { jwt, userId: payload.user_id as number };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bindAddress(
|
||||||
|
request: APIRequestContext,
|
||||||
|
userJwt: string,
|
||||||
|
addressJwt: string,
|
||||||
|
) {
|
||||||
|
const response = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${addressJwt}`,
|
||||||
|
'x-user-token': userJwt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('User send mail API', () => {
|
||||||
|
test('sends and manages sent items for a bound address only', async ({ request }) => {
|
||||||
|
const addresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
|
||||||
|
let userId: number | undefined;
|
||||||
|
let originalUserSettings: Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`);
|
||||||
|
expect(settingsRes.ok()).toBe(true);
|
||||||
|
originalUserSettings = await settingsRes.json();
|
||||||
|
const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||||
|
data: {
|
||||||
|
...originalUserSettings,
|
||||||
|
enable: true,
|
||||||
|
enableMailVerify: false,
|
||||||
|
maxAddressCount: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(enableUserRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const user = await createUser(request);
|
||||||
|
userId = user.userId;
|
||||||
|
const bound = await createTestAddress(request, 'user-send-bound-');
|
||||||
|
const accessRequest = await createTestAddress(request, 'user-send-access-');
|
||||||
|
const outsider = await createTestAddress(request, 'usr-outsider-');
|
||||||
|
addresses.push(bound, accessRequest, outsider);
|
||||||
|
await bindAddress(request, user.jwt, bound.jwt);
|
||||||
|
await bindAddress(request, user.jwt, accessRequest.jwt);
|
||||||
|
|
||||||
|
const invalidAddressSettingsRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/address/0/settings`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(invalidAddressSettingsRes.status()).toBe(400);
|
||||||
|
|
||||||
|
const outsiderSettingsRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/address/${outsider.address_id}/settings`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(outsiderSettingsRes.status()).toBe(400);
|
||||||
|
|
||||||
|
const outsiderCredentialRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/bind_address_jwt/${outsider.address_id}`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(outsiderCredentialRes.status()).toBe(400);
|
||||||
|
|
||||||
|
const outsiderAccessRes = await request.post(
|
||||||
|
`${WORKER_URL}/user_api/address/${outsider.address_id}/request_send_mail_access`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(outsiderAccessRes.status()).toBe(400);
|
||||||
|
|
||||||
|
const outsiderUserSendRes = await request.post(
|
||||||
|
`${WORKER_URL}/user_api/address/${outsider.address_id}/send_mail`,
|
||||||
|
{
|
||||||
|
headers: { 'x-user-token': user.jwt },
|
||||||
|
data: {
|
||||||
|
to_mail: 'recipient@test.example.com',
|
||||||
|
subject: 'Forbidden user send',
|
||||||
|
content: 'This message must not be sent',
|
||||||
|
is_html: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(outsiderUserSendRes.status()).toBe(400);
|
||||||
|
|
||||||
|
const unauthenticatedSendboxRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0`,
|
||||||
|
);
|
||||||
|
expect(unauthenticatedSendboxRes.status()).toBe(401);
|
||||||
|
|
||||||
|
const requestAccessRes = await request.post(
|
||||||
|
`${WORKER_URL}/user_api/address/${accessRequest.address_id}/request_send_mail_access`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(requestAccessRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const accessSender = await getAddressSender(request, accessRequest.address);
|
||||||
|
await updateAddressSender(request, {
|
||||||
|
address: accessRequest.address,
|
||||||
|
address_id: accessSender.id,
|
||||||
|
balance: 0,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
const duplicateAccessRes = await request.post(
|
||||||
|
`${WORKER_URL}/user_api/address/${accessRequest.address_id}/request_send_mail_access`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(duplicateAccessRes.status()).toBe(400);
|
||||||
|
expect(await duplicateAccessRes.text()).toContain('Already');
|
||||||
|
|
||||||
|
const addressSettingsRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/address/${bound.address_id}/settings`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(addressSettingsRes.ok()).toBe(true);
|
||||||
|
const addressSettings = await addressSettingsRes.json();
|
||||||
|
expect(addressSettings.address).toBe(bound.address);
|
||||||
|
expect(addressSettings.send_balance).toBe(10);
|
||||||
|
|
||||||
|
await deleteAllMailpitMessages(request);
|
||||||
|
const subject = `User API send ${Date.now()}`;
|
||||||
|
const listener = onMailpitMessage((mail) => mail.Subject === subject);
|
||||||
|
await listener.ready;
|
||||||
|
|
||||||
|
const sendRes = await request.post(
|
||||||
|
`${WORKER_URL}/user_api/address/${bound.address_id}/send_mail`,
|
||||||
|
{
|
||||||
|
headers: { 'x-user-token': user.jwt },
|
||||||
|
data: {
|
||||||
|
from_name: 'User Sender',
|
||||||
|
from_mail: outsider.address,
|
||||||
|
to_name: 'Recipient',
|
||||||
|
to_mail: 'recipient@test.example.com',
|
||||||
|
subject,
|
||||||
|
content: 'Sent through the user API',
|
||||||
|
is_html: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(sendRes.ok()).toBe(true);
|
||||||
|
const delivered = await listener.message;
|
||||||
|
expect(delivered.From.Address).toBe(bound.address);
|
||||||
|
|
||||||
|
const outsiderSubject = `Outsider send ${Date.now()}`;
|
||||||
|
const outsiderSendRes = await request.post(`${WORKER_URL}/api/send_mail`, {
|
||||||
|
headers: { Authorization: `Bearer ${outsider.jwt}` },
|
||||||
|
data: {
|
||||||
|
to_mail: 'recipient@test.example.com',
|
||||||
|
subject: outsiderSubject,
|
||||||
|
content: 'This sent item must remain inaccessible to the user',
|
||||||
|
is_html: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(outsiderSendRes.ok()).toBe(true);
|
||||||
|
const outsiderAddressSendboxRes = await request.get(
|
||||||
|
`${WORKER_URL}/api/sendbox?limit=20&offset=0`,
|
||||||
|
{ headers: { Authorization: `Bearer ${outsider.jwt}` } },
|
||||||
|
);
|
||||||
|
const outsiderAddressSendbox = await outsiderAddressSendboxRes.json();
|
||||||
|
const outsiderMail = outsiderAddressSendbox.results.find((item: { raw: string }) => (
|
||||||
|
JSON.parse(item.raw).subject === outsiderSubject
|
||||||
|
));
|
||||||
|
expect(outsiderMail).toBeTruthy();
|
||||||
|
|
||||||
|
const unauthorizedDeleteRes = await request.delete(
|
||||||
|
`${WORKER_URL}/user_api/sendbox/${outsiderMail.id}`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(unauthorizedDeleteRes.ok()).toBe(true);
|
||||||
|
const outsiderSendboxAfterDeleteRes = await request.get(
|
||||||
|
`${WORKER_URL}/api/sendbox?limit=20&offset=0`,
|
||||||
|
{ headers: { Authorization: `Bearer ${outsider.jwt}` } },
|
||||||
|
);
|
||||||
|
expect((await outsiderSendboxAfterDeleteRes.json()).count).toBe(1);
|
||||||
|
|
||||||
|
const updatedSettingsRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/address/${bound.address_id}/settings`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect((await updatedSettingsRes.json()).send_balance).toBe(9);
|
||||||
|
|
||||||
|
const sender = await getAddressSender(request, bound.address);
|
||||||
|
await updateAddressSender(request, {
|
||||||
|
address: bound.address,
|
||||||
|
address_id: sender.id,
|
||||||
|
balance: 0,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
const noBalanceRes = await request.post(
|
||||||
|
`${WORKER_URL}/user_api/address/${bound.address_id}/send_mail`,
|
||||||
|
{
|
||||||
|
headers: { 'x-user-token': user.jwt },
|
||||||
|
data: {
|
||||||
|
to_mail: 'recipient@test.example.com',
|
||||||
|
subject: 'No balance user send',
|
||||||
|
content: 'This message must not be sent',
|
||||||
|
is_html: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(noBalanceRes.status()).toBe(400);
|
||||||
|
expect(await noBalanceRes.text()).toContain('No balance');
|
||||||
|
|
||||||
|
const userSendboxRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(userSendboxRes.ok()).toBe(true);
|
||||||
|
const userSendbox = await userSendboxRes.json();
|
||||||
|
expect(userSendbox.count).toBe(1);
|
||||||
|
expect(userSendbox.results).toHaveLength(1);
|
||||||
|
expect(JSON.parse(userSendbox.results[0].raw).subject).toBe(subject);
|
||||||
|
|
||||||
|
const filteredSendboxRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0&address=${encodeURIComponent(bound.address)}`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(filteredSendboxRes.ok()).toBe(true);
|
||||||
|
expect((await filteredSendboxRes.json()).count).toBe(1);
|
||||||
|
|
||||||
|
const outsiderFilterRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0&address=${encodeURIComponent(outsider.address)}`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(outsiderFilterRes.ok()).toBe(true);
|
||||||
|
expect((await outsiderFilterRes.json()).count).toBe(0);
|
||||||
|
|
||||||
|
const deleteRes = await request.delete(
|
||||||
|
`${WORKER_URL}/user_api/sendbox/${userSendbox.results[0].id}`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(deleteRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const emptySendboxRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/sendbox?limit=20&offset=0`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
const emptySendbox = await emptySendboxRes.json();
|
||||||
|
expect(emptySendbox.count).toBe(0);
|
||||||
|
expect(emptySendbox.results).toHaveLength(0);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await Promise.allSettled(addresses.map((address) => deleteAddress(request, address.jwt)));
|
||||||
|
if (userId !== undefined) {
|
||||||
|
await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (originalUserSettings) {
|
||||||
|
await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||||
|
data: originalUserSettings,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applies unlimited balance from the user role access token', async ({ request }) => {
|
||||||
|
const addresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
|
||||||
|
const userIds: number[] = [];
|
||||||
|
let originalUserSettings: Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`);
|
||||||
|
expect(settingsRes.ok()).toBe(true);
|
||||||
|
originalUserSettings = await settingsRes.json();
|
||||||
|
const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||||
|
data: {
|
||||||
|
...originalUserSettings,
|
||||||
|
enable: true,
|
||||||
|
enableMailVerify: false,
|
||||||
|
maxAddressCount: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(enableUserRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const user = await createUser(request);
|
||||||
|
userIds.push(user.userId);
|
||||||
|
const address = await createTestAddress(request, 'user-send-role-');
|
||||||
|
addresses.push(address);
|
||||||
|
await bindAddress(request, user.jwt, address.jwt);
|
||||||
|
|
||||||
|
const updateRoleRes = await request.post(`${WORKER_URL}/admin/user_roles`, {
|
||||||
|
data: { user_id: user.userId, role_text: 'case-role' },
|
||||||
|
});
|
||||||
|
expect(updateRoleRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const accessRes = await request.post(
|
||||||
|
`${WORKER_URL}/user_api/address/${address.address_id}/request_send_mail_access`,
|
||||||
|
{ headers: { 'x-user-token': user.jwt } },
|
||||||
|
);
|
||||||
|
expect(accessRes.ok()).toBe(true);
|
||||||
|
const sender = await getAddressSender(request, address.address);
|
||||||
|
await updateAddressSender(request, {
|
||||||
|
address: address.address,
|
||||||
|
address_id: sender.id,
|
||||||
|
balance: 0,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userSettingsRes = await request.get(`${WORKER_URL}/user_api/settings`, {
|
||||||
|
headers: { 'x-user-token': user.jwt },
|
||||||
|
});
|
||||||
|
expect(userSettingsRes.ok()).toBe(true);
|
||||||
|
const { access_token: accessToken } = await userSettingsRes.json();
|
||||||
|
expect(accessToken).toBeTruthy();
|
||||||
|
const userHeaders = {
|
||||||
|
'x-user-token': user.jwt,
|
||||||
|
'x-user-access-token': accessToken,
|
||||||
|
};
|
||||||
|
|
||||||
|
const addressSettingsRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/address/${address.address_id}/settings`,
|
||||||
|
{ headers: userHeaders },
|
||||||
|
);
|
||||||
|
expect(addressSettingsRes.ok()).toBe(true);
|
||||||
|
expect((await addressSettingsRes.json()).send_balance).toBe(99999);
|
||||||
|
|
||||||
|
const sendRes = await request.post(
|
||||||
|
`${WORKER_URL}/user_api/address/${address.address_id}/send_mail`,
|
||||||
|
{
|
||||||
|
headers: userHeaders,
|
||||||
|
data: {
|
||||||
|
to_mail: 'recipient@test.example.com',
|
||||||
|
subject: `Unlimited role send ${Date.now()}`,
|
||||||
|
content: 'Sent without consuming address balance',
|
||||||
|
is_html: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(sendRes.ok()).toBe(true);
|
||||||
|
expect((await getAddressSender(request, address.address)).balance).toBe(0);
|
||||||
|
|
||||||
|
const otherUser = await createUser(request);
|
||||||
|
userIds.push(otherUser.userId);
|
||||||
|
const otherAddress = await createTestAddress(request, 'user-send-other-');
|
||||||
|
addresses.push(otherAddress);
|
||||||
|
await bindAddress(request, otherUser.jwt, otherAddress.jwt);
|
||||||
|
const otherAccessRes = await request.post(
|
||||||
|
`${WORKER_URL}/user_api/address/${otherAddress.address_id}/request_send_mail_access`,
|
||||||
|
{ headers: { 'x-user-token': otherUser.jwt } },
|
||||||
|
);
|
||||||
|
expect(otherAccessRes.ok()).toBe(true);
|
||||||
|
const otherSender = await getAddressSender(request, otherAddress.address);
|
||||||
|
await updateAddressSender(request, {
|
||||||
|
address: otherAddress.address,
|
||||||
|
address_id: otherSender.id,
|
||||||
|
balance: 0,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mixedHeaders = {
|
||||||
|
'x-user-token': otherUser.jwt,
|
||||||
|
'x-user-access-token': accessToken,
|
||||||
|
};
|
||||||
|
const otherSettingsRes = await request.get(
|
||||||
|
`${WORKER_URL}/user_api/address/${otherAddress.address_id}/settings`,
|
||||||
|
{ headers: mixedHeaders },
|
||||||
|
);
|
||||||
|
expect(otherSettingsRes.ok()).toBe(true);
|
||||||
|
expect((await otherSettingsRes.json()).send_balance).toBe(0);
|
||||||
|
|
||||||
|
const otherSendRes = await request.post(
|
||||||
|
`${WORKER_URL}/user_api/address/${otherAddress.address_id}/send_mail`,
|
||||||
|
{
|
||||||
|
headers: mixedHeaders,
|
||||||
|
data: {
|
||||||
|
to_mail: 'recipient@test.example.com',
|
||||||
|
subject: `Mismatched role token ${Date.now()}`,
|
||||||
|
content: 'This message must not be sent',
|
||||||
|
is_html: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(otherSendRes.status()).toBe(400);
|
||||||
|
expect(await otherSendRes.text()).toContain('No balance');
|
||||||
|
} finally {
|
||||||
|
await Promise.allSettled(addresses.map((address) => deleteAddress(request, address.jwt)));
|
||||||
|
await Promise.allSettled(userIds.map((userId) => (
|
||||||
|
request.delete(`${WORKER_URL}/admin/users/${userId}`)
|
||||||
|
)));
|
||||||
|
if (originalUserSettings) {
|
||||||
|
await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||||
|
data: originalUserSettings,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -95,7 +95,7 @@ test.describe('Webhook — triggered on incoming mail', () => {
|
|||||||
`Webhook trigger test body`,
|
`Webhook trigger test body`,
|
||||||
].join('\r\n');
|
].join('\r\n');
|
||||||
|
|
||||||
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
const res = await request.post(`${WORKER_URL}/__test/receive_mail`, {
|
||||||
data: {
|
data: {
|
||||||
from,
|
from,
|
||||||
to: address,
|
to: address,
|
||||||
@@ -157,7 +157,7 @@ test.describe('Webhook — triggered on incoming mail', () => {
|
|||||||
`Should not trigger webhook`,
|
`Should not trigger webhook`,
|
||||||
].join('\r\n');
|
].join('\r\n');
|
||||||
|
|
||||||
const res = await request.post(`${WORKER_URL}/admin/test/receive_mail`, {
|
const res = await request.post(`${WORKER_URL}/__test/receive_mail`, {
|
||||||
data: { from: 'sender@test.example.com', to: address, raw },
|
data: { from: 'sender@test.example.com', to: address, raw },
|
||||||
});
|
});
|
||||||
expect(res.ok()).toBe(true);
|
expect(res.ok()).toBe(true);
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { FRONTEND_URL } from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
test('waits for access settings before showing the Admin password dialog', async ({ page }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const testWindow = window as Window & { __adminPasswordRendered?: boolean };
|
||||||
|
testWindow.__adminPasswordRendered = false;
|
||||||
|
|
||||||
|
const detectAdminPassword = () => {
|
||||||
|
const textNodes = document.createTreeWalker(document, NodeFilter.SHOW_TEXT);
|
||||||
|
while (textNodes.nextNode()) {
|
||||||
|
if (textNodes.currentNode.textContent?.trim() === '管理员密码') {
|
||||||
|
testWindow.__adminPasswordRendered = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
new MutationObserver(detectAdminPassword).observe(document, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
characterData: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let releaseSettings!: () => void;
|
||||||
|
const settingsPending = new Promise<void>((resolve) => {
|
||||||
|
releaseSettings = resolve;
|
||||||
|
});
|
||||||
|
let markSettingsRequested!: () => void;
|
||||||
|
const settingsRequested = new Promise<void>((resolve) => {
|
||||||
|
markSettingsRequested = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route('**/open_api/settings', async (route) => {
|
||||||
|
markSettingsRequested();
|
||||||
|
await settingsPending;
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`${FRONTEND_URL}/zh/admin`);
|
||||||
|
await settingsRequested;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(page.getByText('管理员密码', { exact: true })).toHaveCount(0);
|
||||||
|
} finally {
|
||||||
|
releaseSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(page.getByText('快速设置', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText('管理员密码', { exact: true })).toHaveCount(0);
|
||||||
|
expect(await page.evaluate(() => (
|
||||||
|
window as Window & { __adminPasswordRendered?: boolean }
|
||||||
|
).__adminPasswordRendered)).toBe(false);
|
||||||
|
});
|
||||||
@@ -6,7 +6,7 @@ test('create an address with a custom subdomain from the UI', async ({ page, req
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await page.goto(`${FRONTEND_URL}/en/`);
|
await page.goto(`${FRONTEND_URL}/en/`);
|
||||||
await page.getByRole('button', { name: 'Create New Email' }).click();
|
await page.getByRole('button', { name: 'Create Email Address' }).click();
|
||||||
|
|
||||||
const name = `subui${Date.now()}`;
|
const name = `subui${Date.now()}`;
|
||||||
const createForm = page.locator('.n-tab-pane:visible form');
|
const createForm = page.locator('.n-tab-pane:visible form');
|
||||||
@@ -36,7 +36,7 @@ test('create an address with a custom subdomain from the UI', async ({ page, req
|
|||||||
await createForm.getByText('Use Custom Subdomain', { exact: true }).click();
|
await createForm.getByText('Use Custom Subdomain', { exact: true }).click();
|
||||||
await createForm.locator('.n-input-group:visible .n-input input').last().fill('team');
|
await createForm.locator('.n-input-group:visible .n-input input').last().fill('team');
|
||||||
|
|
||||||
await createForm.getByRole('button', { name: 'Create New Email' }).click();
|
await createForm.getByRole('button', { name: 'Create Email Address' }).click();
|
||||||
|
|
||||||
const domain = `team.${TEST_DOMAIN}`;
|
const domain = `team.${TEST_DOMAIN}`;
|
||||||
const address = `tmp${name}@${domain}`;
|
const address = `tmp${name}@${domain}`;
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { expect, request as apiRequest, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
FRONTEND_URL,
|
||||||
|
WORKER_URL,
|
||||||
|
createTestAddress,
|
||||||
|
deleteAddress,
|
||||||
|
seedTestMail,
|
||||||
|
} from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
test('keeps refresh unread and supports automatic and manual state changes', async ({ page }) => {
|
||||||
|
const request = await apiRequest.newContext();
|
||||||
|
let jwt: string | undefined;
|
||||||
|
try {
|
||||||
|
const mailbox = await createTestAddress(request, 'mail-read-browser');
|
||||||
|
jwt = mailbox.jwt;
|
||||||
|
const subject = `Unread browser mail ${Date.now()}`;
|
||||||
|
await seedTestMail(request, mailbox.address, { subject });
|
||||||
|
|
||||||
|
await page.goto(`${FRONTEND_URL}/en/`);
|
||||||
|
await page.evaluate(() => localStorage.setItem('mailListView', 'true'));
|
||||||
|
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
|
||||||
|
await expect(page.getByText(subject, { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByText(subject, { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||||
|
const list = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
});
|
||||||
|
const mail = (await list.json()).results.find((item: any) => item.raw.includes(subject));
|
||||||
|
expect(mail.is_unread).toBe(1);
|
||||||
|
await expect(page.locator('.mail-list-unread').filter({ hasText: subject })).toBeVisible();
|
||||||
|
|
||||||
|
await page.route('**/api/mails/*/read', async route => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
const waitForReadUpdate = (isUnread: boolean) => page.waitForResponse(response =>
|
||||||
|
/\/api\/mails\/\d+\/read$/.test(new URL(response.url()).pathname)
|
||||||
|
&& response.request().method() === 'PATCH'
|
||||||
|
&& response.request().postDataJSON().isUnread === isUnread
|
||||||
|
);
|
||||||
|
const readResponse = waitForReadUpdate(false);
|
||||||
|
await page.getByText(subject, { exact: true }).click();
|
||||||
|
expect(await page.locator('.mail-list-unread').filter({ hasText: subject }).count()).toBe(0);
|
||||||
|
expect(await page.locator('.n-spin-content--spinning').count()).toBe(0);
|
||||||
|
await expect(page.getByRole('button', { name: 'Mark as Unread' })).not.toHaveClass(/n-button--loading/);
|
||||||
|
expect((await readResponse).ok()).toBe(true);
|
||||||
|
await expect(page.getByRole('button', { name: 'Mark as Unread' })).not.toHaveClass(/n-button--loading/);
|
||||||
|
|
||||||
|
const unreadResponse = waitForReadUpdate(true);
|
||||||
|
await page.getByRole('button', { name: 'Mark as Unread' }).click();
|
||||||
|
expect(await page.locator('.mail-list-unread').filter({ hasText: subject }).count()).toBe(1);
|
||||||
|
expect(await page.locator('.n-spin-content--spinning').count()).toBe(0);
|
||||||
|
await expect(page.getByRole('button', { name: 'Mark as Read' })).toHaveClass(/n-button--loading/);
|
||||||
|
expect((await unreadResponse).ok()).toBe(true);
|
||||||
|
await expect(page.getByRole('button', { name: 'Mark as Read' })).not.toHaveClass(/n-button--loading/);
|
||||||
|
|
||||||
|
const manualReadResponse = waitForReadUpdate(false);
|
||||||
|
await page.getByRole('button', { name: 'Mark as Read' }).click();
|
||||||
|
expect(await page.locator('.mail-list-unread').filter({ hasText: subject }).count()).toBe(0);
|
||||||
|
expect(await page.locator('.n-spin-content--spinning').count()).toBe(0);
|
||||||
|
await expect(page.getByRole('button', { name: 'Mark as Unread' })).toHaveClass(/n-button--loading/);
|
||||||
|
expect((await manualReadResponse).ok()).toBe(true);
|
||||||
|
await expect(page.getByRole('button', { name: 'Mark as Unread' })).not.toHaveClass(/n-button--loading/);
|
||||||
|
|
||||||
|
const updatedList = await request.get(`${WORKER_URL}/api/mails?limit=10&offset=0`, {
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
});
|
||||||
|
const updatedMail = (await updatedList.json()).results.find((item: any) => item.id === mail.id);
|
||||||
|
expect(updatedMail.is_unread).toBe(0);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
if (jwt) await deleteAddress(request, jwt);
|
||||||
|
} finally {
|
||||||
|
await request.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { expect, test, type APIRequestContext } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
createTestAddress,
|
||||||
|
deleteAddress,
|
||||||
|
FRONTEND_URL,
|
||||||
|
FRONTEND_URL_ENV_OFF,
|
||||||
|
WORKER_URL,
|
||||||
|
} from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
const ADMIN_HEADERS = { 'x-admin-auth': 'e2e-admin-pass' };
|
||||||
|
|
||||||
|
const uniqueValue = (label: string) => (
|
||||||
|
`${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
async function createCode(
|
||||||
|
request: APIRequestContext,
|
||||||
|
type: 'role' | 'send_balance' | 'address_prefix_once',
|
||||||
|
value: string,
|
||||||
|
) {
|
||||||
|
const response = await request.post(`${WORKER_URL}/admin/redeem_codes/batch`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: {
|
||||||
|
count: 1,
|
||||||
|
redeem_type: type,
|
||||||
|
value,
|
||||||
|
enabled: true,
|
||||||
|
expires_at: new Date(Date.now() + 3_600_000).toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
const body = await response.json();
|
||||||
|
expect(body.codes).toHaveLength(1);
|
||||||
|
return body.codes[0] as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findCode(request: APIRequestContext, type: string, code: string) {
|
||||||
|
const response = await request.get(
|
||||||
|
`${WORKER_URL}/admin/redeem_codes?redeem_type=${type}`
|
||||||
|
+ `&limit=20&offset=0&query=${encodeURIComponent(code)}`,
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
const body = await response.json();
|
||||||
|
return body.results.find((row: { code: string }) => row.code === code);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCode(request: APIRequestContext, type: string, code: string) {
|
||||||
|
const row = await findCode(request, type, code);
|
||||||
|
if (!row) return;
|
||||||
|
await request.delete(`${WORKER_URL}/admin/redeem_codes/${row.id}`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(request: APIRequestContext) {
|
||||||
|
const email = `${uniqueValue('browser-redeem-user')}@test.example.com`;
|
||||||
|
const password = createHash('sha256').update('browser-redeem-password').digest('hex');
|
||||||
|
const response = await request.post(`${WORKER_URL}/admin/users`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: { email, password },
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
const usersResponse = await request.get(
|
||||||
|
`${WORKER_URL}/admin/users?limit=10&offset=0&query=${encodeURIComponent(email)}`,
|
||||||
|
{ headers: ADMIN_HEADERS },
|
||||||
|
);
|
||||||
|
const user = (await usersResponse.json()).results[0];
|
||||||
|
await request.post(`${WORKER_URL}/admin/user_roles`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: { user_id: user.id, role_text: null },
|
||||||
|
});
|
||||||
|
const loginResponse = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||||
|
data: { email, password },
|
||||||
|
});
|
||||||
|
expect(loginResponse.ok()).toBe(true);
|
||||||
|
return { id: user.id as number, email, jwt: (await loginResponse.json()).jwt as string };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser(request: APIRequestContext, userId: number) {
|
||||||
|
await request.post(`${WORKER_URL}/admin/user_roles`, {
|
||||||
|
headers: ADMIN_HEADERS,
|
||||||
|
data: { user_id: userId, role_text: null },
|
||||||
|
});
|
||||||
|
await request.delete(`${WORKER_URL}/admin/users/${userId}`, { headers: ADMIN_HEADERS });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the mailbox entry opens the pure redemption page and completes role redemption', async ({ page, request }) => {
|
||||||
|
const user = await createUser(request);
|
||||||
|
const code = await createCode(request, 'role', 'case-role');
|
||||||
|
try {
|
||||||
|
await page.goto(`${FRONTEND_URL}/en/`);
|
||||||
|
await expect(page.getByTestId('redeem-entry')).toBeVisible();
|
||||||
|
await page.getByTestId('redeem-entry').click();
|
||||||
|
await expect(page).toHaveURL(/\/en\/redeem$/);
|
||||||
|
await expect(page.getByTestId('redeem-code-link')).toHaveAttribute(
|
||||||
|
'href', 'https://example.com/redeem-codes',
|
||||||
|
);
|
||||||
|
await page.evaluate((jwt) => localStorage.setItem('userJwt', jwt), user.jwt);
|
||||||
|
const settingsResponse = page.waitForResponse((response) => (
|
||||||
|
new URL(response.url()).pathname === '/user_api/settings'
|
||||||
|
));
|
||||||
|
await page.reload();
|
||||||
|
await settingsResponse;
|
||||||
|
|
||||||
|
await page.getByTestId('redeem-code-input').locator('input').fill(code);
|
||||||
|
await page.getByRole('button', { name: 'Look up code' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Role benefits' })).toBeVisible();
|
||||||
|
await expect(page.getByTestId('redeem-user-email')).toHaveCount(0);
|
||||||
|
await page.getByTestId('redeem-now').click();
|
||||||
|
const userEmailInput = page.getByTestId('redeem-user-email').locator('input');
|
||||||
|
await expect(userEmailInput).toHaveValue(user.email);
|
||||||
|
await userEmailInput.fill(user.email.toUpperCase());
|
||||||
|
const refreshedSettings = page.waitForResponse((response) => (
|
||||||
|
new URL(response.url()).pathname === '/user_api/settings' && response.ok()
|
||||||
|
));
|
||||||
|
await page.getByRole('button', { name: 'Confirm redemption' }).click();
|
||||||
|
const refreshedUser = await (await refreshedSettings).json();
|
||||||
|
expect(refreshedUser.user_role.role).toBe('case-role');
|
||||||
|
expect(refreshedUser.access_token).toEqual(expect.any(String));
|
||||||
|
await expect(page.getByRole('heading', { name: 'Redemption complete' })).toBeVisible();
|
||||||
|
await expect(page.getByText(user.email, { exact: false })).toBeVisible();
|
||||||
|
await expect(page.getByText('case-role', { exact: false })).toBeVisible();
|
||||||
|
|
||||||
|
const row = await findCode(request, 'role', code);
|
||||||
|
expect(JSON.parse(row.result)).toMatchObject({ user_email: user.email, role: 'case-role' });
|
||||||
|
await page.getByRole('button', { name: 'Redeem another code' }).click();
|
||||||
|
await page.getByTestId('redeem-code-input').locator('input').fill(code);
|
||||||
|
const nextQuery = page.waitForRequest((request) => (
|
||||||
|
new URL(request.url()).pathname === '/redeem_api/query'
|
||||||
|
));
|
||||||
|
await page.getByRole('button', { name: 'Look up code' }).click();
|
||||||
|
expect((await nextQuery).headers()['x-user-access-token']).toBe(refreshedUser.access_token);
|
||||||
|
} finally {
|
||||||
|
await deleteCode(request, 'role', code);
|
||||||
|
await deleteUser(request, user.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sending credits defaults to the current mailbox and keeps the target editable', async ({ page, request }) => {
|
||||||
|
const address = await createTestAddress(request, 'rdb-');
|
||||||
|
const code = await createCode(request, 'send_balance', '3');
|
||||||
|
try {
|
||||||
|
await page.goto(`${FRONTEND_URL}/en/`);
|
||||||
|
await expect(page.getByTestId('redeem-entry')).toBeVisible();
|
||||||
|
await page.getByTestId('redeem-entry').click();
|
||||||
|
await expect(page).toHaveURL(/\/en\/redeem$/);
|
||||||
|
await page.evaluate((jwt) => localStorage.setItem('jwt', jwt), address.jwt);
|
||||||
|
const settingsResponse = page.waitForResponse((response) => (
|
||||||
|
new URL(response.url()).pathname === '/api/settings'
|
||||||
|
));
|
||||||
|
await page.reload();
|
||||||
|
await settingsResponse;
|
||||||
|
|
||||||
|
await page.getByTestId('redeem-code-input').locator('input').fill(code);
|
||||||
|
await page.getByRole('button', { name: 'Look up code' }).click();
|
||||||
|
await page.getByTestId('redeem-now').click();
|
||||||
|
const targetInput = page.getByTestId('redeem-target-address').locator('input');
|
||||||
|
await expect(targetInput).toHaveValue(address.address);
|
||||||
|
await targetInput.fill(address.address.toUpperCase());
|
||||||
|
await page.getByRole('button', { name: 'Confirm redemption' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Redemption complete' })).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
await deleteCode(request, 'send_balance', code);
|
||||||
|
await deleteAddress(request, address.jwt);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the special-address page shows and retrieves the same full credentials', async ({ page, request }) => {
|
||||||
|
const code = await createCode(request, 'address_prefix_once', 'ui');
|
||||||
|
const name = `m${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
let jwt = '';
|
||||||
|
try {
|
||||||
|
await page.goto(`${FRONTEND_URL}/en/redeem`);
|
||||||
|
await page.getByTestId('redeem-code-input').locator('input').fill(code);
|
||||||
|
await page.getByRole('button', { name: 'Look up code' }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Custom mailbox' })).toBeVisible();
|
||||||
|
await expect(page.getByText('Unused', { exact: true })).toBeVisible();
|
||||||
|
await page.getByTestId('redeem-address-name').locator('input').fill(name);
|
||||||
|
await page.getByRole('button', { name: 'Redeem mailbox' }).click();
|
||||||
|
|
||||||
|
const addressValue = page.getByTestId('address-credential-address');
|
||||||
|
const passwordValue = page.getByTestId('address-credential-password');
|
||||||
|
const jwtValue = page.getByTestId('address-credential-jwt');
|
||||||
|
await expect(addressValue).toHaveText(`ui${name}@test.example.com`);
|
||||||
|
const firstAddress = await addressValue.innerText();
|
||||||
|
const firstPassword = await passwordValue.innerText();
|
||||||
|
const firstJwt = await jwtValue.innerText();
|
||||||
|
expect(firstPassword).not.toBe('');
|
||||||
|
expect(firstJwt).not.toBe('');
|
||||||
|
jwt = firstJwt;
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Redeem another code' }).click();
|
||||||
|
await page.getByTestId('redeem-code-input').locator('input').fill(code);
|
||||||
|
await page.getByRole('button', { name: 'Look up code' }).click();
|
||||||
|
await expect(page.getByText('Used', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText(firstAddress, { exact: false })).toHaveCount(0);
|
||||||
|
await page.getByRole('button', { name: 'View redemption result' }).click();
|
||||||
|
await expect(page.getByTestId('address-credential-address')).toHaveText(firstAddress);
|
||||||
|
await expect(page.getByTestId('address-credential-password')).toHaveText(firstPassword);
|
||||||
|
await expect(page.getByTestId('address-credential-jwt')).toHaveText(firstJwt);
|
||||||
|
} finally {
|
||||||
|
if (jwt) {
|
||||||
|
await request.delete(`${WORKER_URL}/api/delete_address`, {
|
||||||
|
headers: { Authorization: `Bearer ${jwt}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await deleteCode(request, 'address_prefix_once', code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Admin can batch-create, search, export, and display a redemption result', async ({ page, request }) => {
|
||||||
|
let code = '';
|
||||||
|
const user = await createUser(request);
|
||||||
|
try {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('adminAuth', 'e2e-admin-pass');
|
||||||
|
sessionStorage.setItem('adminTab', 'redeemCodes');
|
||||||
|
});
|
||||||
|
await page.goto(`${FRONTEND_URL}/en/admin`);
|
||||||
|
await expect(page.getByTestId('redeem-admin-create')).toBeVisible();
|
||||||
|
await page.getByTestId('redeem-admin-create').click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await dialog.getByTestId('redeem-admin-count').locator('input').fill('1');
|
||||||
|
await dialog.getByTestId('redeem-admin-role').click();
|
||||||
|
await page.locator('.n-base-select-option').filter({ hasText: 'case-role' }).click();
|
||||||
|
await dialog.getByRole('button', { name: 'Generate' }).click();
|
||||||
|
await expect(page.getByText('Select a future expiration time')).toBeVisible();
|
||||||
|
const expirationInput = dialog.locator('.n-date-picker input');
|
||||||
|
await expirationInput.fill('2099-01-01 00:00:00');
|
||||||
|
await expirationInput.press('Enter');
|
||||||
|
const createResponsePromise = page.waitForResponse((response) => (
|
||||||
|
response.request().method() === 'POST'
|
||||||
|
&& new URL(response.url()).pathname === '/admin/redeem_codes/batch'
|
||||||
|
));
|
||||||
|
const createdCodesDownload = page.waitForEvent('download');
|
||||||
|
await dialog.getByRole('button', { name: 'Generate' }).click();
|
||||||
|
const createResponse = await createResponsePromise;
|
||||||
|
code = (await createResponse.json()).codes[0];
|
||||||
|
expect((await createdCodesDownload).suggestedFilename()).toBe('redeem-codes-role.csv');
|
||||||
|
await expect(page.getByText('Redemption codes generated: 1.')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByTestId('redeem-admin-search').locator('input').fill(code);
|
||||||
|
await page.getByRole('button', { name: 'Search' }).click();
|
||||||
|
await expect(page.getByText(code, { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
const exportDownload = page.waitForEvent('download');
|
||||||
|
await page.getByTestId('redeem-admin-export').click();
|
||||||
|
await expect(page.getByText('Export rows (maximum 10000)')).toBeVisible();
|
||||||
|
await page.getByRole('dialog').getByRole('button', { name: 'Download CSV' }).click();
|
||||||
|
expect((await exportDownload).suggestedFilename()).toBe('redeem-codes-role.csv');
|
||||||
|
|
||||||
|
const redeemResponse = await request.post(`${WORKER_URL}/redeem_api/redeem`, {
|
||||||
|
data: { code, user_email: user.email },
|
||||||
|
});
|
||||||
|
expect(redeemResponse.ok()).toBe(true);
|
||||||
|
await page.getByRole('button', { name: 'Search' }).click();
|
||||||
|
await expect(page.getByText(user.email, { exact: true })).toBeVisible();
|
||||||
|
} finally {
|
||||||
|
if (code) await deleteCode(request, 'role', code);
|
||||||
|
await deleteUser(request, user.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Admin ignores a stale list response after switching redemption type', async ({ page }) => {
|
||||||
|
let releaseRole!: () => void;
|
||||||
|
let markRoleRequested!: () => void;
|
||||||
|
let markRoleCompleted!: () => void;
|
||||||
|
const roleGate = new Promise<void>((resolve) => { releaseRole = resolve; });
|
||||||
|
const roleRequested = new Promise<void>((resolve) => { markRoleRequested = resolve; });
|
||||||
|
const roleCompleted = new Promise<void>((resolve) => { markRoleCompleted = resolve; });
|
||||||
|
|
||||||
|
await page.route('**/admin/redeem_codes?*', async (route) => {
|
||||||
|
const type = new URL(route.request().url()).searchParams.get('redeem_type');
|
||||||
|
if (type === 'role') {
|
||||||
|
markRoleRequested();
|
||||||
|
await roleGate;
|
||||||
|
await route.fulfill({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
count: 1,
|
||||||
|
results: [{
|
||||||
|
id: 1,
|
||||||
|
code: 'STALE-ROLE-CODE',
|
||||||
|
redeem_type: 'role',
|
||||||
|
value: 'case-role',
|
||||||
|
result: null,
|
||||||
|
enabled: 1,
|
||||||
|
expires_at: '2099-01-01 00:00:00',
|
||||||
|
redeemed_at: null,
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
markRoleCompleted();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fulfill({
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
count: 1,
|
||||||
|
results: [{
|
||||||
|
id: 2,
|
||||||
|
code: 'CURRENT-BALANCE-CODE',
|
||||||
|
redeem_type: 'send_balance',
|
||||||
|
value: '20',
|
||||||
|
result: null,
|
||||||
|
enabled: 1,
|
||||||
|
expires_at: '2099-01-01 00:00:00',
|
||||||
|
redeemed_at: null,
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('adminAuth', 'e2e-admin-pass');
|
||||||
|
sessionStorage.setItem('adminTab', 'redeemCodes');
|
||||||
|
});
|
||||||
|
await page.goto(`${FRONTEND_URL}/en/admin`);
|
||||||
|
await roleRequested;
|
||||||
|
|
||||||
|
await page.getByTestId('redeem-admin-filter-type').click();
|
||||||
|
await page.locator('.n-base-select-option').filter({ hasText: 'Sending credits' }).click();
|
||||||
|
await expect(page.getByText('CURRENT-BALANCE-CODE', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
releaseRole();
|
||||||
|
await roleCompleted;
|
||||||
|
await expect(page.getByText('CURRENT-BALANCE-CODE', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText('STALE-ROLE-CODE', { exact: true })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const { locale, roleRequired, roleType, addressType, create, generate, prefixError } of [
|
||||||
|
{
|
||||||
|
locale: 'en', roleRequired: 'Select a role', roleType: 'Role benefits',
|
||||||
|
addressType: 'Custom mailbox', create: 'Batch Generate', generate: 'Generate',
|
||||||
|
prefixError: 'Use only letters and digits, up to 29 characters. Leave empty for no prefix.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
locale: 'zh', roleRequired: '请选择角色', roleType: '角色权益',
|
||||||
|
addressType: '专属邮箱', create: '批量生成', generate: '生成',
|
||||||
|
prefixError: '前缀仅支持英文字母和数字,最多 29 个字符;留空表示无前缀。',
|
||||||
|
},
|
||||||
|
]) {
|
||||||
|
test(`Admin validates missing role and invalid prefix in ${locale}`, async ({ page }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('adminAuth', 'e2e-admin-pass');
|
||||||
|
sessionStorage.setItem('adminTab', 'redeemCodes');
|
||||||
|
});
|
||||||
|
let createRequests = 0;
|
||||||
|
page.on('request', (request) => {
|
||||||
|
if (request.method() === 'POST'
|
||||||
|
&& new URL(request.url()).pathname === '/admin/redeem_codes/batch') {
|
||||||
|
createRequests++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await page.goto(`${FRONTEND_URL}/${locale}/admin`);
|
||||||
|
await expect(page.getByTestId('redeem-admin-filter-type')).toContainText(roleType);
|
||||||
|
await page.getByRole('button', { name: create, exact: true }).click();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await dialog.getByRole('button', { name: generate, exact: true }).click();
|
||||||
|
await expect(page.getByText(roleRequired, { exact: true })).toBeVisible();
|
||||||
|
await dialog.getByRole('button', { name: 'close', exact: true }).click();
|
||||||
|
await page.getByTestId('redeem-admin-filter-type').click();
|
||||||
|
await page.locator('.n-base-select-option').filter({ hasText: addressType }).click();
|
||||||
|
await page.getByRole('button', { name: create, exact: true }).click();
|
||||||
|
await dialog.locator('input[maxlength]').fill('bad-');
|
||||||
|
await dialog.getByRole('button', { name: generate, exact: true }).click();
|
||||||
|
await expect(page.getByText(prefixError, { exact: true })).toBeVisible();
|
||||||
|
expect(createRequests).toBe(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the disabled frontend hides the entry and redirects the page', async ({ page }) => {
|
||||||
|
await page.goto(`${FRONTEND_URL_ENV_OFF}/en/`);
|
||||||
|
await expect(page.getByTestId('redeem-entry')).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.goto(`${FRONTEND_URL_ENV_OFF}/en/redeem`);
|
||||||
|
await expect(page).not.toHaveURL(/\/redeem$/);
|
||||||
|
await expect(page.getByTestId('redeem-entry')).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('adminAuth', 'e2e-admin-pass');
|
||||||
|
});
|
||||||
|
await page.goto(`${FRONTEND_URL_ENV_OFF}/en/admin`);
|
||||||
|
await expect(page.getByTestId('redeem-admin-create')).toHaveCount(0);
|
||||||
|
await expect(page.getByText('Redemption Codes', { exact: true })).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { test, expect, request as apiRequest, type Page } from '@playwright/test';
|
||||||
|
import {
|
||||||
|
FRONTEND_URL,
|
||||||
|
createTestAddress,
|
||||||
|
deleteAddress,
|
||||||
|
requestSendAccess,
|
||||||
|
} from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
const expectEditorOriginsToAlign = async (page: Page) => {
|
||||||
|
const editorOriginDelta = await page.locator('.compose-textarea').evaluate((editor) => {
|
||||||
|
const textareaElement = editor.querySelector('textarea');
|
||||||
|
const placeholder = editor.querySelector('.n-input__placeholder');
|
||||||
|
if (!textareaElement) throw new Error('compose textarea element not found');
|
||||||
|
if (!placeholder) throw new Error('compose placeholder element not found');
|
||||||
|
const textareaBox = textareaElement.getBoundingClientRect();
|
||||||
|
const placeholderBox = placeholder.getBoundingClientRect();
|
||||||
|
const textareaStyle = getComputedStyle(textareaElement);
|
||||||
|
const placeholderStyle = getComputedStyle(placeholder);
|
||||||
|
return {
|
||||||
|
x: textareaBox.x + parseFloat(textareaStyle.paddingLeft)
|
||||||
|
- placeholderBox.x - parseFloat(placeholderStyle.paddingLeft),
|
||||||
|
y: textareaBox.y + parseFloat(textareaStyle.paddingTop)
|
||||||
|
- placeholderBox.y - parseFloat(placeholderStyle.paddingTop),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(Math.abs(editorOriginDelta.x)).toBeLessThan(1);
|
||||||
|
expect(Math.abs(editorOriginDelta.y)).toBeLessThan(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('Send mail composer', () => {
|
||||||
|
test('edits a draft, changes format, and previews HTML', async ({ page }) => {
|
||||||
|
const api = await apiRequest.newContext();
|
||||||
|
let jwt: string | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await createTestAddress(api, 'compose-ui');
|
||||||
|
jwt = created.jwt;
|
||||||
|
await requestSendAccess(api, jwt);
|
||||||
|
|
||||||
|
await page.goto(`${FRONTEND_URL}/en/?jwt=${jwt}`);
|
||||||
|
await page.getByText('Send Mail', { exact: true }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Compose email', exact: true })).toBeVisible();
|
||||||
|
await expect(page.locator('.composer-title')).toContainText(created.address);
|
||||||
|
|
||||||
|
const expectedFieldOrder = [
|
||||||
|
'send-mail-sender-address',
|
||||||
|
'send-mail-sender-name',
|
||||||
|
'send-mail-recipient-address',
|
||||||
|
'send-mail-recipient-name',
|
||||||
|
];
|
||||||
|
const fieldIds = await page.locator('.composer-form .n-grid input').evaluateAll(
|
||||||
|
(inputs) => inputs.map((input) => input.id)
|
||||||
|
);
|
||||||
|
expect(fieldIds.filter((id) => expectedFieldOrder.includes(id))).toEqual(expectedFieldOrder);
|
||||||
|
|
||||||
|
const textarea = page.locator('.compose-textarea textarea');
|
||||||
|
await expectEditorOriginsToAlign(page);
|
||||||
|
|
||||||
|
const recipient = page.getByRole('textbox', { name: /^Recipient address/ });
|
||||||
|
const subject = page.getByRole('textbox', { name: /^Subject/ });
|
||||||
|
await recipient.fill('recipient@test.example.com');
|
||||||
|
await subject.fill('Composer preview');
|
||||||
|
|
||||||
|
await page.getByText('HTML', { exact: true }).click();
|
||||||
|
const htmlContent = [
|
||||||
|
'<h1>Preview heading</h1><p>Preview body</p>',
|
||||||
|
'<script>alert("xss")</script><img src="x" onerror="alert(1)">',
|
||||||
|
].join('');
|
||||||
|
let previewDialogAppeared = false;
|
||||||
|
page.on('dialog', async (dialog) => {
|
||||||
|
previewDialogAppeared = true;
|
||||||
|
await dialog.dismiss();
|
||||||
|
});
|
||||||
|
await textarea.fill(htmlContent);
|
||||||
|
await page.getByRole('button', { name: 'Preview' }).click();
|
||||||
|
|
||||||
|
const preview = page.locator('.compose-preview');
|
||||||
|
await expect(preview).toBeVisible();
|
||||||
|
await expect(preview).toContainText('Preview heading');
|
||||||
|
await expect(preview.locator('script, [onerror]')).toHaveCount(0);
|
||||||
|
expect(previewDialogAppeared).toBe(false);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Edit' }).click();
|
||||||
|
await expect(preview).toBeHidden();
|
||||||
|
await expect(recipient).toHaveValue('recipient@test.example.com');
|
||||||
|
await expect(subject).toHaveValue('Composer preview');
|
||||||
|
await expect(textarea).toHaveValue(htmlContent);
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 320, height: 800 });
|
||||||
|
await page.goto(`${FRONTEND_URL}/es/?jwt=${jwt}`);
|
||||||
|
await page.getByText('Enviar correo', { exact: true }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Redactar correo', exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('textbox', { name: /^Dirección del destinatario/ })).toBeVisible();
|
||||||
|
await expect(page.getByText('Borrador guardado en este navegador', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByText('HTML', { exact: true }).click();
|
||||||
|
const previewButton = page.getByRole('button', { name: 'Vista previa', exact: true });
|
||||||
|
await expect(previewButton).toBeVisible();
|
||||||
|
const previewBox = await previewButton.boundingBox();
|
||||||
|
expect(previewBox).not.toBeNull();
|
||||||
|
expect(previewBox!.x + previewBox!.width).toBeLessThanOrEqual(320);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
if (jwt) await deleteAddress(api, jwt);
|
||||||
|
} finally {
|
||||||
|
await api.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the Admin field order and editor placeholder aligned', async ({ page }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
localStorage.setItem('adminAuth', 'e2e-admin-pass');
|
||||||
|
sessionStorage.setItem('adminTab', 'mails');
|
||||||
|
});
|
||||||
|
await page.goto(`${FRONTEND_URL}/en/admin`);
|
||||||
|
await page.getByText('Send Mail', { exact: true }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Compose email', exact: true })).toBeVisible();
|
||||||
|
const expectedFieldOrder = [
|
||||||
|
'admin-send-mail-sender-address',
|
||||||
|
'admin-send-mail-sender-name',
|
||||||
|
'admin-send-mail-recipient-address',
|
||||||
|
'admin-send-mail-recipient-name',
|
||||||
|
];
|
||||||
|
const fieldIds = await page.locator('.composer-form .n-grid input').evaluateAll(
|
||||||
|
(inputs) => inputs.map((input) => input.id)
|
||||||
|
);
|
||||||
|
expect(fieldIds.filter((id) => expectedFieldOrder.includes(id))).toEqual(expectedFieldOrder);
|
||||||
|
await expectEditorOriginsToAlign(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import { FRONTEND_URL, WORKER_URL, createTestAddress, deleteAddress, hashPassword, getAddressSender, onMailpitMessage } from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
const accessToken = (expiresIn: number) => {
|
||||||
|
const payload = Buffer.from(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + expiresIn })).toString('base64url');
|
||||||
|
return `e30.${payload}.signature`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openApiTestPage = async (page: Page) => {
|
||||||
|
// Isolate API calls from the application's automatic settings requests.
|
||||||
|
await page.route(`${FRONTEND_URL}/api-test`, (route) => route.fulfill({
|
||||||
|
contentType: 'text/html', body: '<!doctype html><html><body></body></html>',
|
||||||
|
}));
|
||||||
|
await page.goto(`${FRONTEND_URL}/api-test`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const expiredTokenResponse = { status: 401, json: { code: 'AUTH_USER_ACCESS_TOKEN_EXPIRED', message: 'Access token expired' } };
|
||||||
|
|
||||||
|
for (const scenario of [
|
||||||
|
{ name: 'mailbox credential', path: '/api/settings', site: false, admin: false, needAuth: true },
|
||||||
|
{ name: 'account credential', path: '/user_api/settings', site: false, admin: false, needAuth: true },
|
||||||
|
{ name: 'site password', path: '/api/settings', site: true, admin: false, needAuth: true },
|
||||||
|
{ name: 'site password on admin API', path: '/admin/db_version', site: true, admin: false, needAuth: true },
|
||||||
|
{ name: 'admin password', path: '/admin/db_version', site: false, admin: true, needAuth: true },
|
||||||
|
{ name: 'admin login', path: '/open_api/admin_login', site: false, admin: true, needAuth: false },
|
||||||
|
{ name: 'site password after authentication', path: '/api/settings', site: true, admin: false, needAuth: false },
|
||||||
|
{ name: 'site password on admin API after authentication', path: '/admin/db_version', site: true, admin: false, needAuth: false },
|
||||||
|
]) {
|
||||||
|
test(`Authentication dialog distinguishes ${scenario.name}`, async ({ page }) => {
|
||||||
|
await openApiTestPage(page);
|
||||||
|
await page.route(`**${scenario.path}`, route => route.fulfill(scenario.site
|
||||||
|
? { status: 401, json: { code: 'AUTH_SITE_PASSWORD_INVALID', message: 'Site password required' } }
|
||||||
|
: scenario.admin
|
||||||
|
? { status: 401, json: { code: 'AUTH_ADMIN_CREDENTIAL_INVALID', message: 'Admin password required' } }
|
||||||
|
: { status: 401, body: 'Invalid credential' }));
|
||||||
|
const result = await page.evaluate(async ({ path, needAuth }) => {
|
||||||
|
const apiModule = '/src/api/index.js';
|
||||||
|
const storeModule = '/src/store/index.js';
|
||||||
|
const { api } = await import(apiModule);
|
||||||
|
const state = (await import(storeModule)).useGlobalState();
|
||||||
|
state.openSettings.value.needAuth = needAuth;
|
||||||
|
state.showAuth.value = false;
|
||||||
|
state.showAdminAuth.value = false;
|
||||||
|
try {
|
||||||
|
await api.fetch(path);
|
||||||
|
} catch {
|
||||||
|
return { site: state.showAuth.value, admin: state.showAdminAuth.value };
|
||||||
|
}
|
||||||
|
}, { path: scenario.path, needAuth: scenario.needAuth });
|
||||||
|
expect(result).toEqual({ site: scenario.site, admin: scenario.admin });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const scenario of ['expired', 'expiring', 'valid', 'no account', 'login expired', 'wrong password', 'text zh', 'text en', 'retry expired', 'retry unauthorized', 'unmatched path', 'server error', 'json client error', 'json server error'] as const) {
|
||||||
|
test(`Access token response handling: ${scenario}`, async ({ page }) => {
|
||||||
|
await openApiTestPage(page);
|
||||||
|
|
||||||
|
const initialToken = accessToken(scenario === 'valid' ? 3600 : scenario === 'expiring' ? 20 : -60);
|
||||||
|
const freshToken = accessToken(7200);
|
||||||
|
const path = scenario === 'unmatched path' ? '/open_api/settings' : '/admin/db_version';
|
||||||
|
let refreshCount = 0;
|
||||||
|
const attempts: string[] = [];
|
||||||
|
await page.route('**/user_api/settings', async (route) => {
|
||||||
|
refreshCount++;
|
||||||
|
expect(route.request().headers()['x-user-token']).toBe('account-token');
|
||||||
|
await route.fulfill(scenario === 'login expired'
|
||||||
|
? { status: 401, contentType: 'text/plain', body: 'Please login again' }
|
||||||
|
: { json: { access_token: freshToken } });
|
||||||
|
});
|
||||||
|
await page.route(`**${path}`, async (route) => {
|
||||||
|
attempts.push(route.request().headers()['x-user-access-token']);
|
||||||
|
if (scenario.startsWith('json')) {
|
||||||
|
await route.fulfill({
|
||||||
|
status: scenario === 'json client error' ? 403 : 503,
|
||||||
|
json: { code: 'OPERATION_FAILED', message: 'Operation failed' },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (scenario === 'server error') {
|
||||||
|
await route.fulfill({ status: 500, body: 'Server error' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!['valid', 'expiring', 'wrong password'].includes(scenario)
|
||||||
|
&& (attempts.length === 1 || scenario === 'retry expired')) {
|
||||||
|
await route.fulfill(scenario.startsWith('text')
|
||||||
|
? { status: 401, body: scenario === 'text zh' ? '您的访问令牌已过期, 请刷新页面' : 'Your access token has expired, please refresh the page' }
|
||||||
|
: expiredTokenResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fulfill(['wrong password', 'retry unauthorized'].includes(scenario)
|
||||||
|
? { status: 401, json: { code: 'AUTH_ADMIN_CREDENTIAL_INVALID', message: 'Admin password required' } }
|
||||||
|
: { json: { current_db_version: 'test-version' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await page.evaluate(async ({ initialToken, scenario, path }) => {
|
||||||
|
const apiModule = '/src/api/index.js';
|
||||||
|
const storeModule = '/src/store/index.js';
|
||||||
|
const { api } = await import(apiModule);
|
||||||
|
const state = (await import(storeModule)).useGlobalState();
|
||||||
|
state.userJwt.value = scenario === 'no account' ? '' : 'account-token';
|
||||||
|
state.userSettings.value.access_token = initialToken;
|
||||||
|
state.adminAuth.value = scenario === 'wrong password' ? 'wrong-password' : '';
|
||||||
|
state.showAdminAuth.value = false;
|
||||||
|
try {
|
||||||
|
return { data: await api.fetch(path), error: null, showAdminAuth: state.showAdminAuth.value };
|
||||||
|
} catch (error) {
|
||||||
|
return { data: null, error: String(error), showAdminAuth: state.showAdminAuth.value };
|
||||||
|
}
|
||||||
|
}, { initialToken, scenario, path });
|
||||||
|
|
||||||
|
const needsRefresh = !['valid', 'expiring', 'no account', 'wrong password', 'text zh', 'text en', 'unmatched path', 'server error', 'json client error', 'json server error'].includes(scenario);
|
||||||
|
expect(refreshCount).toBe(needsRefresh ? 1 : 0);
|
||||||
|
expect(attempts).toEqual(needsRefresh && scenario !== 'login expired' ? [initialToken, freshToken] : [initialToken]);
|
||||||
|
expect(result.showAdminAuth).toBe(['wrong password', 'retry unauthorized'].includes(scenario));
|
||||||
|
if (scenario === 'login expired') expect(result.error).toContain('Please login again');
|
||||||
|
else if (['wrong password', 'retry unauthorized'].includes(scenario)) expect(result.error).toContain('Admin password required');
|
||||||
|
else if (['retry expired', 'no account', 'unmatched path'].includes(scenario)) expect(result.error).toContain('Access token expired');
|
||||||
|
else if (scenario === 'server error') expect(result.error).toContain('Server error');
|
||||||
|
else if (scenario.startsWith('text')) {
|
||||||
|
expect(result.error).toContain(scenario === 'text zh'
|
||||||
|
? '您的访问令牌已过期, 请刷新页面'
|
||||||
|
: 'Your access token has expired, please refresh the page');
|
||||||
|
}
|
||||||
|
else if (scenario.startsWith('json')) {
|
||||||
|
expect(result.error).toContain('Operation failed');
|
||||||
|
expect(result.error).not.toContain('[object Object]');
|
||||||
|
}
|
||||||
|
else expect(result.data).toEqual({ current_db_version: 'test-version' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('concurrent and late responses share one access token refresh', async ({ page }) => {
|
||||||
|
await openApiTestPage(page);
|
||||||
|
const initialToken = accessToken(-60);
|
||||||
|
const freshToken = accessToken(7200);
|
||||||
|
const paths = ['/admin/db_version', '/api/settings', '/api/send_mail',
|
||||||
|
'/user_api/bind_address', '/user_api/address/1/settings'];
|
||||||
|
let refreshCount = 0;
|
||||||
|
const initialPaths: string[] = [];
|
||||||
|
const attempts: string[] = [];
|
||||||
|
let release!: () => void;
|
||||||
|
let releaseLate!: () => void;
|
||||||
|
const refreshPending = new Promise<void>((resolve) => { release = resolve; });
|
||||||
|
const lateResponse = new Promise<void>((resolve) => { releaseLate = resolve; });
|
||||||
|
await page.route('**/user_api/settings', async (route) => {
|
||||||
|
refreshCount++;
|
||||||
|
await refreshPending;
|
||||||
|
await route.fulfill({ json: { access_token: freshToken } });
|
||||||
|
});
|
||||||
|
await page.route((url) => paths.includes(url.pathname), async (route) => {
|
||||||
|
if (route.request().headers()['x-user-access-token'] === initialToken) {
|
||||||
|
const requestPath = new URL(route.request().url()).pathname;
|
||||||
|
initialPaths.push(requestPath);
|
||||||
|
if (requestPath === paths.at(-1)) await lateResponse;
|
||||||
|
await route.fulfill(expiredTokenResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
attempts.push(route.request().headers()['x-user-access-token']);
|
||||||
|
await route.fulfill({ json: { success: true } });
|
||||||
|
});
|
||||||
|
const pending = page.evaluate(async ({ initialToken, paths }) => {
|
||||||
|
const apiModule = '/src/api/index.js';
|
||||||
|
const storeModule = '/src/store/index.js';
|
||||||
|
const { api } = await import(apiModule);
|
||||||
|
const state = (await import(storeModule)).useGlobalState();
|
||||||
|
state.userJwt.value = 'account-token';
|
||||||
|
state.userSettings.value.access_token = initialToken;
|
||||||
|
return Promise.all(paths.map((path) => api.fetch(path, {
|
||||||
|
method: path === '/api/send_mail' ? 'POST' : 'GET',
|
||||||
|
})));
|
||||||
|
}, { initialToken, paths });
|
||||||
|
try {
|
||||||
|
await expect.poll(() => refreshCount).toBe(1);
|
||||||
|
await expect.poll(() => initialPaths.length).toBe(paths.length);
|
||||||
|
expect(attempts).toEqual([]);
|
||||||
|
release();
|
||||||
|
await expect.poll(() => attempts.length).toBe(paths.length - 1);
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
releaseLate();
|
||||||
|
}
|
||||||
|
expect(await pending).toEqual(paths.map(() => ({ success: true })));
|
||||||
|
expect(refreshCount).toBe(1);
|
||||||
|
expect(attempts).toEqual(paths.map(() => freshToken));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const failure of [400, 401, 500, 'network'] as const) {
|
||||||
|
test(`failed shared refresh preserves independent mailbox requests: ${failure}`, async ({ page }) => {
|
||||||
|
await openApiTestPage(page);
|
||||||
|
const paths = ['/admin/db_version', '/user_api/bind_address', '/user_api/address/1/settings',
|
||||||
|
'/api/settings', '/api/send_mail'];
|
||||||
|
const attempts: string[] = [];
|
||||||
|
let initialResponses = 0;
|
||||||
|
page.on('response', async (response) => {
|
||||||
|
if (paths.includes(new URL(response.url()).pathname) && response.status() === 401) {
|
||||||
|
await response.finished();
|
||||||
|
initialResponses++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let refreshCount = 0;
|
||||||
|
let release!: () => void;
|
||||||
|
const refreshPending = new Promise<void>((resolve) => { release = resolve; });
|
||||||
|
const freshToken = accessToken(7200);
|
||||||
|
await page.route('**/user_api/settings', async (route) => {
|
||||||
|
refreshCount++;
|
||||||
|
if (refreshCount > 1) {
|
||||||
|
await route.fulfill({ json: { access_token: freshToken } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await refreshPending;
|
||||||
|
if (failure === 'network') await route.abort('failed');
|
||||||
|
else await route.fulfill({ status: failure, body: 'Refresh failed' });
|
||||||
|
});
|
||||||
|
await page.route((url) => paths.includes(url.pathname), async (route) => {
|
||||||
|
const path = new URL(route.request().url()).pathname;
|
||||||
|
if (route.request().headers()['x-user-access-token'] && route.request().headers()['x-user-access-token'] !== freshToken) {
|
||||||
|
await route.fulfill(expiredTokenResponse);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
attempts.push(path);
|
||||||
|
await route.fulfill(path === '/api/send_mail'
|
||||||
|
? { status: 403, body: 'No send balance' }
|
||||||
|
: { json: { success: true } });
|
||||||
|
});
|
||||||
|
const pending = page.evaluate(async ({ paths, initialToken }) => {
|
||||||
|
const apiModule = '/src/api/index.js';
|
||||||
|
const storeModule = '/src/store/index.js';
|
||||||
|
const { api } = await import(apiModule);
|
||||||
|
const state = (await import(storeModule)).useGlobalState();
|
||||||
|
state.userJwt.value = 'account-token';
|
||||||
|
state.userSettings.value.access_token = initialToken;
|
||||||
|
state.adminAuth.value = '';
|
||||||
|
state.openSettings.value.needAuth = true;
|
||||||
|
state.showAuth.value = false;
|
||||||
|
state.showAdminAuth.value = false;
|
||||||
|
const results = await Promise.all(paths.map(async (path) => {
|
||||||
|
try {
|
||||||
|
return { data: await api.fetch(path, { method: path === '/api/send_mail' ? 'POST' : 'GET' }) };
|
||||||
|
} catch (error) {
|
||||||
|
return { error: String(error) };
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const flags = { showAuth: state.showAuth.value, showAdminAuth: state.showAdminAuth.value, loading: state.loading.value };
|
||||||
|
await api.fetch('/admin/db_version');
|
||||||
|
return { results, flags };
|
||||||
|
}, { paths, initialToken: accessToken(-60) });
|
||||||
|
try {
|
||||||
|
await expect.poll(() => initialResponses).toBe(paths.length);
|
||||||
|
await expect.poll(() => refreshCount).toBe(1);
|
||||||
|
expect(attempts).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
const { results, flags } = await pending;
|
||||||
|
for (const result of results.slice(0, 3)) {
|
||||||
|
expect(result.error).toContain(failure === 'network' ? 'Network Error' : 'Refresh failed');
|
||||||
|
}
|
||||||
|
expect(results[3].data).toEqual({ success: true });
|
||||||
|
expect(results[4].error).toContain('[403]: No send balance');
|
||||||
|
expect(flags).toEqual({ showAuth: false, showAdminAuth: false, loading: false });
|
||||||
|
expect(refreshCount).toBe(2);
|
||||||
|
expect(attempts.slice(0, 2).sort()).toEqual(['/api/send_mail', '/api/settings']);
|
||||||
|
expect(attempts.slice(2)).toEqual(['/admin/db_version']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const changedCredential of ['account', 'mailbox'] as const) {
|
||||||
|
test(`a changed ${changedCredential} stops the waiting request after refresh`, async ({ page }) => {
|
||||||
|
await openApiTestPage(page);
|
||||||
|
const path = changedCredential === 'mailbox' ? '/api/send_mail' : '/admin/db_version';
|
||||||
|
const freshToken = accessToken(7200);
|
||||||
|
let attempts = 0;
|
||||||
|
await page.route(`**${path}`, async (route) => {
|
||||||
|
attempts++;
|
||||||
|
await route.fulfill(attempts === 1 ? expiredTokenResponse : { json: { success: true } });
|
||||||
|
});
|
||||||
|
await page.route('**/user_api/settings', async (route) => {
|
||||||
|
await page.evaluate(async (changedCredential) => {
|
||||||
|
const storeModule = '/src/store/index.js';
|
||||||
|
const state = (await import(storeModule)).useGlobalState();
|
||||||
|
if (changedCredential === 'account') {
|
||||||
|
state.userJwt.value = 'other-account';
|
||||||
|
state.userSettings.value = { access_token: 'other-access-token' };
|
||||||
|
} else {
|
||||||
|
state.jwt.value = 'other-mailbox';
|
||||||
|
}
|
||||||
|
}, changedCredential);
|
||||||
|
await route.fulfill({ json: { access_token: freshToken } });
|
||||||
|
});
|
||||||
|
const result = await page.evaluate(async ({ initialToken, path }) => {
|
||||||
|
const apiModule = '/src/api/index.js';
|
||||||
|
const storeModule = '/src/store/index.js';
|
||||||
|
const { api } = await import(apiModule);
|
||||||
|
const state = (await import(storeModule)).useGlobalState();
|
||||||
|
state.userJwt.value = 'account-token';
|
||||||
|
state.jwt.value = 'mailbox-token';
|
||||||
|
state.userSettings.value.access_token = initialToken;
|
||||||
|
state.adminAuth.value = '';
|
||||||
|
try {
|
||||||
|
await api.fetch(path, { method: path === '/api/send_mail' ? 'POST' : 'GET' });
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
return { error: String(error), token: state.userSettings.value.access_token, loading: state.loading.value };
|
||||||
|
}
|
||||||
|
}, { initialToken: accessToken(-60), path });
|
||||||
|
expect(result).toEqual({
|
||||||
|
error: 'Error: User session changed, please retry',
|
||||||
|
token: changedCredential === 'account' ? 'other-access-token' : freshToken,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
expect(attempts).toBe(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [scenario, query] of [
|
||||||
|
['expired account', ''],
|
||||||
|
['deleted account', ''],
|
||||||
|
['valid account', ''],
|
||||||
|
['missing account', ''],
|
||||||
|
['missing expiry', ''],
|
||||||
|
['expired account', '?refresh=1&source=e2e'],
|
||||||
|
['deleted account', '?refresh=1&source=e2e'],
|
||||||
|
['valid account', '?refresh=1&source=e2e'],
|
||||||
|
['missing account', '?refresh=1&source=e2e'],
|
||||||
|
['missing expiry', '?refresh=1&source=e2e'],
|
||||||
|
] as const) {
|
||||||
|
test(`mailbox operations use the real Worker when ${scenario}, query: ${query || 'none'}`, async ({ page, request }) => {
|
||||||
|
const address = await createTestAddress(request, 'refresh-mailbox-');
|
||||||
|
let userId: number | undefined;
|
||||||
|
try {
|
||||||
|
const email = `refresh-account-${Date.now()}@test.example.com`;
|
||||||
|
const created = await request.post(`${WORKER_URL}/admin/users`, {
|
||||||
|
data: { email, password: hashPassword('test-password-123') },
|
||||||
|
});
|
||||||
|
expect(created.ok()).toBe(true);
|
||||||
|
const users = await request.get(`${WORKER_URL}/admin/users`, {
|
||||||
|
params: { limit: 10, offset: 0, query: email },
|
||||||
|
});
|
||||||
|
expect(users.ok()).toBe(true);
|
||||||
|
userId = (await users.json()).results.find((user: any) => user.user_email === email).id;
|
||||||
|
|
||||||
|
const sign = (payload: Record<string, unknown>) => {
|
||||||
|
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
||||||
|
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||||||
|
const signature = createHmac('sha256', 'e2e-test-secret-key').update(`${header}.${body}`).digest('base64url');
|
||||||
|
return `${header}.${body}.${signature}`;
|
||||||
|
};
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const claims = { user_id: userId, user_email: email, iat: now - 3600 };
|
||||||
|
const userJwt = scenario === 'missing account' ? ''
|
||||||
|
: sign({ ...claims, exp: now + (scenario === 'expired account' ? -60 : 3600) });
|
||||||
|
const expiredAccessToken = sign({ ...claims, exp: scenario === 'missing expiry' ? undefined : now - 60, user_role: 'case-role' });
|
||||||
|
if (scenario === 'deleted account') {
|
||||||
|
expect((await request.delete(`${WORKER_URL}/admin/users/${userId}`)).ok()).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exercise the browser API module without the UI's automatic settings requests.
|
||||||
|
await page.route(`${FRONTEND_URL}/api-test`, (route) => route.fulfill({
|
||||||
|
contentType: 'text/html', body: '<!doctype html><html><body></body></html>',
|
||||||
|
}));
|
||||||
|
await page.goto(`${FRONTEND_URL}/api-test`);
|
||||||
|
const refreshStatuses: number[] = [];
|
||||||
|
let sendCount = 0;
|
||||||
|
page.on('response', (response) => {
|
||||||
|
if (new URL(response.url()).pathname === '/user_api/settings') refreshStatuses.push(response.status());
|
||||||
|
});
|
||||||
|
page.on('request', (req) => {
|
||||||
|
const url = new URL(req.url());
|
||||||
|
if (['/api/settings', '/api/send_mail'].includes(url.pathname)) expect(url.search).toBe(query);
|
||||||
|
if (url.pathname === '/api/send_mail') sendCount++;
|
||||||
|
});
|
||||||
|
const settings = await page.evaluate(async ({ userJwt, expiredAccessToken, mailboxJwt, query }) => {
|
||||||
|
const apiModule = '/src/api/index.js';
|
||||||
|
const storeModule = '/src/store/index.js';
|
||||||
|
const { api } = await import(apiModule);
|
||||||
|
const state = (await import(storeModule)).useGlobalState();
|
||||||
|
state.userJwt.value = userJwt;
|
||||||
|
state.userSettings.value.access_token = expiredAccessToken;
|
||||||
|
state.jwt.value = mailboxJwt;
|
||||||
|
state.adminAuth.value = '';
|
||||||
|
return api.fetch(`/api/settings${query}`);
|
||||||
|
}, { userJwt, expiredAccessToken, mailboxJwt: address.jwt, query });
|
||||||
|
expect(settings.address).toBe(address.address);
|
||||||
|
expect(settings.send_balance).toBe(10);
|
||||||
|
|
||||||
|
const subject = `Mailbox refresh regression ${scenario} ${Date.now()}`;
|
||||||
|
const listener = onMailpitMessage((mail) => mail.Subject === subject);
|
||||||
|
await listener.ready;
|
||||||
|
const [mail] = await Promise.all([
|
||||||
|
listener.message,
|
||||||
|
page.evaluate(async ({ subject, query }) => {
|
||||||
|
const apiModule = '/src/api/index.js';
|
||||||
|
const { api } = await import(apiModule);
|
||||||
|
await api.fetch(`/api/send_mail${query}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { to_mail: 'recipient@test.example.com', subject, content: 'Independent mailbox credential', is_html: false },
|
||||||
|
});
|
||||||
|
}, { subject, query }),
|
||||||
|
]);
|
||||||
|
expect(mail.From.Address).toBe(address.address);
|
||||||
|
expect(sendCount).toBe(scenario === 'valid account' || scenario === 'missing expiry' ? 1 : 2);
|
||||||
|
expect((await getAddressSender(request, address.address)).balance).toBe(9);
|
||||||
|
const sent = await request.get(`${WORKER_URL}/admin/sendbox`, {
|
||||||
|
params: { address: address.address, limit: 10, offset: 0 },
|
||||||
|
});
|
||||||
|
expect(sent.ok()).toBe(true);
|
||||||
|
expect((await sent.json()).results).toHaveLength(1);
|
||||||
|
expect(refreshStatuses).toEqual(scenario === 'valid account' ? [200]
|
||||||
|
: scenario === 'missing account' || scenario === 'missing expiry' ? []
|
||||||
|
: scenario === 'expired account' ? [401, 401] : [400, 400]);
|
||||||
|
} finally {
|
||||||
|
await deleteAddress(request, address.jwt);
|
||||||
|
if (userId !== undefined) await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -100,7 +100,7 @@ test.describe('User address pagination browser flow', () => {
|
|||||||
return url.pathname === '/user_api/bind_address'
|
return url.pathname === '/user_api/bind_address'
|
||||||
&& url.searchParams.get('limit') === '100';
|
&& url.searchParams.get('limit') === '100';
|
||||||
});
|
});
|
||||||
await page.getByText('Mail Box', { exact: true }).click();
|
await page.getByText('Inbox', { exact: true }).click();
|
||||||
const initialMailboxResponse = await initialMailboxAddressesResponse;
|
const initialMailboxResponse = await initialMailboxAddressesResponse;
|
||||||
expect(initialMailboxResponse.ok()).toBe(true);
|
expect(initialMailboxResponse.ok()).toBe(true);
|
||||||
const mailboxAddressSelect = page.locator('.n-input-group .n-select').first();
|
const mailboxAddressSelect = page.locator('.n-input-group .n-select').first();
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { expect, request as apiRequest, test, type APIRequestContext } from '@playwright/test';
|
||||||
|
|
||||||
|
import {
|
||||||
|
FRONTEND_URL,
|
||||||
|
WORKER_URL,
|
||||||
|
createTestAddress,
|
||||||
|
deleteAddress,
|
||||||
|
hashPassword,
|
||||||
|
} from '../../fixtures/test-helpers';
|
||||||
|
|
||||||
|
async function createUser(request: APIRequestContext) {
|
||||||
|
const email = `user-send-browser-${Date.now()}@test.example.com`;
|
||||||
|
const password = hashPassword('test-password-123');
|
||||||
|
const registerRes = await request.post(`${WORKER_URL}/user_api/register`, {
|
||||||
|
data: { email, password },
|
||||||
|
});
|
||||||
|
expect(registerRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const loginRes = await request.post(`${WORKER_URL}/user_api/login`, {
|
||||||
|
data: { email, password },
|
||||||
|
});
|
||||||
|
expect(loginRes.ok()).toBe(true);
|
||||||
|
const { jwt } = await loginRes.json();
|
||||||
|
const payload = JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8'));
|
||||||
|
return { email, jwt, userId: payload.user_id as number };
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('User send mail page', () => {
|
||||||
|
test('selects a bound address, sends mail, and opens its sent items', async ({ page }) => {
|
||||||
|
const request = await apiRequest.newContext();
|
||||||
|
const addresses: Awaited<ReturnType<typeof createTestAddress>>[] = [];
|
||||||
|
let userId: number | undefined;
|
||||||
|
let originalUserSettings: Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settingsRes = await request.get(`${WORKER_URL}/admin/user_settings`);
|
||||||
|
expect(settingsRes.ok()).toBe(true);
|
||||||
|
originalUserSettings = await settingsRes.json();
|
||||||
|
const enableUserRes = await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||||
|
data: {
|
||||||
|
...originalUserSettings,
|
||||||
|
enable: true,
|
||||||
|
enableMailVerify: false,
|
||||||
|
maxAddressCount: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(enableUserRes.ok()).toBe(true);
|
||||||
|
|
||||||
|
const user = await createUser(request);
|
||||||
|
userId = user.userId;
|
||||||
|
const address = await createTestAddress(request, 'usr-browser-');
|
||||||
|
const secondAddress = await createTestAddress(request, 'usr-second-');
|
||||||
|
addresses.push(address, secondAddress);
|
||||||
|
for (const boundAddress of addresses) {
|
||||||
|
const bindRes = await request.post(`${WORKER_URL}/user_api/bind_address`, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${boundAddress.jwt}`,
|
||||||
|
'x-user-token': user.jwt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(bindRes.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.goto(`${FRONTEND_URL}/en/`);
|
||||||
|
await page.evaluate((userJwt) => {
|
||||||
|
localStorage.setItem('userJwt', userJwt);
|
||||||
|
}, user.jwt);
|
||||||
|
await page.goto(`${FRONTEND_URL}/en/user`);
|
||||||
|
|
||||||
|
await expect(page.getByText(user.email)).toBeVisible({ timeout: 15_000 });
|
||||||
|
const credentialResponse = page.waitForResponse((response) => (
|
||||||
|
response.request().method() === 'GET'
|
||||||
|
&& new URL(response.url()).pathname
|
||||||
|
=== `/user_api/bind_address_jwt/${address.address_id}`
|
||||||
|
));
|
||||||
|
const addressRow = page.getByRole('row').filter({ hasText: address.address });
|
||||||
|
await addressRow.getByRole('button', { name: 'Address Credential' }).click();
|
||||||
|
expect((await credentialResponse).ok()).toBe(true);
|
||||||
|
await expect(page.getByRole('dialog')).toContainText(address.address);
|
||||||
|
await page.getByRole('button', { name: 'close' }).click();
|
||||||
|
|
||||||
|
const initialSettingsResponse = page.waitForResponse((response) => (
|
||||||
|
new URL(response.url()).pathname
|
||||||
|
=== `/user_api/address/${secondAddress.address_id}/settings`
|
||||||
|
));
|
||||||
|
await page.getByText('Send Mail', { exact: true }).click();
|
||||||
|
expect((await initialSettingsResponse).ok()).toBe(true);
|
||||||
|
await expect(page.locator('.composer-title h2')).toHaveText('Compose email');
|
||||||
|
|
||||||
|
const settingsResponse = page.waitForResponse((response) => (
|
||||||
|
new URL(response.url()).pathname
|
||||||
|
=== `/user_api/address/${address.address_id}/settings`
|
||||||
|
));
|
||||||
|
await page.locator('.address-picker-select').click();
|
||||||
|
await page.locator('.n-base-select-option').filter({ hasText: address.address }).click();
|
||||||
|
expect((await settingsResponse).ok()).toBe(true);
|
||||||
|
await expect(page.locator('.address-picker-select')).toContainText(address.address);
|
||||||
|
|
||||||
|
const subject = `Browser user send ${Date.now()}`;
|
||||||
|
await page.getByRole('textbox', { name: /^Recipient address/ })
|
||||||
|
.fill('recipient@test.example.com');
|
||||||
|
await page.getByRole('textbox', { name: /^Subject/ }).fill(subject);
|
||||||
|
await page.locator('.compose-textarea textarea').fill('Sent from the user page');
|
||||||
|
|
||||||
|
const sendResponse = page.waitForResponse((response) => (
|
||||||
|
response.request().method() === 'POST'
|
||||||
|
&& new URL(response.url()).pathname
|
||||||
|
=== `/user_api/address/${address.address_id}/send_mail`
|
||||||
|
));
|
||||||
|
const sendboxResponse = page.waitForResponse((response) => (
|
||||||
|
response.request().method() === 'GET'
|
||||||
|
&& new URL(response.url()).pathname === '/user_api/sendbox'
|
||||||
|
));
|
||||||
|
await page.getByRole('button', { name: 'Send', exact: true }).click();
|
||||||
|
expect((await sendResponse).ok()).toBe(true);
|
||||||
|
expect((await sendboxResponse).ok()).toBe(true);
|
||||||
|
|
||||||
|
await expect(page.locator('.n-tabs-tab--active')).toHaveText('Sent');
|
||||||
|
await expect(page.locator('.n-thing-header__title').filter({ hasText: subject }))
|
||||||
|
.toHaveText(subject, { timeout: 15_000 });
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await Promise.allSettled(addresses.map((address) => deleteAddress(request, address.jwt)));
|
||||||
|
if (userId !== undefined) {
|
||||||
|
await request.delete(`${WORKER_URL}/admin/users/${userId}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (originalUserSettings) {
|
||||||
|
await request.post(`${WORKER_URL}/admin/user_settings`, {
|
||||||
|
data: originalUserSettings,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await request.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { once } from 'node:events';
|
||||||
|
import { after, before, test } from 'node:test';
|
||||||
|
import { WebSocketServer } from 'ws';
|
||||||
|
|
||||||
|
let server;
|
||||||
|
let onMailpitMessage;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
server = new WebSocketServer({ host: '127.0.0.1', port: 0 });
|
||||||
|
await once(server, 'listening');
|
||||||
|
const originalApi = process.env.MAILPIT_API;
|
||||||
|
process.env.MAILPIT_API = `http://127.0.0.1:${server.address().port}/api`;
|
||||||
|
try {
|
||||||
|
({ onMailpitMessage } = await import('../../fixtures/test-helpers.ts'));
|
||||||
|
} finally {
|
||||||
|
if (originalApi === undefined) delete process.env.MAILPIT_API;
|
||||||
|
else process.env.MAILPIT_API = originalApi;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
for (const client of server.clients) client.terminate();
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
});
|
||||||
|
|
||||||
|
const target = { Subject: 'target-message' };
|
||||||
|
const newEvent = JSON.stringify({ Type: 'new', Data: target });
|
||||||
|
const statsEvent = JSON.stringify({ Type: 'stats', Data: { total: 1 } });
|
||||||
|
|
||||||
|
for (const [name, payload] of [
|
||||||
|
['single event', newEvent],
|
||||||
|
['new event followed by stats', `${newEvent}\n${statsEvent}`],
|
||||||
|
['stats followed by new event', `${statsEvent}\n${newEvent}`],
|
||||||
|
['malformed and unrelated events before the match',
|
||||||
|
`invalid-json\n${JSON.stringify({ Type: 'new', Data: { Subject: 'other' } })}\n${newEvent}\n`],
|
||||||
|
]) {
|
||||||
|
test(name, async () => {
|
||||||
|
const connected = once(server, 'connection');
|
||||||
|
const listener = onMailpitMessage((mail) => mail.Subject === target.Subject, { timeout: 1000 });
|
||||||
|
const received = assert.doesNotReject(async () => {
|
||||||
|
assert.deepEqual(await listener.message, target);
|
||||||
|
});
|
||||||
|
const [client] = await connected;
|
||||||
|
await listener.ready;
|
||||||
|
client.send(payload);
|
||||||
|
await received;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
VITE_API_BASE=https://temp-email-api.xxx.xxx
|
VITE_API_BASE=https://temp-email-api.xxx.xxx
|
||||||
|
VITE_DEFAULT_LANG=zh
|
||||||
VITE_CF_WEB_ANALY_TOKEN=
|
VITE_CF_WEB_ANALY_TOKEN=
|
||||||
VITE_IS_TELEGRAM=false
|
VITE_IS_TELEGRAM=false
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<link rel="icon" href="/logo.png" sizes="any">
|
<link rel="icon" href="/logo.png" sizes="any">
|
||||||
<link rel="apple-touch-icon" href="/logo.png">
|
<link rel="apple-touch-icon" href="/logo.png">
|
||||||
|
<script id="app-config" type="application/json">{}</script>
|
||||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cloudflare_temp_email",
|
"name": "cloudflare_temp_email",
|
||||||
"version": "1.11.1",
|
"version": "1.12.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -28,16 +28,16 @@
|
|||||||
"@vueuse/core": "^14.4.0",
|
"@vueuse/core": "^14.4.0",
|
||||||
"@wangeditor/editor": "^5.1.23",
|
"@wangeditor/editor": "^5.1.23",
|
||||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||||
"axios": "^1.19.0",
|
"axios": "^1.20.0",
|
||||||
"dompurify": "^3.4.13",
|
"dompurify": "^3.4.15",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"mail-parser-wasm": "^0.2.2",
|
"mail-parser-wasm": "^0.2.2",
|
||||||
"naive-ui": "^2.45.0",
|
"naive-ui": "^2.45.3",
|
||||||
"postal-mime": "^2.7.6",
|
"postal-mime": "^2.7.6",
|
||||||
"vooks": "^0.2.12",
|
"vooks": "^0.2.12",
|
||||||
"vue": "^3.5.41",
|
"vue": "^3.5.42",
|
||||||
"vue-clipboard3": "^2.0.0",
|
"vue-clipboard3": "^2.0.0",
|
||||||
"vue-i18n": "^11.4.8",
|
"vue-i18n": "^11.4.10",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
"vitest": "^4.1.11",
|
"vitest": "^4.1.11",
|
||||||
"workbox-build": "^7.4.1",
|
"workbox-build": "^7.4.1",
|
||||||
"workbox-window": "^7.4.1",
|
"workbox-window": "^7.4.1",
|
||||||
"wrangler": "^4.124.0"
|
"wrangler": "^4.129.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+426
-420
File diff suppressed because it is too large
Load Diff
+12
-10
@@ -3,7 +3,7 @@ import {
|
|||||||
darkTheme,
|
darkTheme,
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
import { computed, onMounted, watchEffect } from 'vue'
|
import { computed, onMounted, watchEffect } from 'vue'
|
||||||
import { useScript } from '@unhead/vue'
|
import { useHead } from '@unhead/vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useGlobalState } from './store'
|
import { useGlobalState } from './store'
|
||||||
import { useIsMobile } from './utils/composables'
|
import { useIsMobile } from './utils/composables'
|
||||||
@@ -12,12 +12,13 @@ import Footer from './views/Footer.vue';
|
|||||||
import { api } from './api'
|
import { api } from './api'
|
||||||
import { getNaiveLocaleConfig } from './i18n/naive-locale'
|
import { getNaiveLocaleConfig } from './i18n/naive-locale'
|
||||||
import { DEFAULT_LOCALE, isSupportedLocale } from './i18n/utils'
|
import { DEFAULT_LOCALE, isSupportedLocale } from './i18n/utils'
|
||||||
|
import { APP_CONFIG } from './config'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isDark, loading, useSideMargin, telegramApp, isTelegram
|
isDark, loading, useSideMargin, telegramApp, isTelegram
|
||||||
} = useGlobalState()
|
} = useGlobalState()
|
||||||
const adClient = import.meta.env.VITE_GOOGLE_AD_CLIENT;
|
const adClient = APP_CONFIG.GOOGLE_AD_CLIENT;
|
||||||
const adSlot = import.meta.env.VITE_GOOGLE_AD_SLOT;
|
const adSlot = APP_CONFIG.GOOGLE_AD_SLOT;
|
||||||
const { locale } = useI18n({ useScope: 'global' });
|
const { locale } = useI18n({ useScope: 'global' });
|
||||||
const theme = computed(() => isDark.value ? darkTheme : null)
|
const theme = computed(() => isDark.value ? darkTheme : null)
|
||||||
const localeConfig = computed(() => getNaiveLocaleConfig(isSupportedLocale(locale.value) ? locale.value : DEFAULT_LOCALE))
|
const localeConfig = computed(() => getNaiveLocaleConfig(isSupportedLocale(locale.value) ? locale.value : DEFAULT_LOCALE))
|
||||||
@@ -31,12 +32,13 @@ watchEffect(() => {
|
|||||||
document.documentElement.lang = isSupportedLocale(locale.value) ? locale.value : DEFAULT_LOCALE
|
document.documentElement.lang = isSupportedLocale(locale.value) ? locale.value : DEFAULT_LOCALE
|
||||||
})
|
})
|
||||||
|
|
||||||
// Load Google Ad script at top level (not inside onMounted)
|
|
||||||
if (showAd.value) {
|
if (showAd.value) {
|
||||||
useScript({
|
useHead({
|
||||||
src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`,
|
script: [{
|
||||||
async: true,
|
src: `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${adClient}`,
|
||||||
crossorigin: "anonymous",
|
async: true,
|
||||||
|
crossorigin: 'anonymous',
|
||||||
|
}],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +49,7 @@ onMounted(async () => {
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = import.meta.env.VITE_CF_WEB_ANALY_TOKEN;
|
const token = APP_CONFIG.CF_WEB_ANALY_TOKEN;
|
||||||
|
|
||||||
const exist = document.querySelector('script[src="https://static.cloudflareinsights.com/beacon.min.js"]') !== null
|
const exist = document.querySelector('script[src="https://static.cloudflareinsights.com/beacon.min.js"]') !== null
|
||||||
if (token && !exist) {
|
if (token && !exist) {
|
||||||
@@ -66,7 +68,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
|
|
||||||
// check if telegram is enabled
|
// check if telegram is enabled
|
||||||
const enableTelegram = import.meta.env.VITE_IS_TELEGRAM;
|
const enableTelegram = APP_CONFIG.IS_TELEGRAM;
|
||||||
if (
|
if (
|
||||||
(typeof enableTelegram === 'boolean' && enableTelegram === true)
|
(typeof enableTelegram === 'boolean' && enableTelegram === true)
|
||||||
||
|
||
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const setRuntimeConfig = (config) => {
|
||||||
|
const element = document.createElement('script')
|
||||||
|
element.id = 'app-config'
|
||||||
|
element.type = 'application/json'
|
||||||
|
element.textContent = JSON.stringify(config)
|
||||||
|
document.head.appendChild(element)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('APP_CONFIG', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules()
|
||||||
|
vi.stubEnv('VITE_API_BASE', 'https://build.example.com')
|
||||||
|
vi.stubEnv('VITE_DEFAULT_LANG', 'zh')
|
||||||
|
vi.stubEnv('VITE_IS_TELEGRAM', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.querySelector('#app-config')?.remove()
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses build settings when runtime settings are absent', async () => {
|
||||||
|
const { APP_CONFIG } = await import('../config')
|
||||||
|
|
||||||
|
expect(APP_CONFIG.API_BASE).toBe('https://build.example.com')
|
||||||
|
expect(APP_CONFIG.DEFAULT_LANG).toBe('zh')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('overrides only settings provided by index.html', async () => {
|
||||||
|
setRuntimeConfig({ API_BASE: 'https://runtime.example.com', DEFAULT_LANG: 'en' })
|
||||||
|
|
||||||
|
const { APP_CONFIG } = await import('../config')
|
||||||
|
|
||||||
|
expect(APP_CONFIG.API_BASE).toBe('https://runtime.example.com')
|
||||||
|
expect(APP_CONFIG.DEFAULT_LANG).toBe('en')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('allows an explicit empty runtime value', async () => {
|
||||||
|
setRuntimeConfig({ API_BASE: '' })
|
||||||
|
|
||||||
|
const { APP_CONFIG } = await import('../config')
|
||||||
|
|
||||||
|
expect(APP_CONFIG.API_BASE).toBe('')
|
||||||
|
expect(APP_CONFIG.DEFAULT_LANG).toBe('zh')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to build settings for invalid runtime value types', async () => {
|
||||||
|
setRuntimeConfig({ API_BASE: {}, DEFAULT_LANG: 1, IS_TELEGRAM: [] })
|
||||||
|
|
||||||
|
const { APP_CONFIG } = await import('../config')
|
||||||
|
|
||||||
|
expect(APP_CONFIG.API_BASE).toBe('https://build.example.com')
|
||||||
|
expect(APP_CONFIG.DEFAULT_LANG).toBe('zh')
|
||||||
|
expect(APP_CONFIG.IS_TELEGRAM).toBe('false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads runtime settings only once', async () => {
|
||||||
|
setRuntimeConfig({ DEFAULT_LANG: 'en' })
|
||||||
|
const firstImport = await import('../config')
|
||||||
|
|
||||||
|
document.querySelector('#app-config').textContent = JSON.stringify({ DEFAULT_LANG: 'de' })
|
||||||
|
const secondImport = await import('../config')
|
||||||
|
|
||||||
|
expect(secondImport.APP_CONFIG).toBe(firstImport.APP_CONFIG)
|
||||||
|
expect(secondImport.APP_CONFIG.DEFAULT_LANG).toBe('en')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export const ErrorCode = {
|
||||||
|
INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
|
||||||
|
AUTH_SITE_PASSWORD_INVALID: 'AUTH_SITE_PASSWORD_INVALID',
|
||||||
|
AUTH_ADMIN_CREDENTIAL_INVALID: 'AUTH_ADMIN_CREDENTIAL_INVALID',
|
||||||
|
AUTH_USER_ACCESS_TOKEN_EXPIRED: 'AUTH_USER_ACCESS_TOKEN_EXPIRED',
|
||||||
|
|
||||||
|
isSiteAuthError: (response) => response.status === 401
|
||||||
|
&& response.data?.code === ErrorCode.AUTH_SITE_PASSWORD_INVALID,
|
||||||
|
|
||||||
|
isUserAccessTokenError: (response) => response.status === 401
|
||||||
|
&& response.data?.code === ErrorCode.AUTH_USER_ACCESS_TOKEN_EXPIRED,
|
||||||
|
|
||||||
|
isAdminAuthError: (response) => response.status === 401
|
||||||
|
&& response.data?.code === ErrorCode.AUTH_ADMIN_CREDENTIAL_INVALID,
|
||||||
|
};
|
||||||
+49
-18
@@ -1,27 +1,44 @@
|
|||||||
import { useGlobalState } from '../store'
|
import { useGlobalState } from '../store'
|
||||||
import { h } from 'vue'
|
import { h } from 'vue'
|
||||||
|
import { useLocalStorage } from '@vueuse/core'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
import i18n from '../i18n'
|
import i18n from '../i18n'
|
||||||
import { getFingerprint } from '../utils/fingerprint'
|
import { getFingerprint } from '../utils/fingerprint'
|
||||||
import { safeBearerHeader, safeHeaderValue } from '../utils/headers'
|
import { safeBearerHeader, safeHeaderValue } from '../utils/headers'
|
||||||
import { sanitizeHtml } from '../utils/sanitize-html'
|
import { sanitizeHtml } from '../utils/sanitize-html'
|
||||||
|
import { APP_CONFIG } from '../config'
|
||||||
|
import { createUserAccessTokenInterceptor } from './user-access-token-interceptor'
|
||||||
|
import { ErrorCode } from './error-codes'
|
||||||
|
import { updateLocalAddressCache } from '../utils/local-address-cache'
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE || "";
|
const API_BASE = APP_CONFIG.API_BASE || "";
|
||||||
const {
|
const {
|
||||||
loading, auth, jwt, settings, openSettings,
|
loading, auth, jwt, settings, openSettings,
|
||||||
userOpenSettings, userSettings, announcement,
|
userOpenSettings, userSettings, announcement,
|
||||||
showAuth, adminAuth, showAdminAuth, userJwt
|
showAuth, adminAuth, showAdminAuth, userJwt
|
||||||
} = useGlobalState();
|
} = useGlobalState();
|
||||||
|
|
||||||
|
const localAddressCache = useLocalStorage('LocalAddressCache', []);
|
||||||
|
|
||||||
const instance = axios.create({
|
const instance = axios.create({
|
||||||
baseURL: API_BASE,
|
baseURL: API_BASE,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
validateStatus: (status) => status >= 200 && status <= 500
|
validateStatus: (status) => status >= 200 && status <= 500
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const responseInterceptors = [createUserAccessTokenInterceptor(instance)];
|
||||||
|
|
||||||
|
const interceptResponse = async (path, response) => {
|
||||||
|
for (const { matches, handle } of responseInterceptors) {
|
||||||
|
if (matches(path, response)) return await handle(response);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
const apiFetch = async (path, options = {}) => {
|
const apiFetch = async (path, options = {}) => {
|
||||||
loading.value = true;
|
const showLoading = options.showLoading !== false;
|
||||||
|
if (showLoading) loading.value = true;
|
||||||
try {
|
try {
|
||||||
// Get browser fingerprint for request tracking
|
// Get browser fingerprint for request tracking
|
||||||
const fingerprint = await getFingerprint();
|
const fingerprint = await getFingerprint();
|
||||||
@@ -42,32 +59,33 @@ const apiFetch = async (path, options = {}) => {
|
|||||||
if (customAuthHeader) headers['x-custom-auth'] = customAuthHeader;
|
if (customAuthHeader) headers['x-custom-auth'] = customAuthHeader;
|
||||||
const adminAuthHeader = safeHeaderValue(adminAuth.value);
|
const adminAuthHeader = safeHeaderValue(adminAuth.value);
|
||||||
if (adminAuthHeader) headers['x-admin-auth'] = adminAuthHeader;
|
if (adminAuthHeader) headers['x-admin-auth'] = adminAuthHeader;
|
||||||
const authorizationHeader = safeBearerHeader(jwt.value);
|
const authorizationHeader = safeBearerHeader(options.addressJwt ?? jwt.value);
|
||||||
if (authorizationHeader) headers['Authorization'] = authorizationHeader;
|
if (authorizationHeader) headers['Authorization'] = authorizationHeader;
|
||||||
|
|
||||||
const response = await instance.request(path, {
|
const initialResponse = await instance.request(path, {
|
||||||
method: options.method || 'GET',
|
method: options.method || 'GET',
|
||||||
data: options.body || null,
|
data: options.body || null,
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
if (response.status === 401 && path.startsWith("/admin")) {
|
const response = await interceptResponse(path, initialResponse);
|
||||||
|
if (ErrorCode.isAdminAuthError(response)) {
|
||||||
showAdminAuth.value = true;
|
showAdminAuth.value = true;
|
||||||
}
|
}
|
||||||
if (response.status === 401 && openSettings.value.needAuth) {
|
if (ErrorCode.isSiteAuthError(response)) {
|
||||||
showAuth.value = true;
|
showAuth.value = true;
|
||||||
}
|
}
|
||||||
if (response.status >= 300) {
|
if (response.status >= 300) {
|
||||||
throw new Error(`[${response.status}]: ${response.data}` || "error");
|
throw new Error(`[${response.status}]: ${response.data?.message || response.data}`);
|
||||||
}
|
}
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
throw new Error(`Code ${error.response.status}: ${error.response.data}` || "error");
|
throw new Error(`Code ${error.response.status}: ${error.response.data?.message || error.response.data}`);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
if (showLoading) loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +117,7 @@ const getOpenSettings = async (message, notification) => {
|
|||||||
disableAnonymousUserCreateEmail: res["disableAnonymousUserCreateEmail"] || false,
|
disableAnonymousUserCreateEmail: res["disableAnonymousUserCreateEmail"] || false,
|
||||||
disableCustomAddressName: res["disableCustomAddressName"] || false,
|
disableCustomAddressName: res["disableCustomAddressName"] || false,
|
||||||
enableUserDeleteEmail: res["enableUserDeleteEmail"] || false,
|
enableUserDeleteEmail: res["enableUserDeleteEmail"] || false,
|
||||||
|
enableMailReadStatus: res["enableMailReadStatus"] === true,
|
||||||
enableAutoReply: res["enableAutoReply"] || false,
|
enableAutoReply: res["enableAutoReply"] || false,
|
||||||
enableIndexAbout: res["enableIndexAbout"] || false,
|
enableIndexAbout: res["enableIndexAbout"] || false,
|
||||||
copyright: res["copyright"] || openSettings.value.copyright,
|
copyright: res["copyright"] || openSettings.value.copyright,
|
||||||
@@ -107,7 +126,10 @@ const getOpenSettings = async (message, notification) => {
|
|||||||
isS3Enabled: res["isS3Enabled"] || false,
|
isS3Enabled: res["isS3Enabled"] || false,
|
||||||
showGithubForUser: res["showGithubForUser"] ?? openSettings.value.showGithubForUser,
|
showGithubForUser: res["showGithubForUser"] ?? openSettings.value.showGithubForUser,
|
||||||
enableAddressPassword: res["enableAddressPassword"] || false,
|
enableAddressPassword: res["enableAddressPassword"] || false,
|
||||||
|
addressPasswordLoginOnly: res["addressPasswordLoginOnly"] === true,
|
||||||
enableAgentEmailInfo: res["enableAgentEmailInfo"] || false,
|
enableAgentEmailInfo: res["enableAgentEmailInfo"] || false,
|
||||||
|
enableRedeemCode: res["enableRedeemCode"] || false,
|
||||||
|
redeemCodeUrl: res["redeemCodeUrl"] || "",
|
||||||
smtpImapProxyConfig: res["smtpImapProxyConfig"] || openSettings.value.smtpImapProxyConfig,
|
smtpImapProxyConfig: res["smtpImapProxyConfig"] || openSettings.value.smtpImapProxyConfig,
|
||||||
statusUrl: res["statusUrl"] || "",
|
statusUrl: res["statusUrl"] || "",
|
||||||
enableGlobalTurnstileCheck: res["enableGlobalTurnstileCheck"] || false,
|
enableGlobalTurnstileCheck: res["enableGlobalTurnstileCheck"] || false,
|
||||||
@@ -137,18 +159,27 @@ const getOpenSettings = async (message, notification) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getSettings = async () => {
|
const getSettings = async () => {
|
||||||
|
let addressToken = jwt.value;
|
||||||
try {
|
try {
|
||||||
if (typeof jwt.value != 'string' || jwt.value.trim() === '' || jwt.value === 'undefined') {
|
if (!safeHeaderValue(addressToken)) return;
|
||||||
return "";
|
const res = await apiFetch('/api/settings', { addressJwt: addressToken });
|
||||||
|
if (jwt.value !== addressToken) return;
|
||||||
|
localAddressCache.value = updateLocalAddressCache(localAddressCache.value, addressToken, res);
|
||||||
|
settings.value = res;
|
||||||
|
const renewedAddressToken = res.new_address_token;
|
||||||
|
if (!renewedAddressToken) return;
|
||||||
|
try {
|
||||||
|
const renewedSettings = await apiFetch('/api/settings', { addressJwt: renewedAddressToken });
|
||||||
|
if (jwt.value !== addressToken) return;
|
||||||
|
localAddressCache.value = updateLocalAddressCache(localAddressCache.value, renewedAddressToken, renewedSettings);
|
||||||
|
addressToken = renewedAddressToken;
|
||||||
|
jwt.value = renewedAddressToken;
|
||||||
|
settings.value = renewedSettings;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to renew mailbox JWT', error);
|
||||||
}
|
}
|
||||||
const res = await apiFetch("/api/settings");;
|
|
||||||
settings.value = {
|
|
||||||
address: res["address"],
|
|
||||||
auto_reply: res["auto_reply"],
|
|
||||||
send_balance: res["send_balance"],
|
|
||||||
};
|
|
||||||
} finally {
|
} finally {
|
||||||
settings.value.fetched = true;
|
if (jwt.value === addressToken) settings.value.fetched = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { AxiosHeaders } from 'axios';
|
||||||
|
import { useGlobalState } from '../store';
|
||||||
|
import { safeBearerHeader, safeHeaderValue } from '../utils/headers';
|
||||||
|
import { ErrorCode } from './error-codes';
|
||||||
|
|
||||||
|
const { userJwt, userSettings, jwt } = useGlobalState();
|
||||||
|
const mailboxPaths = new Set(['/api/settings', '/api/send_mail']);
|
||||||
|
const paths = ['/admin/', ...mailboxPaths, '/user_api/bind_address', '/user_api/address/'];
|
||||||
|
const getPathname = (url) => new URL(url, window.location.origin).pathname;
|
||||||
|
|
||||||
|
const isCurrentSession = ({ headers }) =>
|
||||||
|
safeHeaderValue(headers.get('x-user-token')) === safeHeaderValue(userJwt.value)
|
||||||
|
&& safeHeaderValue(headers.get('Authorization')) === safeBearerHeader(jwt.value);
|
||||||
|
|
||||||
|
const matches = (path, response) => {
|
||||||
|
if (!ErrorCode.isUserAccessTokenError(response)) return false;
|
||||||
|
const pathname = getPathname(path);
|
||||||
|
if (!paths.some(prefix => pathname.startsWith(prefix))) return false;
|
||||||
|
const { config } = response;
|
||||||
|
if (!isCurrentSession(config)) return false;
|
||||||
|
if (!safeHeaderValue(config.headers.get('x-user-access-token'))) return false;
|
||||||
|
|
||||||
|
const token = safeHeaderValue(config.headers.get('x-user-token'));
|
||||||
|
return Boolean(token) || mailboxPaths.has(pathname);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadUserSettings(token, client, headers) {
|
||||||
|
const response = await client.get('/user_api/settings', { headers });
|
||||||
|
if (response.status >= 300) {
|
||||||
|
throw new Error(`[${response.status}]: ${response.data?.message || response.data}`);
|
||||||
|
}
|
||||||
|
if (safeHeaderValue(userJwt.value) === token) {
|
||||||
|
Object.assign(userSettings.value, response.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createUserAccessTokenInterceptor = (client) => {
|
||||||
|
const pendingRefreshes = new Map();
|
||||||
|
|
||||||
|
async function refreshUserSettings(token, headers) {
|
||||||
|
if (pendingRefreshes.has(token)) return await pendingRefreshes.get(token);
|
||||||
|
const request = loadUserSettings(token, client, headers);
|
||||||
|
pendingRefreshes.set(token, request);
|
||||||
|
try {
|
||||||
|
await request;
|
||||||
|
} finally {
|
||||||
|
pendingRefreshes.delete(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveAccessToken(config) {
|
||||||
|
const token = safeHeaderValue(config.headers.get('x-user-token'));
|
||||||
|
if (!token) return;
|
||||||
|
const currentToken = safeHeaderValue(userSettings.value.access_token);
|
||||||
|
if (currentToken !== safeHeaderValue(config.headers.get('x-user-access-token'))) return currentToken;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await refreshUserSettings(token, config.headers);
|
||||||
|
} catch (error) {
|
||||||
|
if (!mailboxPaths.has(getPathname(config.url))) throw error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return safeHeaderValue(userSettings.value.access_token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
matches,
|
||||||
|
handle: async ({ config }) => {
|
||||||
|
const accessToken = await resolveAccessToken(config);
|
||||||
|
if (!isCurrentSession(config)) throw new Error('User session changed, please retry');
|
||||||
|
const headers = new AxiosHeaders(config.headers);
|
||||||
|
headers.delete('x-user-access-token');
|
||||||
|
if (accessToken) headers.set('x-user-access-token', accessToken);
|
||||||
|
return await client.request({ ...config, headers });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import { useScopedI18n } from '@/i18n/app'
|
||||||
|
import { APP_CONFIG } from '@/config'
|
||||||
|
|
||||||
|
import { useGlobalState } from '../store'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
address: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
jwt: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
addressPassword: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { openSettings, auth } = useGlobalState()
|
||||||
|
const { locale, t } = useScopedI18n('components.AddressCredentialModal')
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const configuredApiBaseUrl = APP_CONFIG.API_BASE || ''
|
||||||
|
const frontendBaseUrl = computed(() => window.location.origin)
|
||||||
|
const apiBaseUrl = computed(() => (configuredApiBaseUrl || frontendBaseUrl.value).replace(/\/$/, ''))
|
||||||
|
const docLocale = computed(() => locale.value === 'zh' ? 'zh' : 'en')
|
||||||
|
const agentDocUrl = computed(() => `https://temp-mail-docs.awsl.uk/${docLocale.value}/guide/feature/agent-email.html`)
|
||||||
|
const smtpImapDocUrl = computed(() => `https://temp-mail-docs.awsl.uk/${docLocale.value}/guide/feature/config-smtp-proxy.html`)
|
||||||
|
const agentSkillUrl = 'https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/skills/cf-temp-mail-agent-mail/SKILL.md'
|
||||||
|
const autoLoginUrl = computed(() => `${frontendBaseUrl.value}/?jwt=${encodeURIComponent(props.jwt)}`)
|
||||||
|
const showAgent = computed(() => !!openSettings.value.enableAgentEmailInfo)
|
||||||
|
const smtpImapConfig = computed(() => openSettings.value.smtpImapProxyConfig || {})
|
||||||
|
const smtpConfig = computed(() => smtpImapConfig.value.smtp || {})
|
||||||
|
const imapConfig = computed(() => smtpImapConfig.value.imap || {})
|
||||||
|
const showSmtpImap = computed(() => !!smtpConfig.value.host || !!imapConfig.value.host)
|
||||||
|
const securityLabel = computed(() =>
|
||||||
|
smtpConfig.value.starttls || imapConfig.value.starttls ? t('starttls') : t('plainOrProxyTls')
|
||||||
|
)
|
||||||
|
const agentConfigJson = computed(() => JSON.stringify({
|
||||||
|
base: apiBaseUrl.value,
|
||||||
|
jwt: props.jwt,
|
||||||
|
site_password: auth.value || '',
|
||||||
|
}, null, 2))
|
||||||
|
const agentText = computed(() => [
|
||||||
|
`${t('currentAddress')}: ${props.address || '-'}`,
|
||||||
|
`${t('apiBase')}: ${apiBaseUrl.value}`,
|
||||||
|
`${t('agentSkill')}: ${agentSkillUrl}`,
|
||||||
|
`${t('agentConfig')}:`,
|
||||||
|
agentConfigJson.value,
|
||||||
|
].join('\n'))
|
||||||
|
const smtpImapText = computed(() => [
|
||||||
|
`${t('smtpHost')}: ${smtpConfig.value.host || '-'}`,
|
||||||
|
`${t('smtpPort')}: ${smtpConfig.value.port || 8025}`,
|
||||||
|
`${t('imapHost')}: ${imapConfig.value.host || '-'}`,
|
||||||
|
`${t('imapPort')}: ${imapConfig.value.port || 11143}`,
|
||||||
|
`${t('security')}: ${securityLabel.value}`,
|
||||||
|
`${t('username')}: ${props.address || '-'}`,
|
||||||
|
`${t('password')}: ${props.jwt}`,
|
||||||
|
].join('\n'))
|
||||||
|
|
||||||
|
const copyText = async (text) => {
|
||||||
|
if (!text) return
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
message.success(t('copySuccess'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const textarea = document.createElement('textarea')
|
||||||
|
try {
|
||||||
|
textarea.value = text
|
||||||
|
textarea.setAttribute('readonly', '')
|
||||||
|
textarea.style.position = 'fixed'
|
||||||
|
textarea.style.opacity = '0'
|
||||||
|
document.body.appendChild(textarea)
|
||||||
|
textarea.select()
|
||||||
|
if (document.execCommand('copy')) {
|
||||||
|
message.success(t('copySuccess'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message.error(t('copyFailed'))
|
||||||
|
} finally {
|
||||||
|
textarea.parentNode?.removeChild(textarea)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
message.error(t('copyFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="credential-content">
|
||||||
|
<n-alert type="info" :show-icon="false" :bordered="false">
|
||||||
|
{{ t(openSettings.addressPasswordLoginOnly ? 'passwordOnlyTip' : 'tip') }}
|
||||||
|
</n-alert>
|
||||||
|
<section class="credential-panel">
|
||||||
|
<h3 class="credential-title">{{ t(openSettings.addressPasswordLoginOnly ? 'addressPassword' : 'addressCredential') }}</h3>
|
||||||
|
<div class="credential-section">
|
||||||
|
<div v-if="address" class="credential-field">
|
||||||
|
<span class="credential-label">{{ t('currentAddress') }}</span>
|
||||||
|
<div class="credential-copy-row">
|
||||||
|
<code data-testid="address-credential-address" class="credential-code">{{ address }}</code>
|
||||||
|
<n-button size="tiny" tertiary type="primary" @click="copyText(address)">
|
||||||
|
{{ t('copySection') }}
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!openSettings.addressPasswordLoginOnly" class="credential-field">
|
||||||
|
<span class="credential-label">{{ t('addressCredentialLabel') }}</span>
|
||||||
|
<div class="credential-copy-row">
|
||||||
|
<code data-testid="address-credential-jwt" class="credential-code">{{ jwt }}</code>
|
||||||
|
<n-button size="tiny" tertiary type="primary" @click="copyText(jwt)">
|
||||||
|
{{ t('copySection') }}
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="addressPassword" class="credential-field">
|
||||||
|
<span class="credential-label">{{ t('addressPassword') }}</span>
|
||||||
|
<code data-testid="address-credential-password" class="credential-code">{{ addressPassword }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<n-collapse v-if="!openSettings.addressPasswordLoginOnly" accordion class="credential-collapse">
|
||||||
|
<n-collapse-item v-if="showAgent" name="agent" :title="t('agentAccess')">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(agentText)">
|
||||||
|
{{ t('copySection') }}
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
<div class="credential-section">
|
||||||
|
<p class="credential-tip">{{ t('agentAccessTip') }}</p>
|
||||||
|
<div class="credential-field">
|
||||||
|
<span class="credential-label">{{ t('apiBase') }}</span>
|
||||||
|
<code class="credential-code">{{ apiBaseUrl }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="credential-field">
|
||||||
|
<span class="credential-label">{{ t('agentSkill') }}</span>
|
||||||
|
<code class="credential-code">
|
||||||
|
<a :href="agentSkillUrl" target="_blank" rel="noopener noreferrer">{{ agentSkillUrl }}</a>
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div class="credential-field">
|
||||||
|
<span class="credential-label">{{ t('agentConfig') }}</span>
|
||||||
|
<pre class="credential-code credential-code-block">{{ agentConfigJson }}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="credential-actions">
|
||||||
|
<n-button tag="a" :href="agentDocUrl" target="_blank" rel="noopener noreferrer" text type="primary">
|
||||||
|
{{ t('docs') }}
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-collapse-item>
|
||||||
|
|
||||||
|
<n-collapse-item v-if="showSmtpImap" name="smtp-imap" :title="t('smtpImapAccess')">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(smtpImapText)">
|
||||||
|
{{ t('copySection') }}
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
<div class="credential-section">
|
||||||
|
<p class="credential-tip">{{ t('smtpImapTip') }}</p>
|
||||||
|
<div class="credential-grid">
|
||||||
|
<div class="credential-field">
|
||||||
|
<span class="credential-label">{{ t('smtpHost') }}</span>
|
||||||
|
<code class="credential-code">{{ smtpConfig.host || '-' }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="credential-field">
|
||||||
|
<span class="credential-label">{{ t('smtpPort') }}</span>
|
||||||
|
<code class="credential-code">{{ smtpConfig.port || 8025 }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="credential-field">
|
||||||
|
<span class="credential-label">{{ t('imapHost') }}</span>
|
||||||
|
<code class="credential-code">{{ imapConfig.host || '-' }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="credential-field">
|
||||||
|
<span class="credential-label">{{ t('imapPort') }}</span>
|
||||||
|
<code class="credential-code">{{ imapConfig.port || 11143 }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="credential-field">
|
||||||
|
<span class="credential-label">{{ t('security') }}</span>
|
||||||
|
<code class="credential-code">{{ securityLabel }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="credential-field">
|
||||||
|
<span class="credential-label">{{ t('username') }}</span>
|
||||||
|
<code class="credential-code">{{ address }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="credential-field">
|
||||||
|
<span class="credential-label">{{ t('password') }}</span>
|
||||||
|
<code class="credential-code">{{ jwt }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="credential-actions">
|
||||||
|
<n-button tag="a" :href="smtpImapDocUrl" target="_blank" rel="noopener noreferrer" text type="primary">
|
||||||
|
{{ t('docs') }}
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-collapse-item>
|
||||||
|
|
||||||
|
<n-collapse-item name="share-link" :title="t('autoLoginLink')">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(autoLoginUrl)">
|
||||||
|
{{ t('copySection') }}
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
<div class="credential-section">
|
||||||
|
<div class="credential-field">
|
||||||
|
<code class="credential-code">{{ autoLoginUrl }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-collapse-item>
|
||||||
|
</n-collapse>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.credential-collapse {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-tip {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--n-text-color-2);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-label {
|
||||||
|
color: var(--n-text-color-2);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-code {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--n-color-embedded);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-copy-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-code-block {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.credential-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-copy-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import { useScopedI18n } from '@/i18n/app'
|
import { useScopedI18n } from '@/i18n/app'
|
||||||
|
|
||||||
|
import AddressCredentialContent from './AddressCredentialContent.vue'
|
||||||
import { useGlobalState } from '../store'
|
import { useGlobalState } from '../store'
|
||||||
|
|
||||||
|
const { openSettings } = useGlobalState()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: {
|
show: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -24,299 +28,17 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:show'])
|
const emit = defineEmits(['update:show'])
|
||||||
|
const { t } = useScopedI18n('components.AddressCredentialModal')
|
||||||
const { openSettings, auth } = useGlobalState()
|
|
||||||
const { locale, t } = useScopedI18n('components.AddressCredentialModal')
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const modalShow = computed({
|
const modalShow = computed({
|
||||||
get: () => props.show,
|
get: () => props.show,
|
||||||
set: (value) => emit('update:show', value),
|
set: (value) => emit('update:show', value),
|
||||||
})
|
})
|
||||||
|
|
||||||
const configuredApiBaseUrl = import.meta.env.VITE_API_BASE || ''
|
|
||||||
const frontendBaseUrl = computed(() => window.location.origin)
|
|
||||||
const apiBaseUrl = computed(() => (configuredApiBaseUrl || frontendBaseUrl.value).replace(/\/$/, ''))
|
|
||||||
const docLocale = computed(() => locale.value === 'zh' ? 'zh' : 'en')
|
|
||||||
const agentDocUrl = computed(() => `https://temp-mail-docs.awsl.uk/${docLocale.value}/guide/feature/agent-email.html`)
|
|
||||||
const smtpImapDocUrl = computed(() => `https://temp-mail-docs.awsl.uk/${docLocale.value}/guide/feature/config-smtp-proxy.html`)
|
|
||||||
const agentSkillUrl = 'https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/skills/cf-temp-mail-agent-mail/SKILL.md'
|
|
||||||
const autoLoginUrl = computed(() => `${frontendBaseUrl.value}/?jwt=${encodeURIComponent(props.jwt)}`)
|
|
||||||
const showAgent = computed(() => !!openSettings.value.enableAgentEmailInfo)
|
|
||||||
const smtpImapConfig = computed(() => openSettings.value.smtpImapProxyConfig || {})
|
|
||||||
const smtpConfig = computed(() => smtpImapConfig.value.smtp || {})
|
|
||||||
const imapConfig = computed(() => smtpImapConfig.value.imap || {})
|
|
||||||
const showSmtpImap = computed(() => !!smtpConfig.value.host || !!imapConfig.value.host)
|
|
||||||
const securityLabel = computed(() =>
|
|
||||||
smtpConfig.value.starttls || imapConfig.value.starttls ? t('starttls') : t('plainOrProxyTls')
|
|
||||||
)
|
|
||||||
const agentConfigJson = computed(() => JSON.stringify({
|
|
||||||
base: apiBaseUrl.value,
|
|
||||||
jwt: props.jwt,
|
|
||||||
site_password: auth.value || '',
|
|
||||||
}, null, 2))
|
|
||||||
const agentText = computed(() => [
|
|
||||||
`${t('currentAddress')}: ${props.address || '-'}`,
|
|
||||||
`${t('apiBase')}: ${apiBaseUrl.value}`,
|
|
||||||
`${t('agentSkill')}: ${agentSkillUrl}`,
|
|
||||||
`${t('agentConfig')}:`,
|
|
||||||
agentConfigJson.value,
|
|
||||||
].join('\n'))
|
|
||||||
const smtpImapText = computed(() => [
|
|
||||||
`${t('smtpHost')}: ${smtpConfig.value.host || '-'}`,
|
|
||||||
`${t('smtpPort')}: ${smtpConfig.value.port || 8025}`,
|
|
||||||
`${t('imapHost')}: ${imapConfig.value.host || '-'}`,
|
|
||||||
`${t('imapPort')}: ${imapConfig.value.port || 11143}`,
|
|
||||||
`${t('security')}: ${securityLabel.value}`,
|
|
||||||
`${t('username')}: ${props.address || '-'}`,
|
|
||||||
`${t('password')}: ${props.jwt}`,
|
|
||||||
].join('\n'))
|
|
||||||
|
|
||||||
const copyText = async (text) => {
|
|
||||||
if (!text) return
|
|
||||||
try {
|
|
||||||
if (navigator.clipboard?.writeText) {
|
|
||||||
await navigator.clipboard.writeText(text)
|
|
||||||
message.success(t('copySuccess'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const textarea = document.createElement('textarea')
|
|
||||||
try {
|
|
||||||
textarea.value = text
|
|
||||||
textarea.setAttribute('readonly', '')
|
|
||||||
textarea.style.position = 'fixed'
|
|
||||||
textarea.style.opacity = '0'
|
|
||||||
document.body.appendChild(textarea)
|
|
||||||
textarea.select()
|
|
||||||
if (document.execCommand('copy')) {
|
|
||||||
message.success(t('copySuccess'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
message.error(t('copyFailed'))
|
|
||||||
} finally {
|
|
||||||
textarea.parentNode?.removeChild(textarea)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error)
|
|
||||||
message.error(t('copyFailed'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-modal v-model:show="modalShow" preset="card" :title="t('title')"
|
<n-modal v-model:show="modalShow" preset="card" :title="t(openSettings.addressPasswordLoginOnly ? 'addressPassword' : 'title')"
|
||||||
style="width: min(760px, calc(100vw - 32px));">
|
style="width: min(760px, calc(100vw - 32px));">
|
||||||
<n-alert type="info" :show-icon="false" :bordered="false">
|
<AddressCredentialContent :address="address" :jwt="jwt" :address-password="addressPassword" />
|
||||||
{{ t('tip') }}
|
|
||||||
</n-alert>
|
|
||||||
<section class="credential-panel">
|
|
||||||
<h3 class="credential-title">{{ t('addressCredential') }}</h3>
|
|
||||||
<div class="credential-section">
|
|
||||||
<div class="credential-field" v-if="address">
|
|
||||||
<span class="credential-label">{{ t('currentAddress') }}</span>
|
|
||||||
<div class="credential-copy-row">
|
|
||||||
<code class="credential-code">{{ address }}</code>
|
|
||||||
<n-button size="tiny" tertiary type="primary" @click="copyText(address)">
|
|
||||||
{{ t('copySection') }}
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="credential-field">
|
|
||||||
<span class="credential-label">{{ t('addressCredentialLabel') }}</span>
|
|
||||||
<div class="credential-copy-row">
|
|
||||||
<code class="credential-code">{{ jwt }}</code>
|
|
||||||
<n-button size="tiny" tertiary type="primary" @click="copyText(jwt)">
|
|
||||||
{{ t('copySection') }}
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="credential-field" v-if="addressPassword">
|
|
||||||
<span class="credential-label">{{ t('addressPassword') }}</span>
|
|
||||||
<code class="credential-code">{{ addressPassword }}</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<n-collapse accordion class="credential-collapse">
|
|
||||||
<n-collapse-item v-if="showAgent" name="agent" :title="t('agentAccess')">
|
|
||||||
<template #header-extra>
|
|
||||||
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(agentText)">
|
|
||||||
{{ t('copySection') }}
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
<div class="credential-section">
|
|
||||||
<p class="credential-tip">{{ t('agentAccessTip') }}</p>
|
|
||||||
<div class="credential-field">
|
|
||||||
<span class="credential-label">{{ t('apiBase') }}</span>
|
|
||||||
<code class="credential-code">{{ apiBaseUrl }}</code>
|
|
||||||
</div>
|
|
||||||
<div class="credential-field">
|
|
||||||
<span class="credential-label">{{ t('agentSkill') }}</span>
|
|
||||||
<code class="credential-code">
|
|
||||||
<a :href="agentSkillUrl" target="_blank" rel="noopener noreferrer">{{ agentSkillUrl }}</a>
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
<div class="credential-field">
|
|
||||||
<span class="credential-label">{{ t('agentConfig') }}</span>
|
|
||||||
<pre class="credential-code credential-code-block">{{ agentConfigJson }}</pre>
|
|
||||||
</div>
|
|
||||||
<div class="credential-actions">
|
|
||||||
<n-button tag="a" :href="agentDocUrl" target="_blank" rel="noopener noreferrer" text type="primary">
|
|
||||||
{{ t('docs') }}
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</n-collapse-item>
|
|
||||||
|
|
||||||
<n-collapse-item v-if="showSmtpImap" name="smtp-imap" :title="t('smtpImapAccess')">
|
|
||||||
<template #header-extra>
|
|
||||||
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(smtpImapText)">
|
|
||||||
{{ t('copySection') }}
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
<div class="credential-section">
|
|
||||||
<p class="credential-tip">{{ t('smtpImapTip') }}</p>
|
|
||||||
<div class="credential-grid">
|
|
||||||
<div class="credential-field">
|
|
||||||
<span class="credential-label">{{ t('smtpHost') }}</span>
|
|
||||||
<code class="credential-code">{{ smtpConfig.host || '-' }}</code>
|
|
||||||
</div>
|
|
||||||
<div class="credential-field">
|
|
||||||
<span class="credential-label">{{ t('smtpPort') }}</span>
|
|
||||||
<code class="credential-code">{{ smtpConfig.port || 8025 }}</code>
|
|
||||||
</div>
|
|
||||||
<div class="credential-field">
|
|
||||||
<span class="credential-label">{{ t('imapHost') }}</span>
|
|
||||||
<code class="credential-code">{{ imapConfig.host || '-' }}</code>
|
|
||||||
</div>
|
|
||||||
<div class="credential-field">
|
|
||||||
<span class="credential-label">{{ t('imapPort') }}</span>
|
|
||||||
<code class="credential-code">{{ imapConfig.port || 11143 }}</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="credential-field">
|
|
||||||
<span class="credential-label">{{ t('security') }}</span>
|
|
||||||
<code class="credential-code">{{ securityLabel }}</code>
|
|
||||||
</div>
|
|
||||||
<div class="credential-field">
|
|
||||||
<span class="credential-label">{{ t('username') }}</span>
|
|
||||||
<code class="credential-code">{{ address }}</code>
|
|
||||||
</div>
|
|
||||||
<div class="credential-field">
|
|
||||||
<span class="credential-label">{{ t('password') }}</span>
|
|
||||||
<code class="credential-code">{{ jwt }}</code>
|
|
||||||
</div>
|
|
||||||
<div class="credential-actions">
|
|
||||||
<n-button tag="a" :href="smtpImapDocUrl" target="_blank" rel="noopener noreferrer" text type="primary">
|
|
||||||
{{ t('docs') }}
|
|
||||||
</n-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</n-collapse-item>
|
|
||||||
|
|
||||||
<n-collapse-item name="share-link" :title="t('autoLoginLink')">
|
|
||||||
<template #header-extra>
|
|
||||||
<n-button size="tiny" tertiary type="primary" @click.stop="copyText(autoLoginUrl)">
|
|
||||||
{{ t('copySection') }}
|
|
||||||
</n-button>
|
|
||||||
</template>
|
|
||||||
<div class="credential-section">
|
|
||||||
<div class="credential-field">
|
|
||||||
<code class="credential-code">{{ autoLoginUrl }}</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</n-collapse-item>
|
|
||||||
</n-collapse>
|
|
||||||
</n-modal>
|
</n-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.credential-collapse {
|
|
||||||
margin-top: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-panel {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
margin-top: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-section {
|
|
||||||
display: grid;
|
|
||||||
gap: 12px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-tip {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--n-text-color-2);
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-field {
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-label {
|
|
||||||
color: var(--n-text-color-2);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-code {
|
|
||||||
display: block;
|
|
||||||
min-width: 0;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 6px 8px;
|
|
||||||
background: var(--n-color-embedded);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-copy-row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
|
||||||
align-items: start;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-code-block {
|
|
||||||
margin: 0;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-actions {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.credential-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-copy-row {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Copy } from '@vicons/fa'
|
|||||||
|
|
||||||
import { useGlobalState } from '../store'
|
import { useGlobalState } from '../store'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
|
import { getCachedAddresses } from '../utils/local-address-cache'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
showCopy: {
|
showCopy: {
|
||||||
@@ -28,6 +29,8 @@ const {
|
|||||||
} = useGlobalState()
|
} = useGlobalState()
|
||||||
|
|
||||||
const { t } = useScopedI18n('components.AddressSelect')
|
const { t } = useScopedI18n('components.AddressSelect')
|
||||||
|
const { t: loginT } = useScopedI18n('views.common.Login')
|
||||||
|
const { t: localAddressT } = useScopedI18n('views.index.LocalAddress')
|
||||||
|
|
||||||
const addressOptions = ref([])
|
const addressOptions = ref([])
|
||||||
const addressValue = ref(null)
|
const addressValue = ref(null)
|
||||||
@@ -45,21 +48,6 @@ const formatAddressLabel = (address) => {
|
|||||||
return address.replace('@' + domain, `@${domainLabel}`);
|
return address.replace('@' + domain, `@${domainLabel}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseJwtAddress = (curJwt) => {
|
|
||||||
try {
|
|
||||||
const payload = JSON.parse(
|
|
||||||
decodeURIComponent(
|
|
||||||
atob(curJwt.split(".")[1]
|
|
||||||
.replace(/-/g, "+").replace(/_/g, "/")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return payload.address;
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getOptionValue = (key, scope, payload, address) => {
|
const getOptionValue = (key, scope, payload, address) => {
|
||||||
if (optionValueMap.has(key)) {
|
if (optionValueMap.has(key)) {
|
||||||
const cached = optionValueMap.get(key)
|
const cached = optionValueMap.get(key)
|
||||||
@@ -74,18 +62,17 @@ const getOptionValue = (key, scope, payload, address) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const buildLocalOptions = (excludeAddresses = new Set()) => {
|
const buildLocalOptions = (excludeAddresses = new Set()) => {
|
||||||
if (typeof jwt.value === 'string' && jwt.value && !localAddressCache.value.includes(jwt.value)) {
|
const children = getCachedAddresses(localAddressCache.value)
|
||||||
localAddressCache.value.push(jwt.value)
|
.map(({ token, address, type }, index) => {
|
||||||
}
|
|
||||||
const children = localAddressCache.value
|
|
||||||
.map((curJwt) => {
|
|
||||||
const address = parseJwtAddress(curJwt);
|
|
||||||
if (!address) return null;
|
|
||||||
if (excludeAddresses.has(address)) return null;
|
if (excludeAddresses.has(address)) return null;
|
||||||
const label = formatAddressLabel(address);
|
const isPasswordLogin = type === 'address_password_login';
|
||||||
const key = `local:${curJwt}`;
|
if (address && openSettings.value.addressPasswordLoginOnly && !isPasswordLogin) return null;
|
||||||
const option = { label, value: getOptionValue(key, 'local', curJwt, address), address };
|
const label = address
|
||||||
if (settings.value.address && address === settings.value.address) {
|
? `${formatAddressLabel(address)} (${loginT(isPasswordLogin ? 'passwordLogin' : 'credentialLogin')})`
|
||||||
|
: localAddressT('savedMailbox', { index: index + 1 });
|
||||||
|
const key = `local:${token}`;
|
||||||
|
const option = { label, value: getOptionValue(key, 'local', token, address), address };
|
||||||
|
if (token === jwt.value) {
|
||||||
addressValue.value = option.value;
|
addressValue.value = option.value;
|
||||||
}
|
}
|
||||||
return option;
|
return option;
|
||||||
@@ -153,7 +140,7 @@ const refreshAddressOptions = async () => {
|
|||||||
if (userJwt.value) {
|
if (userJwt.value) {
|
||||||
const userChildren = await buildUserOptions();
|
const userChildren = await buildUserOptions();
|
||||||
if (userChildren.length > 0) {
|
if (userChildren.length > 0) {
|
||||||
groups.push({ type: 'group', label: t('userAddresses'), children: userChildren });
|
groups.push({ type: 'group', label: t('linkedMailboxes'), children: userChildren });
|
||||||
}
|
}
|
||||||
const userAddressSet = new Set(userChildren.map((item) => item.address));
|
const userAddressSet = new Set(userChildren.map((item) => item.address));
|
||||||
const localChildren = buildLocalOptions(userAddressSet);
|
const localChildren = buildLocalOptions(userAddressSet);
|
||||||
@@ -207,7 +194,7 @@ onMounted(async () => {
|
|||||||
await refreshAddressOptions();
|
await refreshAddressOptions();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch([userJwt, isTelegram, () => settings.value.address], async () => {
|
watch([userJwt, isTelegram, localAddressCache, () => settings.value.address, () => openSettings.value.addressPasswordLoginOnly], async () => {
|
||||||
await refreshAddressOptions();
|
await refreshAddressOptions();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -215,7 +202,7 @@ watch([userJwt, isTelegram, () => settings.value.address], async () => {
|
|||||||
<template>
|
<template>
|
||||||
<n-flex class="address-row" align="center" justify="center" :wrap="true">
|
<n-flex class="address-row" align="center" justify="center" :wrap="true">
|
||||||
<n-select v-model:value="addressValue" :options="addressOptions" :size="size" filterable
|
<n-select v-model:value="addressValue" :options="addressOptions" :size="size" filterable
|
||||||
:loading="addressLoading" :placeholder="t('address')" @update:value="onAddressChange"
|
:loading="addressLoading" :placeholder="t('selectMailbox')" @update:value="onAddressChange"
|
||||||
class="address-select" />
|
class="address-select" />
|
||||||
<slot name="actions" />
|
<slot name="actions" />
|
||||||
<n-button v-if="showCopy" class="address-copy" @click="copy" :size="size" tertiary type="primary">
|
<n-button v-if="showCopy" class="address-copy" @click="copy" :size="size" tertiary type="primary">
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ const props = defineProps({
|
|||||||
default: false,
|
default: false,
|
||||||
required: false
|
required: false
|
||||||
},
|
},
|
||||||
|
enableMailReadStatus: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
updateMailReadStatus: {
|
||||||
|
type: Function,
|
||||||
|
default: () => { }
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const localFilterKeyword = ref('')
|
const localFilterKeyword = ref('')
|
||||||
@@ -94,6 +102,29 @@ const data = computed(() => {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const openMail = (mail) => {
|
||||||
|
curMail.value = mail
|
||||||
|
if (mail?.is_unread !== 1 || !props.enableMailReadStatus) return
|
||||||
|
mail.is_unread = 0
|
||||||
|
void props.updateMailReadStatus(mail.id, false).catch(() => {
|
||||||
|
mail.is_unread = 1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleCurrentMailUnread = async () => {
|
||||||
|
if (!curMail.value || !props.enableMailReadStatus) return
|
||||||
|
const mail = curMail.value
|
||||||
|
const previousValue = mail.is_unread
|
||||||
|
const isUnread = previousValue !== 1
|
||||||
|
mail.is_unread = isUnread ? 1 : 0
|
||||||
|
try {
|
||||||
|
await props.updateMailReadStatus(mail.id, isUnread)
|
||||||
|
message.success(t("success"))
|
||||||
|
} catch {
|
||||||
|
mail.is_unread = previousValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const canGoPrevMail = computed(() => {
|
const canGoPrevMail = computed(() => {
|
||||||
if (!curMail.value) return false
|
if (!curMail.value) return false
|
||||||
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
||||||
@@ -111,12 +142,12 @@ const prevMail = async () => {
|
|||||||
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
||||||
|
|
||||||
if (currentIndex > 0) {
|
if (currentIndex > 0) {
|
||||||
curMail.value = data.value[currentIndex - 1]
|
openMail(data.value[currentIndex - 1])
|
||||||
} else if (page.value > 1) {
|
} else if (page.value > 1) {
|
||||||
page.value--
|
page.value--
|
||||||
await refresh()
|
await refresh()
|
||||||
if (data.value.length > 0) {
|
if (data.value.length > 0) {
|
||||||
curMail.value = data.value[data.value.length - 1]
|
openMail(data.value[data.value.length - 1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,12 +157,12 @@ const nextMail = async () => {
|
|||||||
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
const currentIndex = data.value.findIndex(mail => mail.id === curMail.value.id)
|
||||||
|
|
||||||
if (currentIndex < data.value.length - 1) {
|
if (currentIndex < data.value.length - 1) {
|
||||||
curMail.value = data.value[currentIndex + 1]
|
openMail(data.value[currentIndex + 1])
|
||||||
} else if (count.value > page.value * pageSize.value) {
|
} else if (count.value > page.value * pageSize.value) {
|
||||||
page.value++
|
page.value++
|
||||||
await refresh()
|
await refresh()
|
||||||
if (data.value.length > 0) {
|
if (data.value.length > 0) {
|
||||||
curMail.value = data.value[0]
|
openMail(data.value[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,7 +236,7 @@ const backFirstPageAndRefresh = async () => {
|
|||||||
await refresh();
|
await refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
const clickRow = async (row) => {
|
const clickRow = (row) => {
|
||||||
if (multiActionMode.value) {
|
if (multiActionMode.value) {
|
||||||
row.checked = !row.checked;
|
row.checked = !row.checked;
|
||||||
curMail.value = row;
|
curMail.value = row;
|
||||||
@@ -215,7 +246,7 @@ const clickRow = async (row) => {
|
|||||||
curMail.value = null;
|
curMail.value = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
curMail.value = row;
|
openMail(row);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -397,7 +428,7 @@ onBeforeUnmount(() => {
|
|||||||
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
||||||
<n-list hoverable clickable>
|
<n-list hoverable clickable>
|
||||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||||
:class="mailItemClass(row)">
|
:class="[mailItemClass(row), { 'mail-list-unread': enableMailReadStatus && row.is_unread === 1 }]">
|
||||||
<template #prefix v-if="multiActionMode">
|
<template #prefix v-if="multiActionMode">
|
||||||
<n-checkbox v-model:checked="row.checked" />
|
<n-checkbox v-model:checked="row.checked" />
|
||||||
</template>
|
</template>
|
||||||
@@ -461,6 +492,7 @@ onBeforeUnmount(() => {
|
|||||||
style="overflow: auto; max-height: 100vh;">
|
style="overflow: auto; max-height: 100vh;">
|
||||||
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
|
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
|
||||||
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
|
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
|
||||||
|
:enableMailReadStatus="enableMailReadStatus" :onUpdateMailReadStatus="toggleCurrentMailUnread"
|
||||||
:onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail" :onSaveToS3="saveToS3Proxy" />
|
:onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail" :onSaveToS3="saveToS3Proxy" />
|
||||||
</n-card>
|
</n-card>
|
||||||
<n-card :bordered="false" embedded class="mail-item" v-else>
|
<n-card :bordered="false" embedded class="mail-item" v-else>
|
||||||
@@ -475,7 +507,7 @@ onBeforeUnmount(() => {
|
|||||||
<div v-else class="mail-list-scroll">
|
<div v-else class="mail-list-scroll">
|
||||||
<n-list hoverable clickable>
|
<n-list hoverable clickable>
|
||||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||||
:class="mailItemClass(row)">
|
:class="[mailItemClass(row), { 'mail-list-unread': enableMailReadStatus && row.is_unread === 1 }]">
|
||||||
<template #prefix v-if="multiActionMode">
|
<template #prefix v-if="multiActionMode">
|
||||||
<n-checkbox v-model:checked="row.checked" />
|
<n-checkbox v-model:checked="row.checked" />
|
||||||
</template>
|
</template>
|
||||||
@@ -536,7 +568,8 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
<div style="overflow: auto; min-height: 60vh; max-height: 100vh;">
|
||||||
<n-list hoverable clickable>
|
<n-list hoverable clickable>
|
||||||
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)">
|
<n-list-item v-for="row in data" v-bind:key="row.id" @click="() => clickRow(row)"
|
||||||
|
:class="{ 'mail-list-unread': enableMailReadStatus && row.is_unread === 1 }">
|
||||||
<n-thing :title="row.subject">
|
<n-thing :title="row.subject">
|
||||||
<template #description>
|
<template #description>
|
||||||
<n-tag type="info">
|
<n-tag type="info">
|
||||||
@@ -567,6 +600,7 @@ onBeforeUnmount(() => {
|
|||||||
<n-card :bordered="false" embedded style="overflow: auto;">
|
<n-card :bordered="false" embedded style="overflow: auto;">
|
||||||
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
|
<MailContentRenderer :mail="curMail" :showEMailTo="showEMailTo"
|
||||||
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
|
:enableUserDeleteEmail="enableUserDeleteEmail" :showReply="showReply" :showSaveS3="showSaveS3"
|
||||||
|
:enableMailReadStatus="enableMailReadStatus" :onUpdateMailReadStatus="toggleCurrentMailUnread"
|
||||||
:useUTCDate="useUTCDate" :onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail"
|
:useUTCDate="useUTCDate" :onDelete="deleteMail" :onReply="replyMail" :onForward="forwardMail"
|
||||||
:onSaveToS3="saveToS3Proxy" />
|
:onSaveToS3="saveToS3Proxy" />
|
||||||
</n-card>
|
</n-card>
|
||||||
@@ -676,6 +710,21 @@ onBeforeUnmount(() => {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mail-list-unread :deep(.n-thing-header__title) {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-list-unread :deep(.n-thing-header__title)::before {
|
||||||
|
display: inline-block;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
margin-right: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #2080f0;
|
||||||
|
content: '';
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
},
|
},
|
||||||
|
enableMailReadStatus: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
// 回调函数 props
|
// 回调函数 props
|
||||||
onDelete: {
|
onDelete: {
|
||||||
type: Function,
|
type: Function,
|
||||||
@@ -50,6 +54,10 @@ const props = defineProps({
|
|||||||
onSaveToS3: {
|
onSaveToS3: {
|
||||||
type: Function,
|
type: Function,
|
||||||
default: () => { }
|
default: () => { }
|
||||||
|
},
|
||||||
|
onUpdateMailReadStatus: {
|
||||||
|
type: Function,
|
||||||
|
default: () => { }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,6 +65,7 @@ const showTextMail = ref(preferShowTextMail.value);
|
|||||||
const showAttachments = ref(false);
|
const showAttachments = ref(false);
|
||||||
const curAttachments = ref([]);
|
const curAttachments = ref([]);
|
||||||
const attachmentLoding = ref(false);
|
const attachmentLoding = ref(false);
|
||||||
|
const readStatusUpdating = ref(false);
|
||||||
const showFullscreen = ref(false);
|
const showFullscreen = ref(false);
|
||||||
|
|
||||||
// Per-mail consent, deliberately independent of the global setting: it only
|
// Per-mail consent, deliberately independent of the global setting: it only
|
||||||
@@ -96,6 +105,15 @@ const handleForward = () => {
|
|||||||
props.onForward();
|
props.onForward();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUpdateMailReadStatus = async () => {
|
||||||
|
readStatusUpdating.value = true;
|
||||||
|
try {
|
||||||
|
await props.onUpdateMailReadStatus();
|
||||||
|
} finally {
|
||||||
|
readStatusUpdating.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
const handleSaveToS3 = async (filename, blob) => {
|
const handleSaveToS3 = async (filename, blob) => {
|
||||||
attachmentLoding.value = true;
|
attachmentLoding.value = true;
|
||||||
@@ -138,6 +156,11 @@ const handleSaveToS3 = async (filename, blob) => {
|
|||||||
{{ t('attachments') }}
|
{{ t('attachments') }}
|
||||||
</n-button>
|
</n-button>
|
||||||
|
|
||||||
|
<n-button v-if="enableMailReadStatus" size="small" tertiary type="info" :loading="readStatusUpdating"
|
||||||
|
@click="handleUpdateMailReadStatus">
|
||||||
|
{{ mail.is_unread === 1 ? t('markAsRead') : t('markAsUnread') }}
|
||||||
|
</n-button>
|
||||||
|
|
||||||
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="mail.id + '.eml'"
|
<n-button tag="a" target="_blank" tertiary type="info" size="small" :download="mail.id + '.eml'"
|
||||||
:href="getDownloadEmlUrl(mail.raw)">
|
:href="getDownloadEmlUrl(mail.raw)">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
type RuntimeConfig = Record<string, unknown>
|
||||||
|
|
||||||
|
const getRuntimeConfig = (): RuntimeConfig => {
|
||||||
|
if (typeof document === 'undefined') return {}
|
||||||
|
|
||||||
|
const content = document.querySelector('#app-config')?.textContent
|
||||||
|
if (!content?.trim()) return {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(content)
|
||||||
|
return config && typeof config === 'object' && !Array.isArray(config) ? config : {}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse app config', error)
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeConfig = getRuntimeConfig()
|
||||||
|
|
||||||
|
const getStringConfigValue = (key: string, buildValue: string): string => {
|
||||||
|
const runtimeValue = runtimeConfig[key]
|
||||||
|
return typeof runtimeValue === 'string' ? runtimeValue : buildValue
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTelegramConfigValue = (buildValue: string): string | boolean => {
|
||||||
|
const runtimeValue = runtimeConfig.IS_TELEGRAM
|
||||||
|
return typeof runtimeValue === 'string' || typeof runtimeValue === 'boolean'
|
||||||
|
? runtimeValue
|
||||||
|
: buildValue
|
||||||
|
}
|
||||||
|
|
||||||
|
export const APP_CONFIG = {
|
||||||
|
API_BASE: getStringConfigValue('API_BASE', import.meta.env.VITE_API_BASE || ''),
|
||||||
|
DEFAULT_LANG: getStringConfigValue('DEFAULT_LANG', import.meta.env.VITE_DEFAULT_LANG || ''),
|
||||||
|
CF_WEB_ANALY_TOKEN: getStringConfigValue('CF_WEB_ANALY_TOKEN', import.meta.env.VITE_CF_WEB_ANALY_TOKEN || ''),
|
||||||
|
IS_TELEGRAM: getTelegramConfigValue(import.meta.env.VITE_IS_TELEGRAM || ''),
|
||||||
|
GOOGLE_AD_CLIENT: getStringConfigValue('GOOGLE_AD_CLIENT', import.meta.env.VITE_GOOGLE_AD_CLIENT || ''),
|
||||||
|
GOOGLE_AD_SLOT: getStringConfigValue('GOOGLE_AD_SLOT', import.meta.env.VITE_GOOGLE_AD_SLOT || ''),
|
||||||
|
} as const
|
||||||
@@ -1,4 +1,20 @@
|
|||||||
export const deMessages = {
|
export const deMessages = {
|
||||||
|
"views.index.SendMail.balanceUnavailable": "Kein Sendeguthaben für diese Adresse",
|
||||||
|
"views.index.SendMail.composeMail": "E-Mail verfassen",
|
||||||
|
"views.index.SendMail.contentPlaceholder": "Nachricht schreiben...",
|
||||||
|
"views.index.SendMail.draftSaved": "Entwurf in diesem Browser gespeichert",
|
||||||
|
"views.index.SendMail.recipientAddress": "Empfängeradresse",
|
||||||
|
"views.index.SendMail.recipientName": "Empfängername (optional)",
|
||||||
|
"views.index.SendMail.senderAddress": "Absenderadresse",
|
||||||
|
"views.index.SendMail.senderName": "Absendername (optional)",
|
||||||
|
"views.admin.SendMail.adminComposeTip": "Von einer konfigurierten E-Mail-Adresse senden",
|
||||||
|
"views.admin.SendMail.composeMail": "E-Mail verfassen",
|
||||||
|
"views.admin.SendMail.contentPlaceholder": "Nachricht schreiben...",
|
||||||
|
"views.admin.SendMail.draftSaved": "Entwurf in diesem Browser gespeichert",
|
||||||
|
"views.admin.SendMail.recipientAddress": "Empfängeradresse",
|
||||||
|
"views.admin.SendMail.recipientName": "Empfängername (optional)",
|
||||||
|
"views.admin.SendMail.senderAddress": "Absenderadresse",
|
||||||
|
"views.admin.SendMail.senderName": "Absendername (optional)",
|
||||||
"views.admin.DatabaseManager.current_database_size": "Aktuelle Datenbankgröße",
|
"views.admin.DatabaseManager.current_database_size": "Aktuelle Datenbankgröße",
|
||||||
"views.admin.DatabaseManager.free_plan": "Free",
|
"views.admin.DatabaseManager.free_plan": "Free",
|
||||||
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
|
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
|
||||||
@@ -17,10 +33,12 @@ export const deMessages = {
|
|||||||
"views.Index.about": "Über",
|
"views.Index.about": "Über",
|
||||||
"views.Admin.about": "Über",
|
"views.Admin.about": "Über",
|
||||||
"views.Header.accessHeader": "Zugangspasswort",
|
"views.Header.accessHeader": "Zugangspasswort",
|
||||||
"views.Admin.account": "Konto",
|
"views.Header.admin": "Admin-Konsole",
|
||||||
"views.Index.accountSettings": "E-Mail-Einstellungen",
|
"views.Admin.mailbox_management": "Postfachverwaltung",
|
||||||
"views.Admin.account_settings": "Kontoeinstellungen",
|
"views.Admin.mailbox_list": "Postfächer",
|
||||||
"views.index.SimpleIndex.accountSettings": "E-Mail-Einstellungen",
|
"views.Index.mailboxSettings": "Postfacheinstellungen",
|
||||||
|
"views.Admin.mailbox_settings": "Globale Postfacheinstellungen",
|
||||||
|
"views.index.SimpleIndex.mailboxSettings": "Postfacheinstellungen",
|
||||||
"views.index.Attachment.action": "Aktion",
|
"views.index.Attachment.action": "Aktion",
|
||||||
"views.admin.SenderAccess.action": "Aktion",
|
"views.admin.SenderAccess.action": "Aktion",
|
||||||
"views.user.UserSettings.actions": "Aktionen",
|
"views.user.UserSettings.actions": "Aktionen",
|
||||||
@@ -34,7 +52,7 @@ export const deMessages = {
|
|||||||
"views.admin.Maintenance.customSqlTip": "Füge benutzerdefinierte DELETE-SQL-Anweisungen für die geplante Bereinigung hinzu. Pro Eintrag ist nur eine DELETE-Anweisung erlaubt.",
|
"views.admin.Maintenance.customSqlTip": "Füge benutzerdefinierte DELETE-SQL-Anweisungen für die geplante Bereinigung hinzu. Pro Eintrag ist nur eine DELETE-Anweisung erlaubt.",
|
||||||
"views.admin.Maintenance.addCustomSql": "Benutzerdefinierte SQL hinzufügen",
|
"views.admin.Maintenance.addCustomSql": "Benutzerdefinierte SQL hinzufügen",
|
||||||
"views.admin.UserOauth2Settings.addOauth2": "Oauth2 hinzufügen",
|
"views.admin.UserOauth2Settings.addOauth2": "Oauth2 hinzufügen",
|
||||||
"components.AddressSelect.address": "Adresse",
|
"components.AddressSelect.selectMailbox": "Postfach auswählen",
|
||||||
"views.user.AddressManagement.address": "Adresse",
|
"views.user.AddressManagement.address": "Adresse",
|
||||||
"views.index.TelegramAddress.address": "Adresse",
|
"views.index.TelegramAddress.address": "Adresse",
|
||||||
"views.admin.SenderAccess.address": "Adresse",
|
"views.admin.SenderAccess.address": "Adresse",
|
||||||
@@ -70,7 +88,7 @@ export const deMessages = {
|
|||||||
"views.admin.Account.clearSentItemsTip": "Möchtest du wirklich die gesendeten Elemente dieser E-Mail-Adresse leeren?",
|
"views.admin.Account.clearSentItemsTip": "Möchtest du wirklich die gesendeten Elemente dieser E-Mail-Adresse leeren?",
|
||||||
"views.admin.Account.multiDeleteTip": "Möchtest du wirklich die ausgewählten Adressen löschen?",
|
"views.admin.Account.multiDeleteTip": "Möchtest du wirklich die ausgewählten Adressen löschen?",
|
||||||
"views.index.Attachment.deleteConfirm": "Möchtest du wirklich diesen Anhang löschen?",
|
"views.index.Attachment.deleteConfirm": "Möchtest du wirklich diesen Anhang löschen?",
|
||||||
"views.admin.Account.deleteTip": "Möchtest du wirklich diese E-Mail löschen?",
|
"views.admin.Account.deleteTip": "Möchtest du wirklich dieses Postfach löschen?",
|
||||||
"views.admin.SenderAccess.deleteTip": "Möchtest du dies wirklich löschen?",
|
"views.admin.SenderAccess.deleteTip": "Möchtest du dies wirklich löschen?",
|
||||||
"views.index.AccountSettings.deleteAccountConfirm": "Möchtest du diese E-Mail-Adresse und alle zugehörigen E-Mails wirklich löschen? Dein Benutzerkonto wird dabei nicht gelöscht.",
|
"views.index.AccountSettings.deleteAccountConfirm": "Möchtest du diese E-Mail-Adresse und alle zugehörigen E-Mails wirklich löschen? Dein Benutzerkonto wird dabei nicht gelöscht.",
|
||||||
"views.index.AccountSettings.logoutConfirm": "Möchtest du dich wirklich von der aktuellen E-Mail-Adresse abmelden?",
|
"views.index.AccountSettings.logoutConfirm": "Möchtest du dich wirklich von der aktuellen E-Mail-Adresse abmelden?",
|
||||||
@@ -107,7 +125,7 @@ export const deMessages = {
|
|||||||
"views.admin.AccountSettings.cancel": "Abbrechen",
|
"views.admin.AccountSettings.cancel": "Abbrechen",
|
||||||
"components.MailBox.cancelMultiAction": "Mehrfachaktion abbrechen",
|
"components.MailBox.cancelMultiAction": "Mehrfachaktion abbrechen",
|
||||||
"components.SendBox.cancelMultiAction": "Mehrfachaktion abbrechen",
|
"components.SendBox.cancelMultiAction": "Mehrfachaktion abbrechen",
|
||||||
"views.user.AddressManagement.changeMailAddress": "Adresse wechseln",
|
"views.user.AddressManagement.openMailbox": "Postfach öffnen",
|
||||||
"views.index.TelegramAddress.changeMailAddress": "Mailadresse ändern",
|
"views.index.TelegramAddress.changeMailAddress": "Mailadresse ändern",
|
||||||
"views.index.LocalAddress.changeMailAddress": "Mailadresse ändern",
|
"views.index.LocalAddress.changeMailAddress": "Mailadresse ändern",
|
||||||
"views.index.AccountSettings.changePassword": "Adresspasswort ändern",
|
"views.index.AccountSettings.changePassword": "Adresspasswort ändern",
|
||||||
@@ -146,8 +164,8 @@ export const deMessages = {
|
|||||||
"views.index.SimpleIndex.copyAddress": "Kopieren",
|
"views.index.SimpleIndex.copyAddress": "Kopieren",
|
||||||
"components.AiExtractInfo.copyFailed": "Kopieren fehlgeschlagen",
|
"components.AiExtractInfo.copyFailed": "Kopieren fehlgeschlagen",
|
||||||
"views.Footer.copyright": "Urheberrecht",
|
"views.Footer.copyright": "Urheberrecht",
|
||||||
"views.Admin.account_create": "Konto erstellen",
|
"views.Admin.mailbox_create": "E-Mail-Adresse erstellen",
|
||||||
"views.admin.CreateAccount.creatNewEmail": "Neue E-Mail erstellen",
|
"views.admin.CreateAccount.createEmailAddress": "E-Mail-Adresse erstellen",
|
||||||
"views.common.Login.getNewEmail": "Neue E-Mail erstellen",
|
"views.common.Login.getNewEmail": "Neue E-Mail erstellen",
|
||||||
"views.user.AddressManagement.create_or_bind": "Erstellen oder verknüpfen",
|
"views.user.AddressManagement.create_or_bind": "Erstellen oder verknüpfen",
|
||||||
"views.index.LocalAddress.create_or_bind": "Erstellen oder verknüpfen",
|
"views.index.LocalAddress.create_or_bind": "Erstellen oder verknüpfen",
|
||||||
@@ -182,8 +200,8 @@ export const deMessages = {
|
|||||||
"views.admin.UserManagement.delete": "Löschen",
|
"views.admin.UserManagement.delete": "Löschen",
|
||||||
"views.admin.AccountSettings.delete_rule": "Löschen",
|
"views.admin.AccountSettings.delete_rule": "Löschen",
|
||||||
"views.admin.Maintenance.deleteCustomSql": "Löschen",
|
"views.admin.Maintenance.deleteCustomSql": "Löschen",
|
||||||
"views.index.AccountSettings.deleteAccount": "E-Mail-Adresse löschen",
|
"views.index.AccountSettings.deleteMailbox": "Postfach löschen",
|
||||||
"views.admin.Account.deleteAccount": "Konto löschen",
|
"views.admin.Account.deleteMailbox": "Postfach löschen",
|
||||||
"views.user.UserSettings.deletePasskey": "Passkey löschen",
|
"views.user.UserSettings.deletePasskey": "Passkey löschen",
|
||||||
"views.admin.AccountSettings.delete_success": "Erfolgreich gelöscht",
|
"views.admin.AccountSettings.delete_success": "Erfolgreich gelöscht",
|
||||||
"views.admin.UserManagement.deleteUser": "Benutzer löschen",
|
"views.admin.UserManagement.deleteUser": "Benutzer löschen",
|
||||||
@@ -254,7 +272,7 @@ export const deMessages = {
|
|||||||
"views.admin.Telegram.globalMailPushList": "Globale Mail-Push-Chat-ID-Liste",
|
"views.admin.Telegram.globalMailPushList": "Globale Mail-Push-Chat-ID-Liste",
|
||||||
"views.common.Appearance.globalTabplacement": "Globale Tab-Position",
|
"views.common.Appearance.globalTabplacement": "Globale Tab-Position",
|
||||||
"views.common.Login.help": "Hilfe",
|
"views.common.Login.help": "Hilfe",
|
||||||
"views.Header.home": "Startseite",
|
"views.Header.mailbox": "Postfach",
|
||||||
"views.index.SendMail.html": "HTML",
|
"views.index.SendMail.html": "HTML",
|
||||||
"views.admin.SendMail.html": "HTML",
|
"views.admin.SendMail.html": "HTML",
|
||||||
"views.admin.UserOauth2Settings.icon": "Icon (SVG, bitte nur aus vertrauenswürdiger Quelle)",
|
"views.admin.UserOauth2Settings.icon": "Icon (SVG, bitte nur aus vertrauenswürdiger Quelle)",
|
||||||
@@ -303,7 +321,7 @@ export const deMessages = {
|
|||||||
"views.admin.Account.addressCredential": "E-Mail-Adresszugangsdaten",
|
"views.admin.Account.addressCredential": "E-Mail-Adresszugangsdaten",
|
||||||
"views.admin.CreateAccount.addressCredential": "E-Mail-Adresszugangsdaten",
|
"views.admin.CreateAccount.addressCredential": "E-Mail-Adresszugangsdaten",
|
||||||
"views.index.AddressBar.fetchAddressError": "Die E-Mail-Zugangsdaten sind ungültig oder das Konto existiert nicht. Es kann auch ein Netzwerkproblem sein. Bitte später erneut versuchen.",
|
"views.index.AddressBar.fetchAddressError": "Die E-Mail-Zugangsdaten sind ungültig oder das Konto existiert nicht. Es kann auch ein Netzwerkproblem sein. Bitte später erneut versuchen.",
|
||||||
"views.Index.mailbox": "Posteingang",
|
"views.Index.inbox": "Posteingang",
|
||||||
"views.User.user_mail_box_tab": "Posteingang",
|
"views.User.user_mail_box_tab": "Posteingang",
|
||||||
"views.user.AddressManagement.mail_count": "E-Mail-Anzahl",
|
"views.user.AddressManagement.mail_count": "E-Mail-Anzahl",
|
||||||
"views.admin.Account.mail_count": "E-Mail-Anzahl",
|
"views.admin.Account.mail_count": "E-Mail-Anzahl",
|
||||||
@@ -333,11 +351,11 @@ export const deMessages = {
|
|||||||
"views.admin.Account.multiClearInbox": "Mehrere Posteingänge leeren",
|
"views.admin.Account.multiClearInbox": "Mehrere Posteingänge leeren",
|
||||||
"views.admin.Account.multiClearSentItems": "Mehrere gesendete Elemente leeren",
|
"views.admin.Account.multiClearSentItems": "Mehrere gesendete Elemente leeren",
|
||||||
"views.admin.Account.multiDelete": "Mehrfach löschen",
|
"views.admin.Account.multiDelete": "Mehrfach löschen",
|
||||||
"views.user.AddressManagement.name": "Name",
|
"views.user.AddressManagement.emailAddress": "E-Mail-Adresse",
|
||||||
"views.index.AutoReply.name": "Name",
|
"views.index.AutoReply.name": "Name",
|
||||||
"views.admin.Account.name": "Name",
|
"views.admin.Account.emailAddress": "E-Mail-Adresse",
|
||||||
"views.admin.UserOauth2Settings.name": "Name",
|
"views.admin.UserOauth2Settings.name": "Name",
|
||||||
"views.admin.UserAddressManagement.name": "Name",
|
"views.admin.UserAddressManagement.emailAddress": "E-Mail-Adresse",
|
||||||
"views.admin.Maintenance.sqlName": "Name",
|
"views.admin.Maintenance.sqlName": "Name",
|
||||||
"views.index.AccountSettings.newPassword": "Neues Passwort",
|
"views.index.AccountSettings.newPassword": "Neues Passwort",
|
||||||
"views.admin.Account.newPassword": "Neues Passwort",
|
"views.admin.Account.newPassword": "Neues Passwort",
|
||||||
@@ -386,6 +404,7 @@ export const deMessages = {
|
|||||||
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Bitte den neuen Passkey-Namen eingeben",
|
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Bitte den neuen Passkey-Namen eingeben",
|
||||||
"views.user.UserSettings.passkeyNamePlaceholder": "Bitte den Passkey-Namen eingeben oder leer lassen, um einen zufälligen zu erzeugen",
|
"views.user.UserSettings.passkeyNamePlaceholder": "Bitte den Passkey-Namen eingeben oder leer lassen, um einen zufälligen zu erzeugen",
|
||||||
"views.admin.CreateAccount.fillInAllFields": "Bitte alle Felder ausfüllen",
|
"views.admin.CreateAccount.fillInAllFields": "Bitte alle Felder ausfüllen",
|
||||||
|
"views.admin.CreateAccount.generateName": "Zufälligen Namen erzeugen",
|
||||||
"views.admin.SendBox.queryTip": "Bitte die abzufragende Adresse eingeben; leer lassen für alle",
|
"views.admin.SendBox.queryTip": "Bitte die abzufragende Adresse eingeben; leer lassen für alle",
|
||||||
"views.user.UserLogin.pleaseInputCode": "Bitte den Code eingeben",
|
"views.user.UserLogin.pleaseInputCode": "Bitte den Code eingeben",
|
||||||
"views.admin.UserManagement.pleaseInput": "Bitte alle erforderlichen Informationen eingeben",
|
"views.admin.UserManagement.pleaseInput": "Bitte alle erforderlichen Informationen eingeben",
|
||||||
@@ -568,13 +587,13 @@ export const deMessages = {
|
|||||||
"views.admin.UserOauth2Settings.userEmailFormatTip": "Regex verwenden, um die E-Mail umzuwandeln. Beispiel: ^(.+)@old\\.com$ mit $1@new.com",
|
"views.admin.UserOauth2Settings.userEmailFormatTip": "Regex verwenden, um die E-Mail umzuwandeln. Beispiel: ^(.+)@old\\.com$ mit $1@new.com",
|
||||||
"views.common.Appearance.useSimpleIndex": "Einfachen Index verwenden",
|
"views.common.Appearance.useSimpleIndex": "Einfachen Index verwenden",
|
||||||
"views.common.Appearance.useUTCDate": "UTC-Datum verwenden",
|
"views.common.Appearance.useUTCDate": "UTC-Datum verwenden",
|
||||||
"views.Header.user": "Benutzer",
|
"views.Header.userCenter": "Benutzerbereich",
|
||||||
"views.Admin.user": "Benutzer",
|
"views.Admin.user": "Benutzer",
|
||||||
"components.AddressSelect.userAddresses": "Benutzeradressen",
|
"components.AddressSelect.linkedMailboxes": "Verknüpfte Postfächer",
|
||||||
"views.Admin.loginViaUserAdmin": "Benutzer-Admin-Berechtigung",
|
"views.Admin.loginViaUserAdmin": "Benutzerkonto (Administratorrolle)",
|
||||||
"views.admin.Statistics.userCount": "Benutzeranzahl",
|
"views.admin.Statistics.userCount": "Benutzeranzahl",
|
||||||
"views.admin.UserManagement.user_email": "Benutzer-E-Mail",
|
"views.admin.UserManagement.user_email": "Benutzer-E-Mail",
|
||||||
"views.index.AddressBar.userLogin": "Benutzeranmeldung",
|
"views.index.AddressBar.userCenter": "Benutzerbereich",
|
||||||
"views.Admin.user_management": "Benutzerverwaltung",
|
"views.Admin.user_management": "Benutzerverwaltung",
|
||||||
"views.Admin.user_settings": "Benutzereinstellungen",
|
"views.Admin.user_settings": "Benutzereinstellungen",
|
||||||
"views.User.user_settings": "Benutzereinstellungen",
|
"views.User.user_settings": "Benutzereinstellungen",
|
||||||
@@ -634,5 +653,15 @@ export const deMessages = {
|
|||||||
"components.AddressCredentialModal.starttls": "STARTTLS",
|
"components.AddressCredentialModal.starttls": "STARTTLS",
|
||||||
"components.AddressCredentialModal.tip": "Verwende diese Zugangsdaten nur mit Clients und Agents, denen du vertraust.",
|
"components.AddressCredentialModal.tip": "Verwende diese Zugangsdaten nur mit Clients und Agents, denen du vertraust.",
|
||||||
"components.AddressCredentialModal.title": "Adresszugangsdaten & Verbindungsmethoden",
|
"components.AddressCredentialModal.title": "Adresszugangsdaten & Verbindungsmethoden",
|
||||||
"components.AddressCredentialModal.username": "Benutzername"
|
"components.AddressCredentialModal.username": "Benutzername",
|
||||||
|
"views.User.send_mail": "E-Mail senden",
|
||||||
|
"views.user.UserSendBox.noAddress": "Wähle eine verknüpfte E-Mail-Adresse aus",
|
||||||
|
"views.user.UserSendBox.sendbox": "Gesendet",
|
||||||
|
"components.MailContentRenderer.markAsRead": "Als gelesen markieren",
|
||||||
|
"components.MailContentRenderer.markAsUnread": "Als ungelesen markieren",
|
||||||
|
"components.MailContentRenderer.loadRemoteImages": "Bilder laden",
|
||||||
|
"components.MailContentRenderer.remoteImagesBlocked": "{count} externe Ressourcen wurden zum Schutz deiner Privatsphäre blockiert",
|
||||||
|
"views.user.AddressManagement.itemCount": "Gesamt",
|
||||||
|
"views.index.SimpleIndex.readStatusUpdated": "Lesestatus aktualisiert",
|
||||||
|
"views.common.Appearance.autoLoadRemoteImages": "Externe Bilder im E-Mail-Inhalt automatisch laden"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,20 @@
|
|||||||
export const esMessages = {
|
export const esMessages = {
|
||||||
|
"views.index.SendMail.balanceUnavailable": "No hay saldo de envío para esta dirección",
|
||||||
|
"views.index.SendMail.composeMail": "Redactar correo",
|
||||||
|
"views.index.SendMail.contentPlaceholder": "Escribe tu mensaje...",
|
||||||
|
"views.index.SendMail.draftSaved": "Borrador guardado en este navegador",
|
||||||
|
"views.index.SendMail.recipientAddress": "Dirección del destinatario",
|
||||||
|
"views.index.SendMail.recipientName": "Nombre del destinatario (opcional)",
|
||||||
|
"views.index.SendMail.senderAddress": "Dirección del remitente",
|
||||||
|
"views.index.SendMail.senderName": "Nombre del remitente (opcional)",
|
||||||
|
"views.admin.SendMail.adminComposeTip": "Enviar desde una dirección de correo configurada",
|
||||||
|
"views.admin.SendMail.composeMail": "Redactar correo",
|
||||||
|
"views.admin.SendMail.contentPlaceholder": "Escribe tu mensaje...",
|
||||||
|
"views.admin.SendMail.draftSaved": "Borrador guardado en este navegador",
|
||||||
|
"views.admin.SendMail.recipientAddress": "Dirección del destinatario",
|
||||||
|
"views.admin.SendMail.recipientName": "Nombre del destinatario (opcional)",
|
||||||
|
"views.admin.SendMail.senderAddress": "Dirección del remitente",
|
||||||
|
"views.admin.SendMail.senderName": "Nombre del remitente (opcional)",
|
||||||
"views.admin.DatabaseManager.current_database_size": "Tamaño actual de la base de datos",
|
"views.admin.DatabaseManager.current_database_size": "Tamaño actual de la base de datos",
|
||||||
"views.admin.DatabaseManager.free_plan": "Free",
|
"views.admin.DatabaseManager.free_plan": "Free",
|
||||||
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
|
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
|
||||||
@@ -17,10 +33,12 @@ export const esMessages = {
|
|||||||
"views.Index.about": "Acerca de",
|
"views.Index.about": "Acerca de",
|
||||||
"views.Admin.about": "Acerca de",
|
"views.Admin.about": "Acerca de",
|
||||||
"views.Header.accessHeader": "Contraseña de acceso",
|
"views.Header.accessHeader": "Contraseña de acceso",
|
||||||
"views.Admin.account": "Cuenta",
|
"views.Header.admin": "Consola de administración",
|
||||||
"views.Index.accountSettings": "Configuración del correo",
|
"views.Admin.mailbox_management": "Gestión de buzones",
|
||||||
"views.Admin.account_settings": "Configuración de la cuenta",
|
"views.Admin.mailbox_list": "Buzones",
|
||||||
"views.index.SimpleIndex.accountSettings": "Configuración del correo",
|
"views.Index.mailboxSettings": "Configuración del buzón",
|
||||||
|
"views.Admin.mailbox_settings": "Configuración global de buzones",
|
||||||
|
"views.index.SimpleIndex.mailboxSettings": "Configuración del buzón",
|
||||||
"views.index.Attachment.action": "Acción",
|
"views.index.Attachment.action": "Acción",
|
||||||
"views.admin.SenderAccess.action": "Acción",
|
"views.admin.SenderAccess.action": "Acción",
|
||||||
"views.user.UserSettings.actions": "Acciones",
|
"views.user.UserSettings.actions": "Acciones",
|
||||||
@@ -34,7 +52,7 @@ export const esMessages = {
|
|||||||
"views.admin.Maintenance.customSqlTip": "Añade sentencias SQL DELETE personalizadas para la limpieza programada. Solo se permite una sentencia DELETE por entrada.",
|
"views.admin.Maintenance.customSqlTip": "Añade sentencias SQL DELETE personalizadas para la limpieza programada. Solo se permite una sentencia DELETE por entrada.",
|
||||||
"views.admin.Maintenance.addCustomSql": "Añadir SQL personalizada",
|
"views.admin.Maintenance.addCustomSql": "Añadir SQL personalizada",
|
||||||
"views.admin.UserOauth2Settings.addOauth2": "Añadir OAuth2",
|
"views.admin.UserOauth2Settings.addOauth2": "Añadir OAuth2",
|
||||||
"components.AddressSelect.address": "Dirección",
|
"components.AddressSelect.selectMailbox": "Seleccionar buzón",
|
||||||
"views.user.AddressManagement.address": "Dirección",
|
"views.user.AddressManagement.address": "Dirección",
|
||||||
"views.index.TelegramAddress.address": "Dirección",
|
"views.index.TelegramAddress.address": "Dirección",
|
||||||
"views.admin.SenderAccess.address": "Dirección",
|
"views.admin.SenderAccess.address": "Dirección",
|
||||||
@@ -70,7 +88,7 @@ export const esMessages = {
|
|||||||
"views.admin.Account.clearSentItemsTip": "¿Seguro que quieres vaciar los enviados de este correo?",
|
"views.admin.Account.clearSentItemsTip": "¿Seguro que quieres vaciar los enviados de este correo?",
|
||||||
"views.admin.Account.multiDeleteTip": "¿Seguro que quieres eliminar las direcciones seleccionadas?",
|
"views.admin.Account.multiDeleteTip": "¿Seguro que quieres eliminar las direcciones seleccionadas?",
|
||||||
"views.index.Attachment.deleteConfirm": "¿Seguro que quieres eliminar este adjunto?",
|
"views.index.Attachment.deleteConfirm": "¿Seguro que quieres eliminar este adjunto?",
|
||||||
"views.admin.Account.deleteTip": "¿Seguro que quieres eliminar este correo?",
|
"views.admin.Account.deleteTip": "¿Seguro que quieres eliminar este buzón?",
|
||||||
"views.admin.SenderAccess.deleteTip": "¿Seguro que quieres eliminar esto?",
|
"views.admin.SenderAccess.deleteTip": "¿Seguro que quieres eliminar esto?",
|
||||||
"views.index.AccountSettings.deleteAccountConfirm": "¿Seguro que quieres eliminar esta dirección de correo y todos sus correos? Esto no elimina tu cuenta de usuario.",
|
"views.index.AccountSettings.deleteAccountConfirm": "¿Seguro que quieres eliminar esta dirección de correo y todos sus correos? Esto no elimina tu cuenta de usuario.",
|
||||||
"views.index.AccountSettings.logoutConfirm": "¿Seguro que quieres cerrar la sesión de la dirección de correo actual?",
|
"views.index.AccountSettings.logoutConfirm": "¿Seguro que quieres cerrar la sesión de la dirección de correo actual?",
|
||||||
@@ -107,7 +125,7 @@ export const esMessages = {
|
|||||||
"views.admin.AccountSettings.cancel": "Cancelar",
|
"views.admin.AccountSettings.cancel": "Cancelar",
|
||||||
"components.MailBox.cancelMultiAction": "Cancelar selección múltiple",
|
"components.MailBox.cancelMultiAction": "Cancelar selección múltiple",
|
||||||
"components.SendBox.cancelMultiAction": "Cancelar selección múltiple",
|
"components.SendBox.cancelMultiAction": "Cancelar selección múltiple",
|
||||||
"views.user.AddressManagement.changeMailAddress": "Cambiar dirección",
|
"views.user.AddressManagement.openMailbox": "Abrir buzón",
|
||||||
"views.index.TelegramAddress.changeMailAddress": "Cambiar dirección de correo",
|
"views.index.TelegramAddress.changeMailAddress": "Cambiar dirección de correo",
|
||||||
"views.index.LocalAddress.changeMailAddress": "Cambiar dirección de correo",
|
"views.index.LocalAddress.changeMailAddress": "Cambiar dirección de correo",
|
||||||
"views.index.AccountSettings.changePassword": "Cambiar contraseña de la dirección",
|
"views.index.AccountSettings.changePassword": "Cambiar contraseña de la dirección",
|
||||||
@@ -146,8 +164,8 @@ export const esMessages = {
|
|||||||
"views.index.SimpleIndex.copyAddress": "Copiar",
|
"views.index.SimpleIndex.copyAddress": "Copiar",
|
||||||
"components.AiExtractInfo.copyFailed": "Error al copiar",
|
"components.AiExtractInfo.copyFailed": "Error al copiar",
|
||||||
"views.Footer.copyright": "Derechos de autor",
|
"views.Footer.copyright": "Derechos de autor",
|
||||||
"views.Admin.account_create": "Crear cuenta",
|
"views.Admin.mailbox_create": "Crear dirección de correo",
|
||||||
"views.admin.CreateAccount.creatNewEmail": "Crear nuevo correo",
|
"views.admin.CreateAccount.createEmailAddress": "Crear dirección de correo",
|
||||||
"views.common.Login.getNewEmail": "Crear nuevo correo",
|
"views.common.Login.getNewEmail": "Crear nuevo correo",
|
||||||
"views.user.AddressManagement.create_or_bind": "Crear o vincular",
|
"views.user.AddressManagement.create_or_bind": "Crear o vincular",
|
||||||
"views.index.LocalAddress.create_or_bind": "Crear o vincular",
|
"views.index.LocalAddress.create_or_bind": "Crear o vincular",
|
||||||
@@ -182,8 +200,8 @@ export const esMessages = {
|
|||||||
"views.admin.UserManagement.delete": "Eliminar",
|
"views.admin.UserManagement.delete": "Eliminar",
|
||||||
"views.admin.AccountSettings.delete_rule": "Eliminar",
|
"views.admin.AccountSettings.delete_rule": "Eliminar",
|
||||||
"views.admin.Maintenance.deleteCustomSql": "Eliminar",
|
"views.admin.Maintenance.deleteCustomSql": "Eliminar",
|
||||||
"views.index.AccountSettings.deleteAccount": "Eliminar dirección de correo",
|
"views.index.AccountSettings.deleteMailbox": "Eliminar buzón",
|
||||||
"views.admin.Account.deleteAccount": "Eliminar cuenta",
|
"views.admin.Account.deleteMailbox": "Eliminar buzón",
|
||||||
"views.user.UserSettings.deletePasskey": "Eliminar passkey",
|
"views.user.UserSettings.deletePasskey": "Eliminar passkey",
|
||||||
"views.admin.AccountSettings.delete_success": "Eliminado correctamente",
|
"views.admin.AccountSettings.delete_success": "Eliminado correctamente",
|
||||||
"views.admin.UserManagement.deleteUser": "Eliminar usuario",
|
"views.admin.UserManagement.deleteUser": "Eliminar usuario",
|
||||||
@@ -254,7 +272,7 @@ export const esMessages = {
|
|||||||
"views.admin.Telegram.globalMailPushList": "Lista global de chat ID para envío de correos",
|
"views.admin.Telegram.globalMailPushList": "Lista global de chat ID para envío de correos",
|
||||||
"views.common.Appearance.globalTabplacement": "Posición global de pestañas",
|
"views.common.Appearance.globalTabplacement": "Posición global de pestañas",
|
||||||
"views.common.Login.help": "Ayuda",
|
"views.common.Login.help": "Ayuda",
|
||||||
"views.Header.home": "Inicio",
|
"views.Header.mailbox": "Buzón",
|
||||||
"views.index.SendMail.html": "HTML",
|
"views.index.SendMail.html": "HTML",
|
||||||
"views.admin.SendMail.html": "HTML",
|
"views.admin.SendMail.html": "HTML",
|
||||||
"views.admin.UserOauth2Settings.icon": "Icono (SVG, asegúrate de que la fuente sea fiable)",
|
"views.admin.UserOauth2Settings.icon": "Icono (SVG, asegúrate de que la fuente sea fiable)",
|
||||||
@@ -303,7 +321,7 @@ export const esMessages = {
|
|||||||
"views.admin.Account.addressCredential": "Credencial de dirección de correo",
|
"views.admin.Account.addressCredential": "Credencial de dirección de correo",
|
||||||
"views.admin.CreateAccount.addressCredential": "Credencial de dirección de correo",
|
"views.admin.CreateAccount.addressCredential": "Credencial de dirección de correo",
|
||||||
"views.index.AddressBar.fetchAddressError": "La credencial del correo no es válida o la cuenta no existe; también puede ser un problema de red. Inténtalo más tarde.",
|
"views.index.AddressBar.fetchAddressError": "La credencial del correo no es válida o la cuenta no existe; también puede ser un problema de red. Inténtalo más tarde.",
|
||||||
"views.Index.mailbox": "Bandeja de entrada",
|
"views.Index.inbox": "Bandeja de entrada",
|
||||||
"views.User.user_mail_box_tab": "Bandeja de entrada",
|
"views.User.user_mail_box_tab": "Bandeja de entrada",
|
||||||
"views.user.AddressManagement.mail_count": "Cantidad de correos",
|
"views.user.AddressManagement.mail_count": "Cantidad de correos",
|
||||||
"views.admin.Account.mail_count": "Cantidad de correos",
|
"views.admin.Account.mail_count": "Cantidad de correos",
|
||||||
@@ -333,11 +351,11 @@ export const esMessages = {
|
|||||||
"views.admin.Account.multiClearInbox": "Vaciar varias bandejas",
|
"views.admin.Account.multiClearInbox": "Vaciar varias bandejas",
|
||||||
"views.admin.Account.multiClearSentItems": "Vaciar varios enviados",
|
"views.admin.Account.multiClearSentItems": "Vaciar varios enviados",
|
||||||
"views.admin.Account.multiDelete": "Eliminar en lote",
|
"views.admin.Account.multiDelete": "Eliminar en lote",
|
||||||
"views.user.AddressManagement.name": "Nombre",
|
"views.user.AddressManagement.emailAddress": "Dirección de correo",
|
||||||
"views.index.AutoReply.name": "Nombre",
|
"views.index.AutoReply.name": "Nombre",
|
||||||
"views.admin.Account.name": "Nombre",
|
"views.admin.Account.emailAddress": "Dirección de correo",
|
||||||
"views.admin.UserOauth2Settings.name": "Nombre",
|
"views.admin.UserOauth2Settings.name": "Nombre",
|
||||||
"views.admin.UserAddressManagement.name": "Nombre",
|
"views.admin.UserAddressManagement.emailAddress": "Dirección de correo",
|
||||||
"views.admin.Maintenance.sqlName": "Nombre",
|
"views.admin.Maintenance.sqlName": "Nombre",
|
||||||
"views.index.AccountSettings.newPassword": "Nueva contraseña",
|
"views.index.AccountSettings.newPassword": "Nueva contraseña",
|
||||||
"views.admin.Account.newPassword": "Nueva contraseña",
|
"views.admin.Account.newPassword": "Nueva contraseña",
|
||||||
@@ -386,6 +404,7 @@ export const esMessages = {
|
|||||||
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Introduce el nuevo nombre de la passkey",
|
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Introduce el nuevo nombre de la passkey",
|
||||||
"views.user.UserSettings.passkeyNamePlaceholder": "Introduce el nombre de la passkey o déjalo vacío para generarlo aleatoriamente",
|
"views.user.UserSettings.passkeyNamePlaceholder": "Introduce el nombre de la passkey o déjalo vacío para generarlo aleatoriamente",
|
||||||
"views.admin.CreateAccount.fillInAllFields": "Rellena todos los campos",
|
"views.admin.CreateAccount.fillInAllFields": "Rellena todos los campos",
|
||||||
|
"views.admin.CreateAccount.generateName": "Generar nombre aleatorio",
|
||||||
"views.admin.SendBox.queryTip": "Introduce la dirección a consultar; vacío para todas",
|
"views.admin.SendBox.queryTip": "Introduce la dirección a consultar; vacío para todas",
|
||||||
"views.user.UserLogin.pleaseInputCode": "Introduce el código",
|
"views.user.UserLogin.pleaseInputCode": "Introduce el código",
|
||||||
"views.admin.UserManagement.pleaseInput": "Introduce la información completa",
|
"views.admin.UserManagement.pleaseInput": "Introduce la información completa",
|
||||||
@@ -568,13 +587,13 @@ export const esMessages = {
|
|||||||
"views.admin.UserOauth2Settings.userEmailFormatTip": "Usa regex para transformar el correo. Ejemplo: ^(.+)@old\\.com$ con $1@new.com",
|
"views.admin.UserOauth2Settings.userEmailFormatTip": "Usa regex para transformar el correo. Ejemplo: ^(.+)@old\\.com$ con $1@new.com",
|
||||||
"views.common.Appearance.useSimpleIndex": "Usar índice simple",
|
"views.common.Appearance.useSimpleIndex": "Usar índice simple",
|
||||||
"views.common.Appearance.useUTCDate": "Usar fecha UTC",
|
"views.common.Appearance.useUTCDate": "Usar fecha UTC",
|
||||||
"views.Header.user": "Usuario",
|
"views.Header.userCenter": "Centro de usuario",
|
||||||
"views.Admin.user": "Usuario",
|
"views.Admin.user": "Usuario",
|
||||||
"components.AddressSelect.userAddresses": "Direcciones del usuario",
|
"components.AddressSelect.linkedMailboxes": "Buzones vinculados",
|
||||||
"views.Admin.loginViaUserAdmin": "Permiso de administrador del usuario",
|
"views.Admin.loginViaUserAdmin": "Cuenta de usuario (rol de administrador)",
|
||||||
"views.admin.Statistics.userCount": "Cantidad de usuarios",
|
"views.admin.Statistics.userCount": "Cantidad de usuarios",
|
||||||
"views.admin.UserManagement.user_email": "Correo del usuario",
|
"views.admin.UserManagement.user_email": "Correo del usuario",
|
||||||
"views.index.AddressBar.userLogin": "Inicio de sesión de usuario",
|
"views.index.AddressBar.userCenter": "Centro de usuario",
|
||||||
"views.Admin.user_management": "Gestión de usuarios",
|
"views.Admin.user_management": "Gestión de usuarios",
|
||||||
"views.Admin.user_settings": "Configuración de usuario",
|
"views.Admin.user_settings": "Configuración de usuario",
|
||||||
"views.User.user_settings": "Configuración de usuario",
|
"views.User.user_settings": "Configuración de usuario",
|
||||||
@@ -634,5 +653,15 @@ export const esMessages = {
|
|||||||
"components.AddressCredentialModal.starttls": "STARTTLS",
|
"components.AddressCredentialModal.starttls": "STARTTLS",
|
||||||
"components.AddressCredentialModal.tip": "Usa estas credenciales solo con clientes y agentes de confianza.",
|
"components.AddressCredentialModal.tip": "Usa estas credenciales solo con clientes y agentes de confianza.",
|
||||||
"components.AddressCredentialModal.title": "Credenciales de dirección y métodos de conexión",
|
"components.AddressCredentialModal.title": "Credenciales de dirección y métodos de conexión",
|
||||||
"components.AddressCredentialModal.username": "Usuario"
|
"components.AddressCredentialModal.username": "Usuario",
|
||||||
|
"views.User.send_mail": "Enviar correo",
|
||||||
|
"views.user.UserSendBox.noAddress": "Selecciona una dirección de correo vinculada",
|
||||||
|
"views.user.UserSendBox.sendbox": "Enviados",
|
||||||
|
"components.MailContentRenderer.markAsRead": "Marcar como leído",
|
||||||
|
"components.MailContentRenderer.markAsUnread": "Marcar como no leído",
|
||||||
|
"components.MailContentRenderer.loadRemoteImages": "Cargar imágenes",
|
||||||
|
"components.MailContentRenderer.remoteImagesBlocked": "Se bloquearon {count} recursos remotos para proteger tu privacidad",
|
||||||
|
"views.user.AddressManagement.itemCount": "Total",
|
||||||
|
"views.index.SimpleIndex.readStatusUpdated": "Estado de lectura actualizado",
|
||||||
|
"views.common.Appearance.autoLoadRemoteImages": "Cargar automáticamente las imágenes externas del correo"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,20 @@
|
|||||||
export const jaMessages = {
|
export const jaMessages = {
|
||||||
|
"views.index.SendMail.balanceUnavailable": "このアドレスには送信残高がありません",
|
||||||
|
"views.index.SendMail.composeMail": "メールを作成",
|
||||||
|
"views.index.SendMail.contentPlaceholder": "メッセージを入力...",
|
||||||
|
"views.index.SendMail.draftSaved": "下書きはこのブラウザーに保存されています",
|
||||||
|
"views.index.SendMail.recipientAddress": "宛先メールアドレス",
|
||||||
|
"views.index.SendMail.recipientName": "宛先名(任意)",
|
||||||
|
"views.index.SendMail.senderAddress": "送信元メールアドレス",
|
||||||
|
"views.index.SendMail.senderName": "送信者名(任意)",
|
||||||
|
"views.admin.SendMail.adminComposeTip": "設定済みのメールアドレスから送信",
|
||||||
|
"views.admin.SendMail.composeMail": "メールを作成",
|
||||||
|
"views.admin.SendMail.contentPlaceholder": "メッセージを入力...",
|
||||||
|
"views.admin.SendMail.draftSaved": "下書きはこのブラウザーに保存されています",
|
||||||
|
"views.admin.SendMail.recipientAddress": "宛先メールアドレス",
|
||||||
|
"views.admin.SendMail.recipientName": "宛先名(任意)",
|
||||||
|
"views.admin.SendMail.senderAddress": "送信元メールアドレス",
|
||||||
|
"views.admin.SendMail.senderName": "送信者名(任意)",
|
||||||
"views.admin.DatabaseManager.current_database_size": "現在のデータベースサイズ",
|
"views.admin.DatabaseManager.current_database_size": "現在のデータベースサイズ",
|
||||||
"views.admin.DatabaseManager.free_plan": "Free",
|
"views.admin.DatabaseManager.free_plan": "Free",
|
||||||
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
|
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
|
||||||
@@ -17,10 +33,12 @@ export const jaMessages = {
|
|||||||
"views.Index.about": "概要",
|
"views.Index.about": "概要",
|
||||||
"views.Admin.about": "概要",
|
"views.Admin.about": "概要",
|
||||||
"views.Header.accessHeader": "アクセス用パスワード",
|
"views.Header.accessHeader": "アクセス用パスワード",
|
||||||
"views.Admin.account": "アカウント",
|
"views.Header.admin": "管理画面",
|
||||||
"views.Index.accountSettings": "メール設定",
|
"views.Admin.mailbox_management": "メールボックス管理",
|
||||||
"views.Admin.account_settings": "アカウント設定",
|
"views.Admin.mailbox_list": "メールボックス一覧",
|
||||||
"views.index.SimpleIndex.accountSettings": "メール設定",
|
"views.Index.mailboxSettings": "メールボックス設定",
|
||||||
|
"views.Admin.mailbox_settings": "メールボックスのグローバル設定",
|
||||||
|
"views.index.SimpleIndex.mailboxSettings": "メールボックス設定",
|
||||||
"views.index.Attachment.action": "操作",
|
"views.index.Attachment.action": "操作",
|
||||||
"views.admin.SenderAccess.action": "操作",
|
"views.admin.SenderAccess.action": "操作",
|
||||||
"views.user.UserSettings.actions": "操作",
|
"views.user.UserSettings.actions": "操作",
|
||||||
@@ -34,7 +52,7 @@ export const jaMessages = {
|
|||||||
"views.admin.Maintenance.customSqlTip": "定期クリーンアップ用にカスタム DELETE SQL を追加します。各項目には 1 つの DELETE 文のみ使用できます。",
|
"views.admin.Maintenance.customSqlTip": "定期クリーンアップ用にカスタム DELETE SQL を追加します。各項目には 1 つの DELETE 文のみ使用できます。",
|
||||||
"views.admin.Maintenance.addCustomSql": "カスタム SQL を追加",
|
"views.admin.Maintenance.addCustomSql": "カスタム SQL を追加",
|
||||||
"views.admin.UserOauth2Settings.addOauth2": "Oauth2 を追加",
|
"views.admin.UserOauth2Settings.addOauth2": "Oauth2 を追加",
|
||||||
"components.AddressSelect.address": "アドレス",
|
"components.AddressSelect.selectMailbox": "メールボックスを選択",
|
||||||
"views.user.AddressManagement.address": "アドレス",
|
"views.user.AddressManagement.address": "アドレス",
|
||||||
"views.index.TelegramAddress.address": "アドレス",
|
"views.index.TelegramAddress.address": "アドレス",
|
||||||
"views.admin.SenderAccess.address": "アドレス",
|
"views.admin.SenderAccess.address": "アドレス",
|
||||||
@@ -70,7 +88,7 @@ export const jaMessages = {
|
|||||||
"views.admin.Account.clearSentItemsTip": "このメールアドレスの送信済みを削除してもよろしいですか?",
|
"views.admin.Account.clearSentItemsTip": "このメールアドレスの送信済みを削除してもよろしいですか?",
|
||||||
"views.admin.Account.multiDeleteTip": "選択したアドレスを削除してもよろしいですか?",
|
"views.admin.Account.multiDeleteTip": "選択したアドレスを削除してもよろしいですか?",
|
||||||
"views.index.Attachment.deleteConfirm": "この添付ファイルを削除してもよろしいですか?",
|
"views.index.Attachment.deleteConfirm": "この添付ファイルを削除してもよろしいですか?",
|
||||||
"views.admin.Account.deleteTip": "このメールを削除してもよろしいですか?",
|
"views.admin.Account.deleteTip": "このメールボックスを削除してもよろしいですか?",
|
||||||
"views.admin.SenderAccess.deleteTip": "これを削除してもよろしいですか?",
|
"views.admin.SenderAccess.deleteTip": "これを削除してもよろしいですか?",
|
||||||
"views.index.AccountSettings.deleteAccountConfirm": "このメールアドレスと関連するすべてのメールを削除しますか?ユーザーアカウントは削除されません。",
|
"views.index.AccountSettings.deleteAccountConfirm": "このメールアドレスと関連するすべてのメールを削除しますか?ユーザーアカウントは削除されません。",
|
||||||
"views.index.AccountSettings.logoutConfirm": "現在のメールアドレスからログアウトしますか?",
|
"views.index.AccountSettings.logoutConfirm": "現在のメールアドレスからログアウトしますか?",
|
||||||
@@ -107,7 +125,7 @@ export const jaMessages = {
|
|||||||
"views.admin.AccountSettings.cancel": "キャンセル",
|
"views.admin.AccountSettings.cancel": "キャンセル",
|
||||||
"components.MailBox.cancelMultiAction": "複数選択をキャンセル",
|
"components.MailBox.cancelMultiAction": "複数選択をキャンセル",
|
||||||
"components.SendBox.cancelMultiAction": "複数選択をキャンセル",
|
"components.SendBox.cancelMultiAction": "複数選択をキャンセル",
|
||||||
"views.user.AddressManagement.changeMailAddress": "アドレスを変更",
|
"views.user.AddressManagement.openMailbox": "メールボックスを開く",
|
||||||
"views.index.TelegramAddress.changeMailAddress": "メールアドレスを変更",
|
"views.index.TelegramAddress.changeMailAddress": "メールアドレスを変更",
|
||||||
"views.index.LocalAddress.changeMailAddress": "メールアドレスを変更",
|
"views.index.LocalAddress.changeMailAddress": "メールアドレスを変更",
|
||||||
"views.index.AccountSettings.changePassword": "アドレスパスワードを変更",
|
"views.index.AccountSettings.changePassword": "アドレスパスワードを変更",
|
||||||
@@ -146,8 +164,8 @@ export const jaMessages = {
|
|||||||
"views.index.SimpleIndex.copyAddress": "コピー",
|
"views.index.SimpleIndex.copyAddress": "コピー",
|
||||||
"components.AiExtractInfo.copyFailed": "コピーに失敗しました",
|
"components.AiExtractInfo.copyFailed": "コピーに失敗しました",
|
||||||
"views.Footer.copyright": "著作権",
|
"views.Footer.copyright": "著作権",
|
||||||
"views.Admin.account_create": "アカウントを作成",
|
"views.Admin.mailbox_create": "メールアドレスを作成",
|
||||||
"views.admin.CreateAccount.creatNewEmail": "新しいメールを作成",
|
"views.admin.CreateAccount.createEmailAddress": "メールアドレスを作成",
|
||||||
"views.common.Login.getNewEmail": "新しいメールを作成",
|
"views.common.Login.getNewEmail": "新しいメールを作成",
|
||||||
"views.user.AddressManagement.create_or_bind": "作成または紐付け",
|
"views.user.AddressManagement.create_or_bind": "作成または紐付け",
|
||||||
"views.index.LocalAddress.create_or_bind": "作成または紐付け",
|
"views.index.LocalAddress.create_or_bind": "作成または紐付け",
|
||||||
@@ -182,8 +200,8 @@ export const jaMessages = {
|
|||||||
"views.admin.UserManagement.delete": "削除",
|
"views.admin.UserManagement.delete": "削除",
|
||||||
"views.admin.AccountSettings.delete_rule": "削除",
|
"views.admin.AccountSettings.delete_rule": "削除",
|
||||||
"views.admin.Maintenance.deleteCustomSql": "削除",
|
"views.admin.Maintenance.deleteCustomSql": "削除",
|
||||||
"views.index.AccountSettings.deleteAccount": "メールアドレスを削除",
|
"views.index.AccountSettings.deleteMailbox": "メールボックスを削除",
|
||||||
"views.admin.Account.deleteAccount": "アカウントを削除",
|
"views.admin.Account.deleteMailbox": "メールボックスを削除",
|
||||||
"views.user.UserSettings.deletePasskey": "Passkeyを削除",
|
"views.user.UserSettings.deletePasskey": "Passkeyを削除",
|
||||||
"views.admin.AccountSettings.delete_success": "削除しました",
|
"views.admin.AccountSettings.delete_success": "削除しました",
|
||||||
"views.admin.UserManagement.deleteUser": "ユーザーを削除",
|
"views.admin.UserManagement.deleteUser": "ユーザーを削除",
|
||||||
@@ -254,7 +272,7 @@ export const jaMessages = {
|
|||||||
"views.admin.Telegram.globalMailPushList": "グローバルメール通知 Chat ID 一覧",
|
"views.admin.Telegram.globalMailPushList": "グローバルメール通知 Chat ID 一覧",
|
||||||
"views.common.Appearance.globalTabplacement": "全体タブ位置",
|
"views.common.Appearance.globalTabplacement": "全体タブ位置",
|
||||||
"views.common.Login.help": "ヘルプ",
|
"views.common.Login.help": "ヘルプ",
|
||||||
"views.Header.home": "ホーム",
|
"views.Header.mailbox": "メールボックス",
|
||||||
"views.index.SendMail.html": "HTML",
|
"views.index.SendMail.html": "HTML",
|
||||||
"views.admin.SendMail.html": "HTML",
|
"views.admin.SendMail.html": "HTML",
|
||||||
"views.admin.UserOauth2Settings.icon": "アイコン (SVG、信頼できるソースのみ使用してください)",
|
"views.admin.UserOauth2Settings.icon": "アイコン (SVG、信頼できるソースのみ使用してください)",
|
||||||
@@ -303,7 +321,7 @@ export const jaMessages = {
|
|||||||
"views.admin.Account.addressCredential": "メールアドレス資格情報",
|
"views.admin.Account.addressCredential": "メールアドレス資格情報",
|
||||||
"views.admin.CreateAccount.addressCredential": "メールアドレス資格情報",
|
"views.admin.CreateAccount.addressCredential": "メールアドレス資格情報",
|
||||||
"views.index.AddressBar.fetchAddressError": "メール資格情報が無効かアカウントが存在しません。ネットワークの問題の可能性もあります。後でもう一度お試しください。",
|
"views.index.AddressBar.fetchAddressError": "メール資格情報が無効かアカウントが存在しません。ネットワークの問題の可能性もあります。後でもう一度お試しください。",
|
||||||
"views.Index.mailbox": "受信箱",
|
"views.Index.inbox": "受信箱",
|
||||||
"views.User.user_mail_box_tab": "受信箱",
|
"views.User.user_mail_box_tab": "受信箱",
|
||||||
"views.user.AddressManagement.mail_count": "メール数",
|
"views.user.AddressManagement.mail_count": "メール数",
|
||||||
"views.admin.Account.mail_count": "メール数",
|
"views.admin.Account.mail_count": "メール数",
|
||||||
@@ -333,11 +351,11 @@ export const jaMessages = {
|
|||||||
"views.admin.Account.multiClearInbox": "受信箱を一括削除",
|
"views.admin.Account.multiClearInbox": "受信箱を一括削除",
|
||||||
"views.admin.Account.multiClearSentItems": "送信済みを一括削除",
|
"views.admin.Account.multiClearSentItems": "送信済みを一括削除",
|
||||||
"views.admin.Account.multiDelete": "一括削除",
|
"views.admin.Account.multiDelete": "一括削除",
|
||||||
"views.user.AddressManagement.name": "名前",
|
"views.user.AddressManagement.emailAddress": "メールアドレス",
|
||||||
"views.index.AutoReply.name": "名前",
|
"views.index.AutoReply.name": "名前",
|
||||||
"views.admin.Account.name": "名前",
|
"views.admin.Account.emailAddress": "メールアドレス",
|
||||||
"views.admin.UserOauth2Settings.name": "名前",
|
"views.admin.UserOauth2Settings.name": "名前",
|
||||||
"views.admin.UserAddressManagement.name": "名前",
|
"views.admin.UserAddressManagement.emailAddress": "メールアドレス",
|
||||||
"views.admin.Maintenance.sqlName": "名前",
|
"views.admin.Maintenance.sqlName": "名前",
|
||||||
"views.index.AccountSettings.newPassword": "新しいパスワード",
|
"views.index.AccountSettings.newPassword": "新しいパスワード",
|
||||||
"views.admin.Account.newPassword": "新しいパスワード",
|
"views.admin.Account.newPassword": "新しいパスワード",
|
||||||
@@ -386,6 +404,7 @@ export const jaMessages = {
|
|||||||
"views.user.UserSettings.renamePasskeyNamePlaceholder": "新しい Passkey 名を入力してください",
|
"views.user.UserSettings.renamePasskeyNamePlaceholder": "新しい Passkey 名を入力してください",
|
||||||
"views.user.UserSettings.passkeyNamePlaceholder": "Passkey 名を入力するか、空欄でランダム生成してください",
|
"views.user.UserSettings.passkeyNamePlaceholder": "Passkey 名を入力するか、空欄でランダム生成してください",
|
||||||
"views.admin.CreateAccount.fillInAllFields": "すべての項目を入力してください",
|
"views.admin.CreateAccount.fillInAllFields": "すべての項目を入力してください",
|
||||||
|
"views.admin.CreateAccount.generateName": "ランダム名を生成",
|
||||||
"views.admin.SendBox.queryTip": "検索するアドレスを入力してください。空欄で全件検索します",
|
"views.admin.SendBox.queryTip": "検索するアドレスを入力してください。空欄で全件検索します",
|
||||||
"views.user.UserLogin.pleaseInputCode": "コードを入力してください",
|
"views.user.UserLogin.pleaseInputCode": "コードを入力してください",
|
||||||
"views.admin.UserManagement.pleaseInput": "必要な情報をすべて入力してください",
|
"views.admin.UserManagement.pleaseInput": "必要な情報をすべて入力してください",
|
||||||
@@ -568,13 +587,13 @@ export const jaMessages = {
|
|||||||
"views.admin.UserOauth2Settings.userEmailFormatTip": "正規表現でメールを変換します。例: ^(.+)@old\\.com$ と $1@new.com",
|
"views.admin.UserOauth2Settings.userEmailFormatTip": "正規表現でメールを変換します。例: ^(.+)@old\\.com$ と $1@new.com",
|
||||||
"views.common.Appearance.useSimpleIndex": "シンプルインデックスを使う",
|
"views.common.Appearance.useSimpleIndex": "シンプルインデックスを使う",
|
||||||
"views.common.Appearance.useUTCDate": "UTC 日時を使う",
|
"views.common.Appearance.useUTCDate": "UTC 日時を使う",
|
||||||
"views.Header.user": "ユーザー",
|
"views.Header.userCenter": "ユーザーセンター",
|
||||||
"views.Admin.user": "ユーザー",
|
"views.Admin.user": "ユーザー",
|
||||||
"components.AddressSelect.userAddresses": "ユーザーのアドレス",
|
"components.AddressSelect.linkedMailboxes": "連携済みメールボックス",
|
||||||
"views.Admin.loginViaUserAdmin": "ユーザー管理者権限",
|
"views.Admin.loginViaUserAdmin": "ユーザーアカウント(管理者ロール)",
|
||||||
"views.admin.Statistics.userCount": "ユーザー数",
|
"views.admin.Statistics.userCount": "ユーザー数",
|
||||||
"views.admin.UserManagement.user_email": "ユーザーメール",
|
"views.admin.UserManagement.user_email": "ユーザーメール",
|
||||||
"views.index.AddressBar.userLogin": "ユーザーログイン",
|
"views.index.AddressBar.userCenter": "ユーザーセンター",
|
||||||
"views.Admin.user_management": "ユーザー管理",
|
"views.Admin.user_management": "ユーザー管理",
|
||||||
"views.Admin.user_settings": "ユーザー設定",
|
"views.Admin.user_settings": "ユーザー設定",
|
||||||
"views.User.user_settings": "ユーザー設定",
|
"views.User.user_settings": "ユーザー設定",
|
||||||
@@ -634,5 +653,15 @@ export const jaMessages = {
|
|||||||
"components.AddressCredentialModal.starttls": "STARTTLS",
|
"components.AddressCredentialModal.starttls": "STARTTLS",
|
||||||
"components.AddressCredentialModal.tip": "これらの認証情報は信頼できるクライアントと Agent でのみ使用してください。",
|
"components.AddressCredentialModal.tip": "これらの認証情報は信頼できるクライアントと Agent でのみ使用してください。",
|
||||||
"components.AddressCredentialModal.title": "アドレス認証情報と接続方法",
|
"components.AddressCredentialModal.title": "アドレス認証情報と接続方法",
|
||||||
"components.AddressCredentialModal.username": "ユーザー名"
|
"components.AddressCredentialModal.username": "ユーザー名",
|
||||||
|
"views.User.send_mail": "メール送信",
|
||||||
|
"views.user.UserSendBox.noAddress": "紐付け済みのメールアドレスを選択してください",
|
||||||
|
"views.user.UserSendBox.sendbox": "送信済み",
|
||||||
|
"components.MailContentRenderer.markAsRead": "既読にする",
|
||||||
|
"components.MailContentRenderer.markAsUnread": "未読にする",
|
||||||
|
"components.MailContentRenderer.loadRemoteImages": "画像を読み込む",
|
||||||
|
"components.MailContentRenderer.remoteImagesBlocked": "プライバシー保護のため、{count} 件の外部リソースをブロックしました",
|
||||||
|
"views.user.AddressManagement.itemCount": "合計",
|
||||||
|
"views.index.SimpleIndex.readStatusUpdated": "既読状態を更新しました",
|
||||||
|
"views.common.Appearance.autoLoadRemoteImages": "メール本文の外部画像を自動的に読み込む"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,20 @@
|
|||||||
export const ptBRMessages = {
|
export const ptBRMessages = {
|
||||||
|
"views.index.SendMail.balanceUnavailable": "Sem saldo de envio para este endereço",
|
||||||
|
"views.index.SendMail.composeMail": "Escrever e-mail",
|
||||||
|
"views.index.SendMail.contentPlaceholder": "Escreva sua mensagem...",
|
||||||
|
"views.index.SendMail.draftSaved": "Rascunho salvo neste navegador",
|
||||||
|
"views.index.SendMail.recipientAddress": "Endereço do destinatário",
|
||||||
|
"views.index.SendMail.recipientName": "Nome do destinatário (opcional)",
|
||||||
|
"views.index.SendMail.senderAddress": "Endereço do remetente",
|
||||||
|
"views.index.SendMail.senderName": "Nome do remetente (opcional)",
|
||||||
|
"views.admin.SendMail.adminComposeTip": "Enviar usando um endereço de e-mail configurado",
|
||||||
|
"views.admin.SendMail.composeMail": "Escrever e-mail",
|
||||||
|
"views.admin.SendMail.contentPlaceholder": "Escreva sua mensagem...",
|
||||||
|
"views.admin.SendMail.draftSaved": "Rascunho salvo neste navegador",
|
||||||
|
"views.admin.SendMail.recipientAddress": "Endereço do destinatário",
|
||||||
|
"views.admin.SendMail.recipientName": "Nome do destinatário (opcional)",
|
||||||
|
"views.admin.SendMail.senderAddress": "Endereço do remetente",
|
||||||
|
"views.admin.SendMail.senderName": "Nome do remetente (opcional)",
|
||||||
"views.admin.DatabaseManager.current_database_size": "Tamanho atual do banco de dados",
|
"views.admin.DatabaseManager.current_database_size": "Tamanho atual do banco de dados",
|
||||||
"views.admin.DatabaseManager.free_plan": "Free",
|
"views.admin.DatabaseManager.free_plan": "Free",
|
||||||
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
|
"views.admin.DatabaseManager.paid_plan": "Workers Paid",
|
||||||
@@ -17,10 +33,12 @@ export const ptBRMessages = {
|
|||||||
"views.Index.about": "Sobre",
|
"views.Index.about": "Sobre",
|
||||||
"views.Admin.about": "Sobre",
|
"views.Admin.about": "Sobre",
|
||||||
"views.Header.accessHeader": "Senha de acesso",
|
"views.Header.accessHeader": "Senha de acesso",
|
||||||
"views.Admin.account": "Conta",
|
"views.Header.admin": "Painel administrativo",
|
||||||
"views.Index.accountSettings": "Configurações de e-mail",
|
"views.Admin.mailbox_management": "Gerenciamento de caixas de correio",
|
||||||
"views.Admin.account_settings": "Configurações da conta",
|
"views.Admin.mailbox_list": "Caixas de correio",
|
||||||
"views.index.SimpleIndex.accountSettings": "Configurações de e-mail",
|
"views.Index.mailboxSettings": "Configurações da caixa de correio",
|
||||||
|
"views.Admin.mailbox_settings": "Configurações globais de caixas de correio",
|
||||||
|
"views.index.SimpleIndex.mailboxSettings": "Configurações da caixa de correio",
|
||||||
"views.index.Attachment.action": "Ação",
|
"views.index.Attachment.action": "Ação",
|
||||||
"views.admin.SenderAccess.action": "Ação",
|
"views.admin.SenderAccess.action": "Ação",
|
||||||
"views.user.UserSettings.actions": "Ações",
|
"views.user.UserSettings.actions": "Ações",
|
||||||
@@ -34,7 +52,7 @@ export const ptBRMessages = {
|
|||||||
"views.admin.Maintenance.customSqlTip": "Adicione instruções SQL DELETE personalizadas para a limpeza agendada. Apenas uma instrução DELETE é permitida por item.",
|
"views.admin.Maintenance.customSqlTip": "Adicione instruções SQL DELETE personalizadas para a limpeza agendada. Apenas uma instrução DELETE é permitida por item.",
|
||||||
"views.admin.Maintenance.addCustomSql": "Adicionar SQL personalizada",
|
"views.admin.Maintenance.addCustomSql": "Adicionar SQL personalizada",
|
||||||
"views.admin.UserOauth2Settings.addOauth2": "Adicionar OAuth2",
|
"views.admin.UserOauth2Settings.addOauth2": "Adicionar OAuth2",
|
||||||
"components.AddressSelect.address": "Endereço",
|
"components.AddressSelect.selectMailbox": "Selecionar caixa de correio",
|
||||||
"views.user.AddressManagement.address": "Endereço",
|
"views.user.AddressManagement.address": "Endereço",
|
||||||
"views.index.TelegramAddress.address": "Endereço",
|
"views.index.TelegramAddress.address": "Endereço",
|
||||||
"views.admin.SenderAccess.address": "Endereço",
|
"views.admin.SenderAccess.address": "Endereço",
|
||||||
@@ -70,7 +88,7 @@ export const ptBRMessages = {
|
|||||||
"views.admin.Account.clearSentItemsTip": "Tem certeza de que deseja limpar os itens enviados deste e-mail?",
|
"views.admin.Account.clearSentItemsTip": "Tem certeza de que deseja limpar os itens enviados deste e-mail?",
|
||||||
"views.admin.Account.multiDeleteTip": "Tem certeza de que deseja excluir os endereços selecionados?",
|
"views.admin.Account.multiDeleteTip": "Tem certeza de que deseja excluir os endereços selecionados?",
|
||||||
"views.index.Attachment.deleteConfirm": "Tem certeza de que deseja excluir este anexo?",
|
"views.index.Attachment.deleteConfirm": "Tem certeza de que deseja excluir este anexo?",
|
||||||
"views.admin.Account.deleteTip": "Tem certeza de que deseja excluir este e-mail?",
|
"views.admin.Account.deleteTip": "Tem certeza de que deseja excluir esta caixa de correio?",
|
||||||
"views.admin.SenderAccess.deleteTip": "Tem certeza de que deseja excluir isto?",
|
"views.admin.SenderAccess.deleteTip": "Tem certeza de que deseja excluir isto?",
|
||||||
"views.index.AccountSettings.deleteAccountConfirm": "Tem certeza de que deseja excluir este endereço de e-mail e todas as mensagens dele? Isso não exclui sua conta de usuário.",
|
"views.index.AccountSettings.deleteAccountConfirm": "Tem certeza de que deseja excluir este endereço de e-mail e todas as mensagens dele? Isso não exclui sua conta de usuário.",
|
||||||
"views.index.AccountSettings.logoutConfirm": "Tem certeza de que deseja sair do endereço de e-mail atual?",
|
"views.index.AccountSettings.logoutConfirm": "Tem certeza de que deseja sair do endereço de e-mail atual?",
|
||||||
@@ -107,7 +125,7 @@ export const ptBRMessages = {
|
|||||||
"views.admin.AccountSettings.cancel": "Cancelar",
|
"views.admin.AccountSettings.cancel": "Cancelar",
|
||||||
"components.MailBox.cancelMultiAction": "Cancelar múltipla seleção",
|
"components.MailBox.cancelMultiAction": "Cancelar múltipla seleção",
|
||||||
"components.SendBox.cancelMultiAction": "Cancelar múltipla seleção",
|
"components.SendBox.cancelMultiAction": "Cancelar múltipla seleção",
|
||||||
"views.user.AddressManagement.changeMailAddress": "Alterar endereço",
|
"views.user.AddressManagement.openMailbox": "Abrir caixa de correio",
|
||||||
"views.index.TelegramAddress.changeMailAddress": "Alterar endereço de e-mail",
|
"views.index.TelegramAddress.changeMailAddress": "Alterar endereço de e-mail",
|
||||||
"views.index.LocalAddress.changeMailAddress": "Alterar endereço de e-mail",
|
"views.index.LocalAddress.changeMailAddress": "Alterar endereço de e-mail",
|
||||||
"views.index.AccountSettings.changePassword": "Alterar senha do endereço",
|
"views.index.AccountSettings.changePassword": "Alterar senha do endereço",
|
||||||
@@ -146,8 +164,8 @@ export const ptBRMessages = {
|
|||||||
"views.index.SimpleIndex.copyAddress": "Copiar",
|
"views.index.SimpleIndex.copyAddress": "Copiar",
|
||||||
"components.AiExtractInfo.copyFailed": "Falha ao copiar",
|
"components.AiExtractInfo.copyFailed": "Falha ao copiar",
|
||||||
"views.Footer.copyright": "Direitos autorais",
|
"views.Footer.copyright": "Direitos autorais",
|
||||||
"views.Admin.account_create": "Criar conta",
|
"views.Admin.mailbox_create": "Criar endereço de e-mail",
|
||||||
"views.admin.CreateAccount.creatNewEmail": "Criar novo e-mail",
|
"views.admin.CreateAccount.createEmailAddress": "Criar endereço de e-mail",
|
||||||
"views.common.Login.getNewEmail": "Criar novo e-mail",
|
"views.common.Login.getNewEmail": "Criar novo e-mail",
|
||||||
"views.user.AddressManagement.create_or_bind": "Criar ou vincular",
|
"views.user.AddressManagement.create_or_bind": "Criar ou vincular",
|
||||||
"views.index.LocalAddress.create_or_bind": "Criar ou vincular",
|
"views.index.LocalAddress.create_or_bind": "Criar ou vincular",
|
||||||
@@ -182,8 +200,8 @@ export const ptBRMessages = {
|
|||||||
"views.admin.UserManagement.delete": "Excluir",
|
"views.admin.UserManagement.delete": "Excluir",
|
||||||
"views.admin.AccountSettings.delete_rule": "Excluir",
|
"views.admin.AccountSettings.delete_rule": "Excluir",
|
||||||
"views.admin.Maintenance.deleteCustomSql": "Excluir",
|
"views.admin.Maintenance.deleteCustomSql": "Excluir",
|
||||||
"views.index.AccountSettings.deleteAccount": "Excluir endereço de e-mail",
|
"views.index.AccountSettings.deleteMailbox": "Excluir caixa de correio",
|
||||||
"views.admin.Account.deleteAccount": "Excluir conta",
|
"views.admin.Account.deleteMailbox": "Excluir caixa de correio",
|
||||||
"views.user.UserSettings.deletePasskey": "Excluir passkey",
|
"views.user.UserSettings.deletePasskey": "Excluir passkey",
|
||||||
"views.admin.AccountSettings.delete_success": "Excluído com sucesso",
|
"views.admin.AccountSettings.delete_success": "Excluído com sucesso",
|
||||||
"views.admin.UserManagement.deleteUser": "Excluir usuário",
|
"views.admin.UserManagement.deleteUser": "Excluir usuário",
|
||||||
@@ -254,7 +272,7 @@ export const ptBRMessages = {
|
|||||||
"views.admin.Telegram.globalMailPushList": "Lista global de chat ID para envio de e-mails",
|
"views.admin.Telegram.globalMailPushList": "Lista global de chat ID para envio de e-mails",
|
||||||
"views.common.Appearance.globalTabplacement": "Posição global das abas",
|
"views.common.Appearance.globalTabplacement": "Posição global das abas",
|
||||||
"views.common.Login.help": "Ajuda",
|
"views.common.Login.help": "Ajuda",
|
||||||
"views.Header.home": "Início",
|
"views.Header.mailbox": "Caixa de correio",
|
||||||
"views.index.SendMail.html": "HTML",
|
"views.index.SendMail.html": "HTML",
|
||||||
"views.admin.SendMail.html": "HTML",
|
"views.admin.SendMail.html": "HTML",
|
||||||
"views.admin.UserOauth2Settings.icon": "Ícone (SVG, use apenas fonte confiável)",
|
"views.admin.UserOauth2Settings.icon": "Ícone (SVG, use apenas fonte confiável)",
|
||||||
@@ -303,7 +321,7 @@ export const ptBRMessages = {
|
|||||||
"views.admin.Account.addressCredential": "Credencial do endereço de e-mail",
|
"views.admin.Account.addressCredential": "Credencial do endereço de e-mail",
|
||||||
"views.admin.CreateAccount.addressCredential": "Credencial do endereço de e-mail",
|
"views.admin.CreateAccount.addressCredential": "Credencial do endereço de e-mail",
|
||||||
"views.index.AddressBar.fetchAddressError": "A credencial do e-mail é inválida ou a conta não existe; também pode ser um problema de rede. Tente novamente mais tarde.",
|
"views.index.AddressBar.fetchAddressError": "A credencial do e-mail é inválida ou a conta não existe; também pode ser um problema de rede. Tente novamente mais tarde.",
|
||||||
"views.Index.mailbox": "Caixa de entrada",
|
"views.Index.inbox": "Caixa de entrada",
|
||||||
"views.User.user_mail_box_tab": "Caixa de entrada",
|
"views.User.user_mail_box_tab": "Caixa de entrada",
|
||||||
"views.user.AddressManagement.mail_count": "Quantidade de e-mails",
|
"views.user.AddressManagement.mail_count": "Quantidade de e-mails",
|
||||||
"views.admin.Account.mail_count": "Quantidade de e-mails",
|
"views.admin.Account.mail_count": "Quantidade de e-mails",
|
||||||
@@ -333,11 +351,11 @@ export const ptBRMessages = {
|
|||||||
"views.admin.Account.multiClearInbox": "Limpar várias caixas de entrada",
|
"views.admin.Account.multiClearInbox": "Limpar várias caixas de entrada",
|
||||||
"views.admin.Account.multiClearSentItems": "Limpar vários itens enviados",
|
"views.admin.Account.multiClearSentItems": "Limpar vários itens enviados",
|
||||||
"views.admin.Account.multiDelete": "Excluir em lote",
|
"views.admin.Account.multiDelete": "Excluir em lote",
|
||||||
"views.user.AddressManagement.name": "Nome",
|
"views.user.AddressManagement.emailAddress": "Endereço de e-mail",
|
||||||
"views.index.AutoReply.name": "Nome",
|
"views.index.AutoReply.name": "Nome",
|
||||||
"views.admin.Account.name": "Nome",
|
"views.admin.Account.emailAddress": "Endereço de e-mail",
|
||||||
"views.admin.UserOauth2Settings.name": "Nome",
|
"views.admin.UserOauth2Settings.name": "Nome",
|
||||||
"views.admin.UserAddressManagement.name": "Nome",
|
"views.admin.UserAddressManagement.emailAddress": "Endereço de e-mail",
|
||||||
"views.admin.Maintenance.sqlName": "Nome",
|
"views.admin.Maintenance.sqlName": "Nome",
|
||||||
"views.index.AccountSettings.newPassword": "Nova senha",
|
"views.index.AccountSettings.newPassword": "Nova senha",
|
||||||
"views.admin.Account.newPassword": "Nova senha",
|
"views.admin.Account.newPassword": "Nova senha",
|
||||||
@@ -386,6 +404,7 @@ export const ptBRMessages = {
|
|||||||
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Informe o novo nome da passkey",
|
"views.user.UserSettings.renamePasskeyNamePlaceholder": "Informe o novo nome da passkey",
|
||||||
"views.user.UserSettings.passkeyNamePlaceholder": "Informe o nome da passkey ou deixe em branco para gerar um aleatório",
|
"views.user.UserSettings.passkeyNamePlaceholder": "Informe o nome da passkey ou deixe em branco para gerar um aleatório",
|
||||||
"views.admin.CreateAccount.fillInAllFields": "Preencha todos os campos",
|
"views.admin.CreateAccount.fillInAllFields": "Preencha todos os campos",
|
||||||
|
"views.admin.CreateAccount.generateName": "Gerar nome aleatório",
|
||||||
"views.admin.SendBox.queryTip": "Informe o endereço para consulta; deixe em branco para consultar todos",
|
"views.admin.SendBox.queryTip": "Informe o endereço para consulta; deixe em branco para consultar todos",
|
||||||
"views.user.UserLogin.pleaseInputCode": "Informe o código",
|
"views.user.UserLogin.pleaseInputCode": "Informe o código",
|
||||||
"views.admin.UserManagement.pleaseInput": "Informe todas as informações",
|
"views.admin.UserManagement.pleaseInput": "Informe todas as informações",
|
||||||
@@ -568,13 +587,13 @@ export const ptBRMessages = {
|
|||||||
"views.admin.UserOauth2Settings.userEmailFormatTip": "Use regex para transformar o e-mail. Exemplo: ^(.+)@old\\.com$ com $1@new.com",
|
"views.admin.UserOauth2Settings.userEmailFormatTip": "Use regex para transformar o e-mail. Exemplo: ^(.+)@old\\.com$ com $1@new.com",
|
||||||
"views.common.Appearance.useSimpleIndex": "Usar índice simples",
|
"views.common.Appearance.useSimpleIndex": "Usar índice simples",
|
||||||
"views.common.Appearance.useUTCDate": "Usar data UTC",
|
"views.common.Appearance.useUTCDate": "Usar data UTC",
|
||||||
"views.Header.user": "Usuário",
|
"views.Header.userCenter": "Central do usuário",
|
||||||
"views.Admin.user": "Usuário",
|
"views.Admin.user": "Usuário",
|
||||||
"components.AddressSelect.userAddresses": "Endereços do usuário",
|
"components.AddressSelect.linkedMailboxes": "Caixas de correio vinculadas",
|
||||||
"views.Admin.loginViaUserAdmin": "Permissão de administrador do usuário",
|
"views.Admin.loginViaUserAdmin": "Conta de usuário (função de administrador)",
|
||||||
"views.admin.Statistics.userCount": "Quantidade de usuários",
|
"views.admin.Statistics.userCount": "Quantidade de usuários",
|
||||||
"views.admin.UserManagement.user_email": "E-mail do usuário",
|
"views.admin.UserManagement.user_email": "E-mail do usuário",
|
||||||
"views.index.AddressBar.userLogin": "Login do usuário",
|
"views.index.AddressBar.userCenter": "Central do usuário",
|
||||||
"views.Admin.user_management": "Gerenciamento de usuários",
|
"views.Admin.user_management": "Gerenciamento de usuários",
|
||||||
"views.Admin.user_settings": "Configurações do usuário",
|
"views.Admin.user_settings": "Configurações do usuário",
|
||||||
"views.User.user_settings": "Configurações do usuário",
|
"views.User.user_settings": "Configurações do usuário",
|
||||||
@@ -634,5 +653,15 @@ export const ptBRMessages = {
|
|||||||
"components.AddressCredentialModal.starttls": "STARTTLS",
|
"components.AddressCredentialModal.starttls": "STARTTLS",
|
||||||
"components.AddressCredentialModal.tip": "Use estas credenciais somente com clientes e agents confiáveis.",
|
"components.AddressCredentialModal.tip": "Use estas credenciais somente com clientes e agents confiáveis.",
|
||||||
"components.AddressCredentialModal.title": "Credenciais do endereço e métodos de conexão",
|
"components.AddressCredentialModal.title": "Credenciais do endereço e métodos de conexão",
|
||||||
"components.AddressCredentialModal.username": "Nome de usuário"
|
"components.AddressCredentialModal.username": "Nome de usuário",
|
||||||
|
"views.User.send_mail": "Enviar e-mail",
|
||||||
|
"views.user.UserSendBox.noAddress": "Selecione um endereço de e-mail vinculado",
|
||||||
|
"views.user.UserSendBox.sendbox": "Enviados",
|
||||||
|
"components.MailContentRenderer.markAsRead": "Marcar como lido",
|
||||||
|
"components.MailContentRenderer.markAsUnread": "Marcar como não lido",
|
||||||
|
"components.MailContentRenderer.loadRemoteImages": "Carregar imagens",
|
||||||
|
"components.MailContentRenderer.remoteImagesBlocked": "{count} recursos externos foram bloqueados para proteger sua privacidade",
|
||||||
|
"views.user.AddressManagement.itemCount": "Total",
|
||||||
|
"views.index.SimpleIndex.readStatusUpdated": "Status de leitura atualizado",
|
||||||
|
"views.common.Appearance.autoLoadRemoteImages": "Carregar automaticamente imagens externas no corpo do e-mail"
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
|||||||
import { LOCALE_REGISTRY, SUPPORTED_LOCALES } from './locale-registry'
|
import { LOCALE_REGISTRY, SUPPORTED_LOCALES } from './locale-registry'
|
||||||
|
import { APP_CONFIG } from '../config'
|
||||||
|
|
||||||
export { SUPPORTED_LOCALES } from './locale-registry'
|
export { SUPPORTED_LOCALES } from './locale-registry'
|
||||||
export type { SupportedLocale } from './locale-registry'
|
export type { SupportedLocale } from './locale-registry'
|
||||||
|
|
||||||
import type { SupportedLocale } from './locale-registry'
|
import type { SupportedLocale } from './locale-registry'
|
||||||
|
|
||||||
export const DEFAULT_LOCALE: SupportedLocale = 'zh'
|
|
||||||
export const FALLBACK_LOCALE: SupportedLocale = 'zh'
|
export const FALLBACK_LOCALE: SupportedLocale = 'zh'
|
||||||
export const PREFERRED_LOCALE_STORAGE_KEY = 'preferredLocale'
|
export const PREFERRED_LOCALE_STORAGE_KEY = 'preferredLocale'
|
||||||
export const EMPTY_LOCALE_MESSAGES = Object.fromEntries(
|
export const EMPTY_LOCALE_MESSAGES = Object.fromEntries(
|
||||||
@@ -29,6 +29,9 @@ export const resolveSupportedLocale = (locale: string | null | undefined): Suppo
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_LOCALE: SupportedLocale = resolveSupportedLocale(APP_CONFIG.DEFAULT_LANG)
|
||||||
|
|| FALLBACK_LOCALE
|
||||||
|
|
||||||
export const matchSupportedLocale = (locale: string | null | undefined): SupportedLocale | null => {
|
export const matchSupportedLocale = (locale: string | null | undefined): SupportedLocale | null => {
|
||||||
if (!locale) return null
|
if (!locale) return null
|
||||||
const normalizedLocale = locale.trim().toLowerCase()
|
const normalizedLocale = locale.trim().toLowerCase()
|
||||||
@@ -135,4 +138,4 @@ const getLocaleAliasPath = (path: string, locale: SupportedLocale): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return getPathWithLocale(basePath, locale)
|
return getPathWithLocale(basePath, locale)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import User from '../views/User.vue'
|
|||||||
import UserOauth2Callback from '../views/user/UserOauth2Callback.vue'
|
import UserOauth2Callback from '../views/user/UserOauth2Callback.vue'
|
||||||
import i18n from '../i18n'
|
import i18n from '../i18n'
|
||||||
import { useGlobalState } from '../store'
|
import { useGlobalState } from '../store'
|
||||||
|
import { api } from '../api'
|
||||||
import {
|
import {
|
||||||
DEFAULT_LOCALE,
|
DEFAULT_LOCALE,
|
||||||
getBrowserLocales,
|
getBrowserLocales,
|
||||||
@@ -12,7 +13,7 @@ import {
|
|||||||
resolveSupportedLocale,
|
resolveSupportedLocale,
|
||||||
} from '../i18n/utils'
|
} from '../i18n/utils'
|
||||||
|
|
||||||
const { jwt, preferredLocale } = useGlobalState()
|
const { jwt, preferredLocale, openSettings } = useGlobalState()
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@@ -27,6 +28,11 @@ const router = createRouter({
|
|||||||
alias: '/:lang/user',
|
alias: '/:lang/user',
|
||||||
component: User
|
component: User
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/redeem',
|
||||||
|
alias: '/:lang/redeem',
|
||||||
|
component: () => import('../views/Redeem.vue')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/user/oauth2/callback',
|
path: '/user/oauth2/callback',
|
||||||
alias: '/:lang/user/oauth2/callback',
|
alias: '/:lang/user/oauth2/callback',
|
||||||
@@ -50,7 +56,7 @@ const router = createRouter({
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach(async (to, from, next) => {
|
||||||
const routeLocale = resolveSupportedLocale(to.path.split('/')[1])
|
const routeLocale = resolveSupportedLocale(to.path.split('/')[1])
|
||||||
const resolvedLocale = routeLocale || DEFAULT_LOCALE
|
const resolvedLocale = routeLocale || DEFAULT_LOCALE
|
||||||
i18n.global.locale.value = resolvedLocale
|
i18n.global.locale.value = resolvedLocale
|
||||||
@@ -64,7 +70,12 @@ router.beforeEach((to, from, next) => {
|
|||||||
if (Object.prototype.hasOwnProperty.call(to.query, 'jwt')) {
|
if (Object.prototype.hasOwnProperty.call(to.query, 'jwt')) {
|
||||||
const jwtQuery = Array.isArray(to.query.jwt) ? to.query.jwt[0] : to.query.jwt
|
const jwtQuery = Array.isArray(to.query.jwt) ? to.query.jwt[0] : to.query.jwt
|
||||||
if (typeof jwtQuery === 'string') {
|
if (typeof jwtQuery === 'string') {
|
||||||
jwt.value = jwtQuery
|
try {
|
||||||
|
const config = openSettings.value.fetched ? openSettings.value : await api.fetch('/open_api/settings');
|
||||||
|
if (!config.addressPasswordLoginOnly) jwt.value = jwtQuery;
|
||||||
|
} catch {
|
||||||
|
// Do not import a login link until the server policy is known.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const query = { ...to.query }
|
const query = { ...to.query }
|
||||||
delete query.jwt
|
delete query.jwt
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const useGlobalState = createGlobalState(
|
|||||||
disableAnonymousUserCreateEmail: false,
|
disableAnonymousUserCreateEmail: false,
|
||||||
disableCustomAddressName: false,
|
disableCustomAddressName: false,
|
||||||
enableUserDeleteEmail: false,
|
enableUserDeleteEmail: false,
|
||||||
|
enableMailReadStatus: false,
|
||||||
enableAutoReply: false,
|
enableAutoReply: false,
|
||||||
enableIndexAbout: false,
|
enableIndexAbout: false,
|
||||||
/** @type {string[]} */
|
/** @type {string[]} */
|
||||||
@@ -41,7 +42,10 @@ export const useGlobalState = createGlobalState(
|
|||||||
showGithubForUser: true,
|
showGithubForUser: true,
|
||||||
disableAdminPasswordCheck: false,
|
disableAdminPasswordCheck: false,
|
||||||
enableAddressPassword: false,
|
enableAddressPassword: false,
|
||||||
|
addressPasswordLoginOnly: false,
|
||||||
enableAgentEmailInfo: false,
|
enableAgentEmailInfo: false,
|
||||||
|
enableRedeemCode: false,
|
||||||
|
redeemCodeUrl: '',
|
||||||
smtpImapProxyConfig: {
|
smtpImapProxyConfig: {
|
||||||
smtp: {
|
smtp: {
|
||||||
host: '',
|
host: '',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useBreakpoint, useMemo } from 'vooks'
|
import { useBreakpoint, useMemo } from 'vooks'
|
||||||
|
|
||||||
export function useIsMobile() {
|
export function useIsMobile() {
|
||||||
const breakpointRef = useBreakpoint()
|
const breakpointRef = useBreakpoint({ xs: 0, s: 768 })
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
return breakpointRef.value === 'xs'
|
return breakpointRef.value === 'xs'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export type CachedAddress = {
|
||||||
|
token: string;
|
||||||
|
address?: string;
|
||||||
|
type?: 'address_password_login' | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCachedAddresses = (cache: (string | CachedAddress)[]): CachedAddress[] => cache
|
||||||
|
.map(entry => typeof entry === 'string' ? { token: entry } : entry)
|
||||||
|
.filter(entry => typeof entry?.token === 'string' && entry.token);
|
||||||
|
|
||||||
|
export const updateLocalAddressCache = (
|
||||||
|
cache: (string | CachedAddress)[], token: string,
|
||||||
|
{ address, type }: { address: string; type?: 'address_password_login' },
|
||||||
|
) => {
|
||||||
|
const entries = getCachedAddresses(cache);
|
||||||
|
const loginType = type ?? null;
|
||||||
|
const existing = entries.find(entry => entry.token === token
|
||||||
|
|| (entry.address === address && entry.type === loginType));
|
||||||
|
const updated = { token, address, type: loginType };
|
||||||
|
if (!existing) return [...entries, updated];
|
||||||
|
return entries.flatMap(entry => {
|
||||||
|
if (entry === existing) return [updated];
|
||||||
|
if (entry.token === token || (entry.address === address && entry.type === loginType)) return [];
|
||||||
|
return [entry];
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -30,6 +30,7 @@ import MailWebhook from './admin/MailWebhook.vue';
|
|||||||
import WorkerConfig from './admin/WorkerConfig.vue';
|
import WorkerConfig from './admin/WorkerConfig.vue';
|
||||||
import IpBlacklistSettings from './admin/IpBlacklistSettings.vue';
|
import IpBlacklistSettings from './admin/IpBlacklistSettings.vue';
|
||||||
import AiExtractSettings from './admin/AiExtractSettings.vue';
|
import AiExtractSettings from './admin/AiExtractSettings.vue';
|
||||||
|
import RedeemCodes from './admin/RedeemCodes.vue';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
adminAuth, showAdminAuth, adminTab, loading,
|
adminAuth, showAdminAuth, adminTab, loading,
|
||||||
@@ -106,7 +107,7 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="userSettings.fetched">
|
<div v-if="openSettings.fetched && userSettings.fetched">
|
||||||
<n-modal v-model:show="showAdminPasswordModal" :closable="false" :closeOnEsc="false" :maskClosable="false"
|
<n-modal v-model:show="showAdminPasswordModal" :closable="false" :closeOnEsc="false" :maskClosable="false"
|
||||||
preset="dialog" :title="t('accessHeader')">
|
preset="dialog" :title="t('accessHeader')">
|
||||||
<p>{{ t('accessTip') }}</p>
|
<p>{{ t('accessTip') }}</p>
|
||||||
@@ -124,7 +125,7 @@ onMounted(async () => {
|
|||||||
<n-tab-pane name="database" :tab="t('database')">
|
<n-tab-pane name="database" :tab="t('database')">
|
||||||
<DatabaseManager />
|
<DatabaseManager />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="account_settings" :tab="t('account_settings')">
|
<n-tab-pane name="account_settings" :tab="t('mailbox_settings')">
|
||||||
<AccountSettings />
|
<AccountSettings />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="user_settings" :tab="t('user_settings')">
|
<n-tab-pane name="user_settings" :tab="t('user_settings')">
|
||||||
@@ -135,15 +136,15 @@ onMounted(async () => {
|
|||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
</n-tabs>
|
</n-tabs>
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="account" :tab="t('account')">
|
<n-tab-pane name="account" :tab="t('mailbox_management')">
|
||||||
<n-tabs key="account-tabs" type="bar" justify-content="center" animated>
|
<n-tabs key="account-tabs" type="bar" justify-content="center" animated>
|
||||||
<n-tab-pane name="account" :tab="t('account')">
|
<n-tab-pane name="account" :tab="t('mailbox_list')">
|
||||||
<Account />
|
<Account />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="account_create" :tab="t('account_create')">
|
<n-tab-pane name="account_create" :tab="t('mailbox_create')">
|
||||||
<CreateAccount />
|
<CreateAccount />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="account_settings" :tab="t('account_settings')">
|
<n-tab-pane name="account_settings" :tab="t('mailbox_settings')">
|
||||||
<AccountSettings />
|
<AccountSettings />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="senderAccess" :tab="t('senderAccess')">
|
<n-tab-pane name="senderAccess" :tab="t('senderAccess')">
|
||||||
@@ -201,6 +202,9 @@ onMounted(async () => {
|
|||||||
<n-tab-pane name="statistics" :tab="t('statistics')">
|
<n-tab-pane name="statistics" :tab="t('statistics')">
|
||||||
<Statistics />
|
<Statistics />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
|
<n-tab-pane v-if="openSettings.enableRedeemCode" name="redeemCodes" :tab="t('redeemCodes')">
|
||||||
|
<RedeemCodes />
|
||||||
|
</n-tab-pane>
|
||||||
<n-tab-pane name="maintenance" :tab="t('maintenance')">
|
<n-tab-pane name="maintenance" :tab="t('maintenance')">
|
||||||
<n-tabs key="maintenance-tabs" type="bar" justify-content="center" animated>
|
<n-tabs key="maintenance-tabs" type="bar" justify-content="center" animated>
|
||||||
<n-tab-pane name="database" :tab="t('database')">
|
<n-tab-pane name="database" :tab="t('database')">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
AdminPanelSettingsFilled, MonitorHeartFilled,
|
AdminPanelSettingsFilled, MonitorHeartFilled,
|
||||||
KeyboardArrowDownOutlined, OpenInNewOutlined
|
KeyboardArrowDownOutlined, OpenInNewOutlined
|
||||||
} from '@vicons/material'
|
} from '@vicons/material'
|
||||||
import { GithubAlt, Language, User, Home } from '@vicons/fa'
|
import { Envelope, GithubAlt, Language, User } from '@vicons/fa'
|
||||||
|
|
||||||
import { useGlobalState } from '../store'
|
import { useGlobalState } from '../store'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
@@ -123,8 +123,8 @@ const menuOptions = computed(() => [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
default: () => t('home'),
|
default: () => t('mailbox'),
|
||||||
icon: () => h(NIcon, { component: Home })
|
icon: () => h(NIcon, { component: Envelope })
|
||||||
}),
|
}),
|
||||||
key: "home"
|
key: "home"
|
||||||
},
|
},
|
||||||
@@ -142,7 +142,7 @@ const menuOptions = computed(() => [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
default: () => t('user'),
|
default: () => t('userCenter'),
|
||||||
icon: () => h(NIcon, { component: User }),
|
icon: () => h(NIcon, { component: User }),
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -165,7 +165,7 @@ const menuOptions = computed(() => [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
default: () => "Admin",
|
default: () => t('admin'),
|
||||||
icon: () => h(NIcon, { component: AdminPanelSettingsFilled }),
|
icon: () => h(NIcon, { component: AdminPanelSettingsFilled }),
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -331,6 +331,32 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
:deep(.n-page-header) {
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-page-header__main) {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-page-header__title) {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-page-header__title h3) {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.n-page-header__extra) {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.n-layout-header {
|
.n-layout-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -436,7 +462,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
:deep(.n-page-header) {
|
:deep(.n-page-header) {
|
||||||
padding: 10px;
|
padding: 10px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.n-page-header__title) {
|
:deep(.n-page-header__title) {
|
||||||
@@ -444,10 +470,11 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
:deep(.n-page-header__title h3) {
|
:deep(.n-page-header__title h3) {
|
||||||
max-width: calc(100vw - 136px);
|
max-width: calc(100vw - 104px);
|
||||||
overflow: hidden;
|
margin: 0;
|
||||||
text-overflow: ellipsis;
|
font-size: clamp(16px, 5vw, 20px);
|
||||||
white-space: nowrap;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -45,6 +45,14 @@ const deleteMail = async (curMailId) => {
|
|||||||
await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' });
|
await api.fetch(`/api/mails/${curMailId}`, { method: 'DELETE' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateMailReadStatus = async (id, isUnread) => {
|
||||||
|
await api.fetch(`/api/mails/${id}/read`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ isUnread }),
|
||||||
|
showLoading: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const deleteSenboxMail = async (curMailId) => {
|
const deleteSenboxMail = async (curMailId) => {
|
||||||
await api.fetch(`/api/sendbox/${curMailId}`, { method: 'DELETE' });
|
await api.fetch(`/api/sendbox/${curMailId}`, { method: 'DELETE' });
|
||||||
};
|
};
|
||||||
@@ -116,7 +124,7 @@ onMounted(() => {
|
|||||||
{{ t('enterSimpleMode') }}
|
{{ t('enterSimpleMode') }}
|
||||||
</n-button>
|
</n-button>
|
||||||
</template>
|
</template>
|
||||||
<n-tab-pane name="mailbox" :tab="t('mailbox')">
|
<n-tab-pane name="mailbox" :tab="t('inbox')">
|
||||||
<div v-if="showMailIdQuery" style="margin-bottom: 10px;">
|
<div v-if="showMailIdQuery" style="margin-bottom: 10px;">
|
||||||
<n-input-group>
|
<n-input-group>
|
||||||
<n-input v-model:value="mailIdQuery" />
|
<n-input v-model:value="mailIdQuery" />
|
||||||
@@ -127,7 +135,8 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="openSettings.enableSendMail" :showSaveS3="openSettings.isS3Enabled"
|
<MailBox :key="mailBoxKey" :showEMailTo="false" :showReply="openSettings.enableSendMail" :showSaveS3="openSettings.isS3Enabled"
|
||||||
:saveToS3="saveToS3" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
|
:saveToS3="saveToS3" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
|
||||||
:fetchMailData="fetchMailData" :deleteMail="deleteMail" :showFilterInput="true" />
|
:fetchMailData="fetchMailData" :deleteMail="deleteMail" :showFilterInput="true"
|
||||||
|
:enableMailReadStatus="openSettings.enableMailReadStatus" :updateMailReadStatus="updateMailReadStatus" />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane v-if="openSettings.enableSendMail" name="sendbox" :tab="t('sendbox')">
|
<n-tab-pane v-if="openSettings.enableSendMail" name="sendbox" :tab="t('sendbox')">
|
||||||
<SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
|
<SendBox :fetchMailData="fetchSenboxData" :enableUserDeleteEmail="openSettings.enableUserDeleteEmail"
|
||||||
@@ -136,7 +145,7 @@ onMounted(() => {
|
|||||||
<n-tab-pane v-if="openSettings.enableSendMail" name="sendmail" :tab="t('sendmail')">
|
<n-tab-pane v-if="openSettings.enableSendMail" name="sendmail" :tab="t('sendmail')">
|
||||||
<SendMail />
|
<SendMail />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="accountSettings" :tab="t('accountSettings')">
|
<n-tab-pane name="accountSettings" :tab="t('mailboxSettings')">
|
||||||
<AccountSettings />
|
<AccountSettings />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane name="appearance" :tab="t('appearance')">
|
<n-tab-pane name="appearance" :tab="t('appearance')">
|
||||||
|
|||||||
@@ -0,0 +1,627 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import {
|
||||||
|
AlternateEmailOutlined,
|
||||||
|
ArrowBackOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
RedeemOutlined,
|
||||||
|
SendOutlined,
|
||||||
|
} from '@vicons/material'
|
||||||
|
|
||||||
|
import { api } from '../api'
|
||||||
|
import AddressCredentialContent from '../components/AddressCredentialContent.vue'
|
||||||
|
import { useGlobalState } from '../store'
|
||||||
|
import { useScopedI18n } from '../i18n/app'
|
||||||
|
import { getRouterPathWithLang } from '../utils'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
const notification = useNotification()
|
||||||
|
const { locale, t } = useScopedI18n('views.Redeem')
|
||||||
|
const {
|
||||||
|
jwt,
|
||||||
|
settings,
|
||||||
|
openSettings,
|
||||||
|
userSettings,
|
||||||
|
addressPassword,
|
||||||
|
showAddressCredential,
|
||||||
|
} = useGlobalState()
|
||||||
|
|
||||||
|
const code = ref('')
|
||||||
|
const codeInfo = ref(null)
|
||||||
|
const redeemStarted = ref(false)
|
||||||
|
const completed = ref(null)
|
||||||
|
const targetUserEmail = ref('')
|
||||||
|
const targetAddress = ref(settings.value.address || '')
|
||||||
|
const emailName = ref('')
|
||||||
|
const emailDomain = ref('')
|
||||||
|
const subdomainMode = ref('normal')
|
||||||
|
const customSubdomain = ref('')
|
||||||
|
let latestQueryRequest = 0
|
||||||
|
|
||||||
|
const activeRedeemType = computed(() => completed.value?.type || codeInfo.value?.redeem_type || '')
|
||||||
|
const codeStatusType = computed(() => {
|
||||||
|
if (codeInfo.value?.status === 'expired') return 'error'
|
||||||
|
return codeInfo.value?.status === 'unused' ? 'success' : 'default'
|
||||||
|
})
|
||||||
|
const redeemTypes = computed(() => [
|
||||||
|
{
|
||||||
|
type: 'role',
|
||||||
|
icon: RedeemOutlined,
|
||||||
|
title: t('roleTitle'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'send_balance',
|
||||||
|
icon: SendOutlined,
|
||||||
|
title: t('balanceTitle'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'address_prefix_once',
|
||||||
|
icon: AlternateEmailOutlined,
|
||||||
|
title: t('prefixTitle'),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const redeemCodeUrl = computed(() => {
|
||||||
|
try {
|
||||||
|
const url = new URL(openSettings.value.redeemCodeUrl)
|
||||||
|
return ['http:', 'https:'].includes(url.protocol) ? url.href : ''
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const domainsOptions = computed(() => {
|
||||||
|
const allowed = openSettings.value.defaultDomains?.length
|
||||||
|
? openSettings.value.defaultDomains
|
||||||
|
: openSettings.value.domains.map((item) => item.value)
|
||||||
|
return openSettings.value.domains.filter((item) => allowed.includes(item.value))
|
||||||
|
})
|
||||||
|
|
||||||
|
const canUseRandomSubdomain = computed(() => (
|
||||||
|
openSettings.value.randomSubdomainDomains?.includes(emailDomain.value)
|
||||||
|
))
|
||||||
|
const addressNameMaxLength = computed(() => Math.max(
|
||||||
|
(openSettings.value.maxAddressLen || 30) - (codeInfo.value?.value?.length || 0),
|
||||||
|
1,
|
||||||
|
))
|
||||||
|
const addressNameMinLength = computed(() => Math.min(
|
||||||
|
addressNameMaxLength.value,
|
||||||
|
Math.max(
|
||||||
|
(openSettings.value.minAddressLen || 1) - (codeInfo.value?.value?.length || 0),
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
watch(canUseRandomSubdomain, (enabled) => {
|
||||||
|
if (!enabled) subdomainMode.value = 'normal'
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(addressNameMaxLength, (maxLength) => {
|
||||||
|
emailName.value = emailName.value.slice(0, maxLength)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(domainsOptions, (options) => {
|
||||||
|
if (!options.some((item) => item.value === emailDomain.value)) {
|
||||||
|
emailDomain.value = options[0]?.value || ''
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
watch(code, () => {
|
||||||
|
latestQueryRequest += 1
|
||||||
|
codeInfo.value = null
|
||||||
|
redeemStarted.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => userSettings.value.user_email, (email) => {
|
||||||
|
if (!targetUserEmail.value) targetUserEmail.value = email || ''
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
watch(() => settings.value.address, (address) => {
|
||||||
|
if (!targetAddress.value) targetAddress.value = address || ''
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
const loadContext = async () => {
|
||||||
|
if (!openSettings.value.fetched) {
|
||||||
|
await api.getOpenSettings(message, notification)
|
||||||
|
}
|
||||||
|
if (!openSettings.value.enableRedeemCode) {
|
||||||
|
await router.replace(getRouterPathWithLang('/', locale.value))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (jwt.value && !settings.value.address) {
|
||||||
|
try {
|
||||||
|
await api.getSettings()
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryCode = async () => {
|
||||||
|
const queriedCode = code.value.trim()
|
||||||
|
if (!queriedCode) return
|
||||||
|
const requestId = ++latestQueryRequest
|
||||||
|
try {
|
||||||
|
const result = await api.fetch('/redeem_api/query', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ code: queriedCode }),
|
||||||
|
})
|
||||||
|
if (requestId !== latestQueryRequest || code.value.trim() !== queriedCode) return
|
||||||
|
codeInfo.value = result
|
||||||
|
redeemStarted.value = false
|
||||||
|
completed.value = null
|
||||||
|
await loadContext()
|
||||||
|
} catch (error) {
|
||||||
|
if (requestId !== latestQueryRequest) return
|
||||||
|
codeInfo.value = null
|
||||||
|
message.error(error.message || 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetRedemption = () => {
|
||||||
|
code.value = ''
|
||||||
|
codeInfo.value = null
|
||||||
|
redeemStarted.value = false
|
||||||
|
completed.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const redeem = async () => {
|
||||||
|
if (!codeInfo.value) return
|
||||||
|
try {
|
||||||
|
let result
|
||||||
|
if (codeInfo.value.redeem_type === 'role') {
|
||||||
|
if (!targetUserEmail.value.trim()) return
|
||||||
|
result = await api.fetch('/redeem_api/redeem', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: code.value,
|
||||||
|
user_email: targetUserEmail.value.trim(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
} else if (codeInfo.value.redeem_type === 'send_balance') {
|
||||||
|
if (!targetAddress.value.trim()) return
|
||||||
|
result = await api.fetch('/redeem_api/redeem', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: code.value,
|
||||||
|
address: targetAddress.value.trim(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
completed.value = result
|
||||||
|
codeInfo.value = null
|
||||||
|
if (result?.type === 'role'
|
||||||
|
&& result.user_email.toLowerCase() === userSettings.value.user_email?.trim().toLowerCase()
|
||||||
|
) {
|
||||||
|
await api.getUserSettings(message)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error.message || 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateName = () => {
|
||||||
|
const length = Math.min(
|
||||||
|
addressNameMaxLength.value,
|
||||||
|
Math.max(addressNameMinLength.value, 8),
|
||||||
|
)
|
||||||
|
const charset = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(length))
|
||||||
|
emailName.value = Array.from(bytes, (value) => charset[value % charset.length]).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const createAddress = async () => {
|
||||||
|
if (!codeInfo.value || codeInfo.value.redeem_type !== 'address_prefix_once') return
|
||||||
|
try {
|
||||||
|
const domain = subdomainMode.value === 'custom'
|
||||||
|
? `${customSubdomain.value.trim()}.${emailDomain.value}`
|
||||||
|
: emailDomain.value
|
||||||
|
const result = await api.fetch('/redeem_api/redeem', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
code: code.value,
|
||||||
|
name: openSettings.value.disableCustomAddressName ? '' : emailName.value,
|
||||||
|
domain,
|
||||||
|
enableRandomSubdomain: subdomainMode.value === 'random',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
completed.value = result
|
||||||
|
codeInfo.value = null
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error.message || 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showRedeemResult = async () => {
|
||||||
|
try {
|
||||||
|
completed.value = await api.fetch('/redeem_api/result', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ code: code.value }),
|
||||||
|
})
|
||||||
|
codeInfo.value = null
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error.message || 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const useRedeemedAddress = async () => {
|
||||||
|
if (completed.value?.type !== 'address_prefix_once') return
|
||||||
|
jwt.value = completed.value.jwt
|
||||||
|
addressPassword.value = completed.value.password || ''
|
||||||
|
await api.getSettings()
|
||||||
|
showAddressCredential.value = true
|
||||||
|
await router.push(getRouterPathWithLang('/', locale.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const goBack = async () => {
|
||||||
|
await router.push(getRouterPathWithLang('/', locale.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadContext)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main v-if="openSettings.enableRedeemCode" class="redeem-page">
|
||||||
|
<section class="redeem-shell">
|
||||||
|
<n-card class="redeem-card" :bordered="false" embedded>
|
||||||
|
<n-button text class="back-link" @click="goBack">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon :component="ArrowBackOutlined" />
|
||||||
|
</template>
|
||||||
|
{{ t('back') }}
|
||||||
|
</n-button>
|
||||||
|
<div class="heading-copy">
|
||||||
|
<div class="heading-icon">
|
||||||
|
<n-icon :component="RedeemOutlined" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1>{{ t('title') }}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="type-section">
|
||||||
|
<div class="section-label">{{ t('categoryTitle') }}</div>
|
||||||
|
<div class="type-grid">
|
||||||
|
<div v-for="item in redeemTypes" :key="item.type" class="type-card"
|
||||||
|
:class="{ active: activeRedeemType === item.type }">
|
||||||
|
<n-icon :component="item.icon" />
|
||||||
|
<span>{{ item.title }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-divider />
|
||||||
|
<div v-if="completed" class="success-state">
|
||||||
|
<n-icon :component="CheckCircleOutlined" />
|
||||||
|
<h2>{{ t('success') }}</h2>
|
||||||
|
<p v-if="completed.type === 'role'">
|
||||||
|
{{ t('roleSuccess', { role: completed.role, email: completed.user_email }) }}
|
||||||
|
</p>
|
||||||
|
<p v-else-if="completed.type === 'send_balance'">
|
||||||
|
{{ t('balanceSuccess', { amount: completed.amount, address: completed.address }) }}
|
||||||
|
</p>
|
||||||
|
<template v-else>
|
||||||
|
<p>{{ t('addressSuccess') }}</p>
|
||||||
|
<AddressCredentialContent class="credential-result" :address="completed.address"
|
||||||
|
:jwt="completed.jwt" :address-password="completed.password" />
|
||||||
|
<n-button type="primary" block @click="useRedeemedAddress">
|
||||||
|
{{ t('openAddress') }}
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
<n-button type="primary" secondary @click="resetRedemption">
|
||||||
|
{{ t('redeemAnother') }}
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="redeem-form">
|
||||||
|
<n-form-item :label="t('codeLabel')" :show-feedback="false">
|
||||||
|
<n-input data-testid="redeem-code-input" v-model:value="code" size="large" :placeholder="t('codePlaceholder')"
|
||||||
|
@keyup.enter="queryCode" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-button v-if="!codeInfo" type="primary" size="large" block :disabled="!code.trim()"
|
||||||
|
@click="queryCode">
|
||||||
|
{{ t('checkCode') }}
|
||||||
|
</n-button>
|
||||||
|
<n-button v-if="redeemCodeUrl" data-testid="redeem-code-link" class="get-code-link"
|
||||||
|
text tag="a" target="_blank" rel="noopener noreferrer" :href="redeemCodeUrl">
|
||||||
|
{{ t('getCode') }}
|
||||||
|
</n-button>
|
||||||
|
|
||||||
|
<div v-if="codeInfo" class="redemption-detail">
|
||||||
|
<n-divider />
|
||||||
|
|
||||||
|
<template v-if="codeInfo.redeem_type === 'role'">
|
||||||
|
<div class="detail-heading">
|
||||||
|
<n-icon :component="RedeemOutlined" />
|
||||||
|
<div>
|
||||||
|
<h2>{{ t('roleTitle') }}</h2>
|
||||||
|
<p>{{ t('roleDescription', { role: codeInfo.value }) }}</p>
|
||||||
|
</div>
|
||||||
|
<n-tag :type="codeStatusType" :bordered="false">
|
||||||
|
{{ t(codeInfo.status) }}
|
||||||
|
</n-tag>
|
||||||
|
</div>
|
||||||
|
<n-button v-if="codeInfo.status === 'unused' && !redeemStarted" data-testid="redeem-now" type="primary" block
|
||||||
|
@click="redeemStarted = true">
|
||||||
|
{{ t('redeemNow') }}
|
||||||
|
</n-button>
|
||||||
|
<template v-else-if="codeInfo.status === 'unused'">
|
||||||
|
<n-form-item :label="t('userEmail')">
|
||||||
|
<n-input data-testid="redeem-user-email" v-model:value="targetUserEmail"
|
||||||
|
:placeholder="t('userEmailPlaceholder')" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-button type="primary" block :disabled="!targetUserEmail.trim()" @click="redeem">
|
||||||
|
{{ t('confirmRole') }}
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
<n-button v-else-if="codeInfo.status === 'redeemed'" data-testid="redeem-result"
|
||||||
|
type="primary" block @click="showRedeemResult">
|
||||||
|
{{ t('viewResult') }}
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="codeInfo.redeem_type === 'send_balance'">
|
||||||
|
<div class="detail-heading">
|
||||||
|
<n-icon :component="SendOutlined" />
|
||||||
|
<div>
|
||||||
|
<h2>{{ t('balanceTitle') }}</h2>
|
||||||
|
<p>{{ t('balanceDescription', { amount: codeInfo.value }) }}</p>
|
||||||
|
</div>
|
||||||
|
<n-tag :type="codeStatusType" :bordered="false">
|
||||||
|
{{ t(codeInfo.status) }}
|
||||||
|
</n-tag>
|
||||||
|
</div>
|
||||||
|
<n-button v-if="codeInfo.status === 'unused' && !redeemStarted" data-testid="redeem-now" type="primary" block
|
||||||
|
@click="redeemStarted = true">
|
||||||
|
{{ t('redeemNow') }}
|
||||||
|
</n-button>
|
||||||
|
<template v-else-if="codeInfo.status === 'unused'">
|
||||||
|
<n-form-item :label="t('targetAddress')">
|
||||||
|
<n-input data-testid="redeem-target-address" v-model:value="targetAddress"
|
||||||
|
:placeholder="t('targetAddress')" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-button type="primary" block :disabled="!targetAddress.trim()" @click="redeem">
|
||||||
|
{{ t('confirmBalance') }}
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
<n-button v-else-if="codeInfo.status === 'redeemed'" data-testid="redeem-result"
|
||||||
|
type="primary" block @click="showRedeemResult">
|
||||||
|
{{ t('viewResult') }}
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="detail-heading">
|
||||||
|
<n-icon :component="AlternateEmailOutlined" />
|
||||||
|
<div>
|
||||||
|
<h2>{{ t('prefixTitle') }}</h2>
|
||||||
|
<p>{{ codeInfo.value
|
||||||
|
? t('prefixDescription', { prefix: codeInfo.value })
|
||||||
|
: t('noPrefixDescription') }}</p>
|
||||||
|
</div>
|
||||||
|
<n-tag :type="codeStatusType" :bordered="false">
|
||||||
|
{{ t(codeInfo.status) }}
|
||||||
|
</n-tag>
|
||||||
|
</div>
|
||||||
|
<n-button v-if="codeInfo.status === 'redeemed'" data-testid="redeem-result" type="primary" block
|
||||||
|
@click="showRedeemResult">
|
||||||
|
{{ t('viewResult') }}
|
||||||
|
</n-button>
|
||||||
|
<n-form v-else-if="codeInfo.status === 'unused'">
|
||||||
|
<n-form-item :label="t('address')">
|
||||||
|
<n-input-group>
|
||||||
|
<n-input-group-label v-if="codeInfo.value">{{ codeInfo.value }}</n-input-group-label>
|
||||||
|
<n-input v-if="!openSettings.disableCustomAddressName" data-testid="redeem-address-name"
|
||||||
|
v-model:value="emailName"
|
||||||
|
:minlength="addressNameMinLength" :maxlength="addressNameMaxLength" />
|
||||||
|
<n-input v-else :value="t('autoName')" disabled />
|
||||||
|
<n-input-group-label>@</n-input-group-label>
|
||||||
|
<n-select v-model:value="emailDomain" :options="domainsOptions"
|
||||||
|
:consistent-menu-width="false" />
|
||||||
|
</n-input-group>
|
||||||
|
</n-form-item>
|
||||||
|
<n-button v-if="!openSettings.disableCustomAddressName" size="small" @click="generateName">
|
||||||
|
{{ t('randomName') }}
|
||||||
|
</n-button>
|
||||||
|
<n-form-item v-if="canUseRandomSubdomain" class="subdomain-options">
|
||||||
|
<n-radio-group v-model:value="subdomainMode">
|
||||||
|
<n-space vertical>
|
||||||
|
<n-radio value="normal">{{ t('normalDomain') }}</n-radio>
|
||||||
|
<n-radio value="random">{{ t('randomSubdomain') }}</n-radio>
|
||||||
|
<n-radio value="custom">{{ t('customSubdomain') }}</n-radio>
|
||||||
|
</n-space>
|
||||||
|
</n-radio-group>
|
||||||
|
<n-input-group v-if="subdomainMode === 'custom'">
|
||||||
|
<n-input v-model:value="customSubdomain" />
|
||||||
|
<n-input-group-label>.{{ emailDomain }}</n-input-group-label>
|
||||||
|
</n-input-group>
|
||||||
|
</n-form-item>
|
||||||
|
<n-button type="primary" block
|
||||||
|
:disabled="!emailDomain || (subdomainMode === 'custom' && !customSubdomain.trim())"
|
||||||
|
@click="createAddress">
|
||||||
|
{{ t('createAddress') }}
|
||||||
|
</n-button>
|
||||||
|
</n-form>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-card>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.redeem-page {
|
||||||
|
min-height: 70vh;
|
||||||
|
padding: 24px 18px 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-shell {
|
||||||
|
width: min(720px, 100%);
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-card {
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading-copy {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading-icon {
|
||||||
|
display: grid;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
place-items: center;
|
||||||
|
color: #18a058;
|
||||||
|
font-size: 24px;
|
||||||
|
background: rgba(24, 160, 88, 0.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading-copy h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-section {
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-card .n-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #18a058;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-card span {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-card.active {
|
||||||
|
color: #18a058;
|
||||||
|
border-color: rgba(24, 160, 88, 0.55);
|
||||||
|
background: rgba(24, 160, 88, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-card > :deep(.n-card__content) > .n-divider {
|
||||||
|
margin: 22px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-form > :deep(.n-form-item) {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-form > .n-button {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-form > .get-code-link {
|
||||||
|
width: max-content;
|
||||||
|
margin: 12px auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redemption-detail :deep(.n-button),
|
||||||
|
.success-state > .n-button {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-heading {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-heading > .n-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #18a058;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-heading > .n-tag {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-heading p,
|
||||||
|
.success-state p {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.68;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-heading h2,
|
||||||
|
.success-state h2 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subdomain-options :deep(.n-form-item-blank) {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-state {
|
||||||
|
padding: 24px 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-state > .n-icon {
|
||||||
|
color: #18a058;
|
||||||
|
font-size: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credential-result {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.redeem-page {
|
||||||
|
padding: 16px 12px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading-copy h1 {
|
||||||
|
font-size: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-card {
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 10px 5px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,12 +8,14 @@ import UserSettingsPage from './user/UserSettings.vue';
|
|||||||
import UserBar from './user/UserBar.vue';
|
import UserBar from './user/UserBar.vue';
|
||||||
import BindAddress from './user/BindAddress.vue';
|
import BindAddress from './user/BindAddress.vue';
|
||||||
import UserMailBox from './user/UserMailBox.vue';
|
import UserMailBox from './user/UserMailBox.vue';
|
||||||
|
import UserSendBox from './user/UserSendBox.vue';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
userTab, globalTabplacement, userSettings
|
userTab, globalTabplacement, userSettings, openSettings
|
||||||
} = useGlobalState()
|
} = useGlobalState()
|
||||||
|
|
||||||
const { t } = useScopedI18n('views.User')
|
const { t } = useScopedI18n('views.User')
|
||||||
|
const { t: userMailT } = useScopedI18n('views.user.UserSendBox')
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -27,6 +29,12 @@ const { t } = useScopedI18n('views.User')
|
|||||||
<n-tab-pane name="user_mail_box_tab" :tab="t('user_mail_box_tab')">
|
<n-tab-pane name="user_mail_box_tab" :tab="t('user_mail_box_tab')">
|
||||||
<UserMailBox />
|
<UserMailBox />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
|
<n-tab-pane v-if="openSettings.enableSendMail" name="user_sendbox" :tab="userMailT('sendbox')">
|
||||||
|
<UserSendBox mode="sendbox" />
|
||||||
|
</n-tab-pane>
|
||||||
|
<n-tab-pane v-if="openSettings.enableSendMail" name="user_send_mail" :tab="t('send_mail')">
|
||||||
|
<UserSendBox mode="send_mail" @sent="userTab = 'user_sendbox'" />
|
||||||
|
</n-tab-pane>
|
||||||
<n-tab-pane name="user_settings" :tab="t('user_settings')">
|
<n-tab-pane name="user_settings" :tab="t('user_settings')">
|
||||||
<UserSettingsPage />
|
<UserSettingsPage />
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ const columns = computed(() => [
|
|||||||
sortOrder: sortBy.value === 'id' ? sortOrder.value : false
|
sortOrder: sortBy.value === 'id' ? sortOrder.value : false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('name'),
|
title: t('emailAddress'),
|
||||||
key: "name",
|
key: "name",
|
||||||
sorter: true,
|
sorter: true,
|
||||||
sortOrder: sortBy.value === 'name' ? sortOrder.value : false
|
sortOrder: sortBy.value === 'name' ? sortOrder.value : false
|
||||||
@@ -479,11 +479,11 @@ onMounted(async () => {
|
|||||||
<div style="margin-top: 10px;">
|
<div style="margin-top: 10px;">
|
||||||
<AddressCredentialModal v-model:show="showEmailCredential" :address="curEmailAddress"
|
<AddressCredentialModal v-model:show="showEmailCredential" :address="curEmailAddress"
|
||||||
:jwt="curEmailCredential" />
|
:jwt="curEmailCredential" />
|
||||||
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteAccount')">
|
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteMailbox')">
|
||||||
<p>{{ t('deleteTip') }}</p>
|
<p>{{ t('deleteTip') }}</p>
|
||||||
<template #action>
|
<template #action>
|
||||||
<n-button :loading="loading" @click="deleteEmail" size="small" tertiary type="error">
|
<n-button :loading="loading" @click="deleteEmail" size="small" tertiary type="error">
|
||||||
{{ t('deleteAccount') }}
|
{{ t('deleteMailbox') }}
|
||||||
</n-button>
|
</n-button>
|
||||||
</template>
|
</template>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
|
|||||||
@@ -23,6 +23,40 @@ const result = ref("")
|
|||||||
const addressPassword = ref("")
|
const addressPassword = ref("")
|
||||||
const createdAddress = ref("")
|
const createdAddress = ref("")
|
||||||
|
|
||||||
|
const addressRegex = computed(() => {
|
||||||
|
try {
|
||||||
|
if (openSettings.value.addressRegex) {
|
||||||
|
return new RegExp(openSettings.value.addressRegex, 'g');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
message.error(`Invalid addressRegex: ${openSettings.value.addressRegex}`);
|
||||||
|
}
|
||||||
|
return /[^a-z0-9]/g;
|
||||||
|
});
|
||||||
|
|
||||||
|
const generateNameLoading = ref(false);
|
||||||
|
const generateName = async () => {
|
||||||
|
try {
|
||||||
|
generateNameLoading.value = true;
|
||||||
|
const { faker } = await import('https://esm.sh/@faker-js/faker');
|
||||||
|
emailName.value = faker.internet.email()
|
||||||
|
.split('@')[0]
|
||||||
|
.replace(/\s+/g, '.')
|
||||||
|
.replace(/\.{2,}/g, '.')
|
||||||
|
.replace(addressRegex.value, '')
|
||||||
|
.toLowerCase();
|
||||||
|
// support maxAddressLen
|
||||||
|
if (emailName.value.length > openSettings.value.maxAddressLen) {
|
||||||
|
emailName.value = emailName.value.slice(0, openSettings.value.maxAddressLen);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error.message || "error");
|
||||||
|
} finally {
|
||||||
|
generateNameLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const canUseRandomSubdomain = computed(() => {
|
const canUseRandomSubdomain = computed(() => {
|
||||||
if (!emailDomain.value) {
|
if (!emailDomain.value) {
|
||||||
return false
|
return false
|
||||||
@@ -81,15 +115,22 @@ onMounted(async () => {
|
|||||||
<n-switch v-model:value="enablePrefix" :round="false" />
|
<n-switch v-model:value="enablePrefix" :round="false" />
|
||||||
</n-form-item-row>
|
</n-form-item-row>
|
||||||
<n-form-item-row :label="t('address')">
|
<n-form-item-row :label="t('address')">
|
||||||
<n-input-group>
|
<n-spin :show="generateNameLoading" style="width: 100%;">
|
||||||
<n-input-group-label v-if="enablePrefix && openSettings.prefix">
|
<div>
|
||||||
{{ openSettings.prefix }}
|
<n-button @click="generateName" style="margin-bottom: 10px;">
|
||||||
</n-input-group-label>
|
{{ t('generateName') }}
|
||||||
<n-input v-model:value="emailName" />
|
</n-button>
|
||||||
<n-input-group-label>@</n-input-group-label>
|
<n-input-group>
|
||||||
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
|
<n-input-group-label v-if="enablePrefix && openSettings.prefix">
|
||||||
:options="openSettings.domains" />
|
{{ openSettings.prefix }}
|
||||||
</n-input-group>
|
</n-input-group-label>
|
||||||
|
<n-input v-model:value="emailName" />
|
||||||
|
<n-input-group-label>@</n-input-group-label>
|
||||||
|
<n-select v-model:value="emailDomain" :consistent-menu-width="false"
|
||||||
|
:options="openSettings.domains" />
|
||||||
|
</n-input-group>
|
||||||
|
</div>
|
||||||
|
</n-spin>
|
||||||
</n-form-item-row>
|
</n-form-item-row>
|
||||||
<n-form-item-row v-if="canUseRandomSubdomain">
|
<n-form-item-row v-if="canUseRandomSubdomain">
|
||||||
<div style="width: 100%;">
|
<div style="width: 100%;">
|
||||||
@@ -111,7 +152,7 @@ onMounted(async () => {
|
|||||||
</n-form-item-row>
|
</n-form-item-row>
|
||||||
<n-button @click="newEmail" type="primary" block :loading="loading"
|
<n-button @click="newEmail" type="primary" block :loading="loading"
|
||||||
:disabled="subdomainMode === 'custom' && !customSubdomain.trim()">
|
:disabled="subdomainMode === 'custom' && !customSubdomain.trim()">
|
||||||
{{ t('creatNewEmail') }}
|
{{ t('createEmailAddress') }}
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-card>
|
</n-card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,493 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, h, onMounted, ref, watch } from 'vue'
|
||||||
|
import { NButton, NEllipsis, NPopconfirm, NTag } from 'naive-ui'
|
||||||
|
import { useScopedI18n } from '@/i18n/app'
|
||||||
|
|
||||||
|
import { api } from '../../api'
|
||||||
|
import { useGlobalState } from '../../store'
|
||||||
|
|
||||||
|
const MAX_BATCH_SIZE = 500
|
||||||
|
const MAX_EXPORT_SIZE = 10000
|
||||||
|
|
||||||
|
const { loading, openSettings } = useGlobalState()
|
||||||
|
const { t } = useScopedI18n('views.admin.RedeemCodes')
|
||||||
|
const typeOptions = computed(() => [
|
||||||
|
{ label: t('typeRole'), value: 'role' },
|
||||||
|
{ label: t('typeBalance'), value: 'send_balance' },
|
||||||
|
{ label: t('typeAddress'), value: 'address_prefix_once' },
|
||||||
|
])
|
||||||
|
const message = useMessage()
|
||||||
|
const data = ref([])
|
||||||
|
const roleOptions = ref([])
|
||||||
|
const count = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const query = ref('')
|
||||||
|
const selectedType = ref('role')
|
||||||
|
const showEditor = ref(false)
|
||||||
|
const showExport = ref(false)
|
||||||
|
const editorMode = ref('create')
|
||||||
|
const exportType = ref('role')
|
||||||
|
const exportLimit = ref(1000)
|
||||||
|
const form = ref({})
|
||||||
|
const prefixMaxLength = computed(() => Math.max((openSettings.value.maxAddressLen || 30) - 1, 0))
|
||||||
|
let latestFetchRequest = 0
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
form.value = {
|
||||||
|
id: null,
|
||||||
|
type: selectedType.value,
|
||||||
|
count: 1,
|
||||||
|
role: '',
|
||||||
|
amount: 100,
|
||||||
|
prefix: '',
|
||||||
|
enabled: true,
|
||||||
|
expires_at: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseJson = (value) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value || '{}')
|
||||||
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseDate = (value) => {
|
||||||
|
if (!value) return null
|
||||||
|
const normalized = /(?:z|[+-]\d{2}:?\d{2})$/i.test(value) ? value : `${value}Z`
|
||||||
|
const date = new Date(normalized)
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
const requestId = ++latestFetchRequest
|
||||||
|
try {
|
||||||
|
const result = await api.fetch(
|
||||||
|
`/admin/redeem_codes?redeem_type=${encodeURIComponent(selectedType.value)}`
|
||||||
|
+ `&limit=${pageSize.value}&offset=${(page.value - 1) * pageSize.value}`
|
||||||
|
+ (query.value.trim() ? `&query=${encodeURIComponent(query.value.trim())}` : '')
|
||||||
|
)
|
||||||
|
if (requestId !== latestFetchRequest) return
|
||||||
|
data.value = result.results || []
|
||||||
|
count.value = result.count || 0
|
||||||
|
} catch (error) {
|
||||||
|
if (requestId !== latestFetchRequest) return
|
||||||
|
message.error(error.message || 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchRoleOptions = async () => {
|
||||||
|
try {
|
||||||
|
const [roles, config] = await Promise.all([
|
||||||
|
api.fetch('/admin/user_roles'),
|
||||||
|
api.fetch('/admin/worker/configs'),
|
||||||
|
])
|
||||||
|
roleOptions.value = roles
|
||||||
|
.filter((item) => item.role && item.role !== config.ADMIN_USER_ROLE)
|
||||||
|
.map((item) => ({ label: item.role, value: item.role }))
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error.message || 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = async () => {
|
||||||
|
page.value = 1
|
||||||
|
await fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
editorMode.value = 'create'
|
||||||
|
resetForm()
|
||||||
|
showEditor.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (row) => {
|
||||||
|
editorMode.value = 'edit'
|
||||||
|
form.value = {
|
||||||
|
id: row.id,
|
||||||
|
type: selectedType.value,
|
||||||
|
count: 1,
|
||||||
|
role: selectedType.value === 'role' ? row.value : '',
|
||||||
|
amount: selectedType.value === 'send_balance' ? Number(row.value) : null,
|
||||||
|
prefix: selectedType.value === 'address_prefix_once' ? row.value : '',
|
||||||
|
enabled: Boolean(row.enabled),
|
||||||
|
expires_at: parseDate(row.expires_at)?.getTime() || null,
|
||||||
|
}
|
||||||
|
showEditor.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildValue = () => {
|
||||||
|
if (form.value.type === 'role') return form.value.role.trim()
|
||||||
|
if (form.value.type === 'send_balance') return String(form.value.amount)
|
||||||
|
return form.value.prefix.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (form.value.type === 'role' && !form.value.role.trim()) {
|
||||||
|
message.error(t('roleRequired'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const value = buildValue()
|
||||||
|
if (form.value.type === 'address_prefix_once'
|
||||||
|
&& (!/^[a-z0-9]*$/i.test(value) || value.length > prefixMaxLength.value)
|
||||||
|
) {
|
||||||
|
message.error(t('invalidPrefix', { max: prefixMaxLength.value }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!form.value.expires_at || form.value.expires_at <= Date.now()) {
|
||||||
|
message.error(t('invalidExpiration'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (editorMode.value === 'create') {
|
||||||
|
if (!Number.isInteger(form.value.count)
|
||||||
|
|| form.value.count < 1
|
||||||
|
|| form.value.count > MAX_BATCH_SIZE
|
||||||
|
) {
|
||||||
|
message.error(t('countLimit', { max: MAX_BATCH_SIZE }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const result = await api.fetch('/admin/redeem_codes/batch', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
count: form.value.count,
|
||||||
|
redeem_type: form.value.type,
|
||||||
|
value,
|
||||||
|
enabled: form.value.enabled,
|
||||||
|
expires_at: new Date(form.value.expires_at).toISOString(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
downloadCreatedCodes(result.codes, {
|
||||||
|
redeem_type: form.value.type,
|
||||||
|
value,
|
||||||
|
enabled: form.value.enabled,
|
||||||
|
expires_at: new Date(form.value.expires_at).toISOString(),
|
||||||
|
})
|
||||||
|
message.success(t('createdResult', result))
|
||||||
|
} else {
|
||||||
|
await api.fetch(`/admin/redeem_codes/${form.value.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
redeem_type: form.value.type,
|
||||||
|
value,
|
||||||
|
enabled: form.value.enabled,
|
||||||
|
expires_at: new Date(form.value.expires_at).toISOString(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
message.success(t('saved'))
|
||||||
|
}
|
||||||
|
showEditor.value = false
|
||||||
|
await fetchData()
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error.message || 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = async (id) => {
|
||||||
|
try {
|
||||||
|
await api.fetch(`/admin/redeem_codes/${id}`, { method: 'DELETE' })
|
||||||
|
await fetchData()
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error.message || 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyCode = async (code) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(code)
|
||||||
|
message.success(t('copied'))
|
||||||
|
} catch {
|
||||||
|
message.error(t('copyFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const download = (content, filename, type) => {
|
||||||
|
const url = URL.createObjectURL(new Blob([content], { type }))
|
||||||
|
const anchor = document.createElement('a')
|
||||||
|
anchor.href = url
|
||||||
|
anchor.download = filename
|
||||||
|
anchor.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const csvCell = (value) => {
|
||||||
|
let text = String(value ?? '')
|
||||||
|
if (/^[=+\-@]/.test(text)) text = `'${text}`
|
||||||
|
return `"${text.replace(/"/g, '""')}"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadCreatedCodes = (codes, fields) => {
|
||||||
|
if (!Array.isArray(codes) || !codes.length) return
|
||||||
|
const columns = ['code', 'redeem_type', 'value', 'enabled', 'expires_at']
|
||||||
|
const csv = [
|
||||||
|
columns.join(','),
|
||||||
|
...codes.map((code) => columns.map((column) => (
|
||||||
|
csvCell(column === 'code' ? code : fields[column])
|
||||||
|
)).join(',')),
|
||||||
|
].join('\n')
|
||||||
|
download(`\uFEFF${csv}`, `redeem-codes-${fields.redeem_type}.csv`, 'text/csv;charset=utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (value) => parseDate(value)?.toLocaleString() || '-'
|
||||||
|
|
||||||
|
const exportList = async () => {
|
||||||
|
try {
|
||||||
|
const csv = await api.fetch(
|
||||||
|
`/admin/redeem_codes/export?redeem_type=${encodeURIComponent(exportType.value)}`
|
||||||
|
+ `&limit=${exportLimit.value}`
|
||||||
|
)
|
||||||
|
download(csv, `redeem-codes-${exportType.value}.csv`, 'text/csv;charset=utf-8')
|
||||||
|
showExport.value = false
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error.message || 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const commonColumns = computed(() => [
|
||||||
|
{
|
||||||
|
title: t('code'),
|
||||||
|
key: 'code',
|
||||||
|
width: 180,
|
||||||
|
render: (row) => h(NEllipsis, { style: 'max-width: 160px' }, { default: () => row.code }),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const businessColumns = computed(() => {
|
||||||
|
if (selectedType.value === 'role') {
|
||||||
|
return [
|
||||||
|
{ title: t('role'), key: 'role', width: 140, render: (row) => row.value || '-' },
|
||||||
|
{
|
||||||
|
title: t('redeemedUser'),
|
||||||
|
key: 'user',
|
||||||
|
width: 240,
|
||||||
|
render: (row) => parseJson(row.result).user_email || '-',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (selectedType.value === 'send_balance') {
|
||||||
|
return [
|
||||||
|
{ title: t('amount'), key: 'amount', width: 140, render: (row) => row.value || '-' },
|
||||||
|
{
|
||||||
|
title: t('targetAddress'),
|
||||||
|
key: 'address',
|
||||||
|
width: 240,
|
||||||
|
render: (row) => parseJson(row.result).address || '-',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: t('prefix'),
|
||||||
|
key: 'prefix',
|
||||||
|
width: 160,
|
||||||
|
render: (row) => row.value || t('emptyPrefix'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('resultAddress'),
|
||||||
|
key: 'address',
|
||||||
|
width: 240,
|
||||||
|
render: (row) => parseJson(row.result).address || '-',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns = computed(() => [
|
||||||
|
...commonColumns.value,
|
||||||
|
...businessColumns.value,
|
||||||
|
{
|
||||||
|
title: t('enabled'),
|
||||||
|
key: 'enabled',
|
||||||
|
width: 90,
|
||||||
|
render: (row) => h(NTag, { type: row.enabled ? 'success' : 'default', bordered: false }, {
|
||||||
|
default: () => row.enabled ? t('yes') : t('no'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('expiresAt'),
|
||||||
|
key: 'expires_at',
|
||||||
|
width: 260,
|
||||||
|
render: (row) => {
|
||||||
|
const expiration = parseDate(row.expires_at)
|
||||||
|
const expired = !expiration || expiration.getTime() <= Date.now()
|
||||||
|
return h('div', { class: 'expiration-cell' }, [
|
||||||
|
h(NTag, { type: expired ? 'error' : 'success', size: 'small', bordered: false }, {
|
||||||
|
default: () => expired ? t('expired') : t('valid'),
|
||||||
|
}),
|
||||||
|
h('span', formatTime(row.expires_at)),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ title: t('redeemedAt'), key: 'redeemed_at', width: 180, render: (row) => formatTime(row.redeemed_at) },
|
||||||
|
{
|
||||||
|
title: t('actions'),
|
||||||
|
key: 'actions',
|
||||||
|
width: 230,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (row) => h('div', { class: 'actions' }, [
|
||||||
|
h(NButton, { size: 'small', tertiary: true, onClick: () => copyCode(row.code) }, {
|
||||||
|
default: () => t('copy'),
|
||||||
|
}),
|
||||||
|
row.redeemed
|
||||||
|
? null
|
||||||
|
: h(NButton, { size: 'small', tertiary: true, onClick: () => openEdit(row) }, {
|
||||||
|
default: () => t('edit'),
|
||||||
|
}),
|
||||||
|
h(NPopconfirm, { onPositiveClick: () => remove(row.id) }, {
|
||||||
|
trigger: () => h(NButton, { size: 'small', tertiary: true, type: 'error' }, {
|
||||||
|
default: () => t('delete'),
|
||||||
|
}),
|
||||||
|
default: () => t('deleteConfirm'),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
watch([page, pageSize], fetchData)
|
||||||
|
watch(selectedType, async () => {
|
||||||
|
page.value = 1
|
||||||
|
await fetchData()
|
||||||
|
})
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([fetchData(), fetchRoleOptions()])
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="redeem-admin">
|
||||||
|
<div class="redeem-toolbar">
|
||||||
|
<div class="redeem-filters">
|
||||||
|
<n-input-group class="type-filter">
|
||||||
|
<n-input-group-label>{{ t('typeFilter') }}</n-input-group-label>
|
||||||
|
<n-select data-testid="redeem-admin-filter-type" v-model:value="selectedType"
|
||||||
|
:options="typeOptions" class="type-select" />
|
||||||
|
</n-input-group>
|
||||||
|
<n-input data-testid="redeem-admin-search" v-model:value="query"
|
||||||
|
:placeholder="t('searchPlaceholder')" class="search-input" @keyup.enter="search" />
|
||||||
|
<n-button @click="search">{{ t('search') }}</n-button>
|
||||||
|
</div>
|
||||||
|
<div class="redeem-toolbar-actions">
|
||||||
|
<n-button data-testid="redeem-admin-export" @click="exportType = selectedType; showExport = true">
|
||||||
|
{{ t('export') }}
|
||||||
|
</n-button>
|
||||||
|
<n-button data-testid="redeem-admin-create" type="primary" @click="openCreate">
|
||||||
|
{{ t('batchCreate') }}
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-data-table :columns="columns" :data="data" :bordered="false" :scroll-x="1320" />
|
||||||
|
<n-pagination v-model:page="page" v-model:page-size="pageSize" :item-count="count"
|
||||||
|
:page-sizes="[20, 50, 100]" show-size-picker />
|
||||||
|
|
||||||
|
<n-modal v-model:show="showEditor" preset="dialog"
|
||||||
|
:title="editorMode === 'create' ? t('batchCreate') : t('edit')">
|
||||||
|
<n-form label-placement="top">
|
||||||
|
<n-form-item v-if="editorMode === 'edit'" :label="t('type')">
|
||||||
|
<n-select v-model:value="form.type" :options="typeOptions" disabled />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item v-if="editorMode === 'create'" :label="t('generationCount')">
|
||||||
|
<n-input-number data-testid="redeem-admin-count" v-model:value="form.count"
|
||||||
|
:min="1" :max="MAX_BATCH_SIZE" :precision="0" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item v-if="form.type === 'role'" :label="t('role')" required>
|
||||||
|
<n-select data-testid="redeem-admin-role" v-model:value="form.role"
|
||||||
|
:options="roleOptions" filterable />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item v-else-if="form.type === 'send_balance'" :label="t('amount')">
|
||||||
|
<n-input-number v-model:value="form.amount" :min="1" :max="1000000000" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item v-else :label="t('prefix')">
|
||||||
|
<n-input v-model:value="form.prefix" :maxlength="prefixMaxLength"
|
||||||
|
:placeholder="t('emptyPrefixTip')" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item :label="t('expiresAt')" required>
|
||||||
|
<n-date-picker v-model:value="form.expires_at" type="datetime" clearable />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item :label="t('enabled')">
|
||||||
|
<n-switch v-model:value="form.enabled" />
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<template #action>
|
||||||
|
<n-button :loading="loading" type="primary" @click="save">
|
||||||
|
{{ editorMode === 'create' ? t('generate') : t('save') }}
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
|
||||||
|
<n-modal v-model:show="showExport" preset="dialog" :title="t('export')">
|
||||||
|
<n-form label-placement="top">
|
||||||
|
<n-form-item :label="t('exportLimit', { max: MAX_EXPORT_SIZE })">
|
||||||
|
<n-input-number v-model:value="exportLimit" :min="1" :max="MAX_EXPORT_SIZE" />
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<template #action>
|
||||||
|
<n-button :loading="loading" type="primary" @click="exportList">{{ t('downloadCsv') }}</n-button>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.redeem-admin {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-toolbar,
|
||||||
|
.redeem-filters,
|
||||||
|
.redeem-toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-toolbar,
|
||||||
|
.redeem-filters {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-toolbar {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-filters {
|
||||||
|
flex: 1 1 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-filter {
|
||||||
|
flex: 1 1 300px;
|
||||||
|
min-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1 1 180px;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-toolbar-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.actions) {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.expiration-cell) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,14 +2,20 @@
|
|||||||
import '@wangeditor/editor/dist/css/style.css'
|
import '@wangeditor/editor/dist/css/style.css'
|
||||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||||
import { useScopedI18n } from '@/i18n/app'
|
import { useScopedI18n } from '@/i18n/app'
|
||||||
import { onBeforeUnmount, ref, shallowRef } from 'vue'
|
import { computed, onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||||
import { useSessionStorage } from '@vueuse/core'
|
import { useSessionStorage } from '@vueuse/core'
|
||||||
|
import { SendRound } from '@vicons/material'
|
||||||
import { api } from '../../api'
|
import { api } from '../../api'
|
||||||
|
import ShadowHtmlComponent from '../../components/ShadowHtmlComponent.vue'
|
||||||
|
import { useGlobalState } from '../../store'
|
||||||
|
import { blockRemoteContent } from '../../utils/remote-content-policy'
|
||||||
|
import { sanitizeHtml } from '../../utils/sanitize-html'
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const isPreview = ref(false)
|
const isPreview = ref(false)
|
||||||
const editorRef = shallowRef()
|
const editorRef = shallowRef()
|
||||||
const sending = ref(false)
|
const sending = ref(false)
|
||||||
|
const { autoLoadRemoteImages, isDark } = useGlobalState()
|
||||||
|
|
||||||
const sendMailModel = useSessionStorage('sendMailByAdminModel', {
|
const sendMailModel = useSessionStorage('sendMailByAdminModel', {
|
||||||
fromName: "",
|
fromName: "",
|
||||||
@@ -23,11 +29,18 @@ const sendMailModel = useSessionStorage('sendMailByAdminModel', {
|
|||||||
|
|
||||||
const { t } = useScopedI18n('views.admin.SendMail')
|
const { t } = useScopedI18n('views.admin.SendMail')
|
||||||
|
|
||||||
const contentTypes = [
|
const contentTypes = computed(() => [
|
||||||
{ label: t('text'), value: 'text' },
|
{ label: t('text'), value: 'text' },
|
||||||
{ label: t('html'), value: 'html' },
|
{ label: t('html'), value: 'html' },
|
||||||
{ label: t('rich text'), value: 'rich' },
|
{ label: t('rich text'), value: 'rich' },
|
||||||
]
|
])
|
||||||
|
|
||||||
|
const previewContent = computed(() => {
|
||||||
|
const content = `${sendMailModel.value.content ?? ''}`
|
||||||
|
return autoLoadRemoteImages.value
|
||||||
|
? sanitizeHtml(content)
|
||||||
|
: blockRemoteContent(content).html
|
||||||
|
})
|
||||||
|
|
||||||
const normalizeSendMailText = (content) => {
|
const normalizeSendMailText = (content) => {
|
||||||
return content
|
return content
|
||||||
@@ -110,6 +123,7 @@ const send = async () => {
|
|||||||
contentType: 'text',
|
contentType: 'text',
|
||||||
content: "",
|
content: "",
|
||||||
}
|
}
|
||||||
|
isPreview.value = false
|
||||||
message.success(t("successSend"));
|
message.success(t("successSend"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error(error.message || "error");
|
message.error(error.message || "error");
|
||||||
@@ -146,78 +160,230 @@ const handleCreated = (editor) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="center">
|
<div class="composer-page">
|
||||||
<n-card :bordered="false" embedded>
|
<n-card class="composer-card" :bordered="false" embedded>
|
||||||
<n-flex justify="end">
|
<template #header>
|
||||||
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">{{ t('send') }}</n-button>
|
<div class="composer-title">
|
||||||
</n-flex>
|
<h2>{{ t('composeMail') }}</h2>
|
||||||
<div class="left">
|
<n-text depth="3">{{ t('adminComposeTip') }}</n-text>
|
||||||
<n-form :model="sendMailModel">
|
</div>
|
||||||
<n-form-item :label="t('fromName')" label-placement="top">
|
</template>
|
||||||
<n-input-group>
|
|
||||||
<n-input v-model:value="sendMailModel.fromName" />
|
<n-form class="composer-form" :model="sendMailModel" label-placement="top">
|
||||||
<n-input v-model:value="sendMailModel.fromMail" />
|
<n-grid cols="1 m:2" responsive="screen" :x-gap="16">
|
||||||
</n-input-group>
|
<n-grid-item>
|
||||||
</n-form-item>
|
<n-form-item :label="t('senderAddress')" required
|
||||||
<n-form-item :label="t('toName')" label-placement="top">
|
:label-props="{ for: 'admin-send-mail-sender-address' }">
|
||||||
<n-input-group>
|
<n-input v-model:value="sendMailModel.fromMail"
|
||||||
<n-input v-model:value="sendMailModel.toName" />
|
:input-props="{ id: 'admin-send-mail-sender-address' }" />
|
||||||
<n-input v-model:value="sendMailModel.toMail" />
|
</n-form-item>
|
||||||
</n-input-group>
|
</n-grid-item>
|
||||||
</n-form-item>
|
<n-grid-item>
|
||||||
<n-form-item :label="t('subject')" label-placement="top">
|
<n-form-item :label="t('senderName')"
|
||||||
<n-input v-model:value="sendMailModel.subject" />
|
:label-props="{ for: 'admin-send-mail-sender-name' }">
|
||||||
</n-form-item>
|
<n-input v-model:value="sendMailModel.fromName"
|
||||||
<n-form-item :label="t('options')" label-placement="top">
|
:input-props="{ id: 'admin-send-mail-sender-name' }" />
|
||||||
<n-radio-group v-model:value="sendMailModel.contentType">
|
</n-form-item>
|
||||||
<n-radio-button v-for="option in contentTypes" :key="option.value" :value="option.value"
|
</n-grid-item>
|
||||||
:label="option.label" />
|
<n-grid-item>
|
||||||
</n-radio-group>
|
<n-form-item :label="t('recipientAddress')" required
|
||||||
<n-button v-if="sendMailModel.contentType != 'text'" @click="isPreview = !isPreview"
|
:label-props="{ for: 'admin-send-mail-recipient-address' }">
|
||||||
style="margin-left: 10px;">
|
<n-input v-model:value="sendMailModel.toMail"
|
||||||
{{ isPreview ? t('edit') : t('preview') }}
|
:input-props="{ id: 'admin-send-mail-recipient-address' }" />
|
||||||
</n-button>
|
</n-form-item>
|
||||||
</n-form-item>
|
</n-grid-item>
|
||||||
<n-form-item :label="t('content')" label-placement="top">
|
<n-grid-item>
|
||||||
<n-card :bordered="false" embedded v-if="isPreview">
|
<n-form-item :label="t('recipientName')"
|
||||||
<div v-html="sendMailModel.content" />
|
:label-props="{ for: 'admin-send-mail-recipient-name' }">
|
||||||
</n-card>
|
<n-input v-model:value="sendMailModel.toName"
|
||||||
<div v-else-if="sendMailModel.contentType == 'rich'" style="border: 1px solid #ccc">
|
:input-props="{ id: 'admin-send-mail-recipient-name' }" />
|
||||||
<Toolbar style="border-bottom: 1px solid #ccc" :defaultConfig="toolbarConfig"
|
</n-form-item>
|
||||||
:editor="editorRef" mode="default" />
|
</n-grid-item>
|
||||||
<Editor style="height: 500px; overflow-y: hidden;" v-model="sendMailModel.content"
|
</n-grid>
|
||||||
:defaultConfig="editorConfig" mode="default" @onCreated="handleCreated" />
|
|
||||||
|
<n-form-item :label="t('subject')" required
|
||||||
|
:label-props="{ for: 'admin-send-mail-subject' }">
|
||||||
|
<n-input v-model:value="sendMailModel.subject"
|
||||||
|
:input-props="{ id: 'admin-send-mail-subject' }" />
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<div class="editor-panel">
|
||||||
|
<div class="editor-panel-header">
|
||||||
|
<n-text id="admin-send-mail-content-label" strong>{{ t('content') }} <span
|
||||||
|
class="required-mark">*</span></n-text>
|
||||||
|
<div class="editor-controls">
|
||||||
|
<n-radio-group class="format-options" v-model:value="sendMailModel.contentType"
|
||||||
|
size="small" aria-labelledby="admin-send-mail-content-label">
|
||||||
|
<n-radio-button v-for="option in contentTypes" :key="option.value"
|
||||||
|
:value="option.value" :label="option.label" />
|
||||||
|
</n-radio-group>
|
||||||
|
<n-button v-if="sendMailModel.contentType !== 'text'" tertiary size="small"
|
||||||
|
@click="isPreview = !isPreview">
|
||||||
|
{{ isPreview ? t('edit') : t('preview') }}
|
||||||
|
</n-button>
|
||||||
</div>
|
</div>
|
||||||
<n-input v-else type="textarea" v-model:value="sendMailModel.content" :autosize="{
|
</div>
|
||||||
minRows: 3
|
|
||||||
}" />
|
<div v-if="isPreview && sendMailModel.contentType !== 'text'" class="compose-preview">
|
||||||
</n-form-item>
|
<ShadowHtmlComponent :htmlContent="previewContent" :isDark="isDark" />
|
||||||
</n-form>
|
</div>
|
||||||
</div>
|
<div v-else-if="sendMailModel.contentType === 'rich'" class="rich-editor">
|
||||||
|
<Toolbar :defaultConfig="toolbarConfig" :editor="editorRef" mode="default" />
|
||||||
|
<Editor v-model="sendMailModel.content" :defaultConfig="editorConfig" mode="default"
|
||||||
|
@onCreated="handleCreated" />
|
||||||
|
</div>
|
||||||
|
<n-input v-else class="compose-textarea" type="textarea" :bordered="false"
|
||||||
|
v-model:value="sendMailModel.content" :placeholder="t('contentPlaceholder')"
|
||||||
|
:input-props="{ 'aria-label': t('content') }"
|
||||||
|
:autosize="{ minRows: 14, maxRows: 24 }" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="composer-actions">
|
||||||
|
<n-text depth="3" class="draft-status">{{ t('draftSaved') }}</n-text>
|
||||||
|
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">
|
||||||
|
<template #icon><n-icon :component="SendRound" /></template>
|
||||||
|
{{ t('send') }}
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</n-form>
|
||||||
</n-card>
|
</n-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.n-card {
|
.composer-page {
|
||||||
max-width: 800px;
|
width: 100%;
|
||||||
}
|
padding: 14px 0 24px;
|
||||||
|
|
||||||
.n-button {
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
margin-right: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.center {
|
.composer-card {
|
||||||
|
width: min(900px, 100%);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
text-align: center;
|
align-items: baseline;
|
||||||
place-items: center;
|
flex-wrap: wrap;
|
||||||
justify-content: center;
|
gap: 8px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.left {
|
.composer-title > h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-title .n-text {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-panel {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.24);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 46px;
|
||||||
|
padding: 6px 10px 6px 14px;
|
||||||
|
border-bottom: 1px solid rgba(128, 128, 128, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required-mark {
|
||||||
|
color: #d03050;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-options {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-options :deep(.n-radio-button) {
|
||||||
|
min-width: 72px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-preview {
|
||||||
|
min-height: 360px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor :deep(.w-e-toolbar) {
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor :deep(.w-e-text-container),
|
||||||
|
.rich-editor :deep(.w-e-scroll) {
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-textarea :deep(.n-input__textarea-el),
|
||||||
|
.compose-textarea :deep(.n-input__placeholder) {
|
||||||
|
line-height: 1.7;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
place-items: left;
|
}
|
||||||
justify-content: left;
|
|
||||||
|
.composer-form :deep(.n-input__input-el) {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid rgba(128, 128, 128, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.draft-status {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.composer-page {
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-panel-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-controls {
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-options {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-options :deep(.n-radio-button) {
|
||||||
|
min-width: 0;
|
||||||
|
padding-right: 8px;
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor :deep(.w-e-toolbar) {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const fetchData = async () => {
|
|||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: t('name'),
|
title: t('emailAddress'),
|
||||||
key: "name"
|
key: "name"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { ref, onMounted, computed, watch } from 'vue'
|
import { ref, onMounted, computed, watch } from 'vue'
|
||||||
import { useScopedI18n } from '@/i18n/app'
|
import { useScopedI18n } from '@/i18n/app'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { NewLabelOutlined, EmailOutlined } from '@vicons/material'
|
import { NewLabelOutlined, EmailOutlined, RedeemOutlined } from '@vicons/material'
|
||||||
|
|
||||||
import AdminContact from '../common/AdminContact.vue'
|
import AdminContact from '../common/AdminContact.vue'
|
||||||
import Turnstile from '../../components/Turnstile.vue'
|
import Turnstile from '../../components/Turnstile.vue'
|
||||||
@@ -66,6 +66,7 @@ const initLoginMethod = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const login = async () => {
|
const login = async () => {
|
||||||
|
if (openSettings.value.addressPasswordLoginOnly) loginMethod.value = 'password';
|
||||||
if (loginMethod.value === 'password') {
|
if (loginMethod.value === 'password') {
|
||||||
// Password login
|
// Password login
|
||||||
if (!loginAddress.value || !loginPassword.value) {
|
if (!loginAddress.value || !loginPassword.value) {
|
||||||
@@ -192,6 +193,10 @@ const newEmail = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openRedeemPage = async () => {
|
||||||
|
await router.push(getRouterPathWithLang('/redeem', locale.value));
|
||||||
|
};
|
||||||
|
|
||||||
const addressPrefix = computed(() => {
|
const addressPrefix = computed(() => {
|
||||||
// if user has role, return role prefix
|
// if user has role, return role prefix
|
||||||
if (userSettings.value?.user_role) {
|
if (userSettings.value?.user_role) {
|
||||||
@@ -242,6 +247,8 @@ const showNewAddressTab = computed(() => {
|
|||||||
return openSettings.value.enableUserCreateEmail;
|
return openSettings.value.enableUserCreateEmail;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(() => openSettings.value.addressPasswordLoginOnly, initLoginMethod);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!openSettings.value.domains || openSettings.value.domains.length === 0) {
|
if (!openSettings.value.domains || openSettings.value.domains.length === 0) {
|
||||||
await api.getOpenSettings(message, notification);
|
await api.getOpenSettings(message, notification);
|
||||||
@@ -279,7 +286,7 @@ onMounted(async () => {
|
|||||||
v-model:value="loginCfToken" />
|
v-model:value="loginCfToken" />
|
||||||
|
|
||||||
<div class="switch-login-button">
|
<div class="switch-login-button">
|
||||||
<n-button v-if="openSettings?.enableAddressPassword"
|
<n-button v-if="openSettings?.enableAddressPassword && !openSettings.addressPasswordLoginOnly"
|
||||||
@click="loginMethod === 'password' ? loginMethod = 'credential' : loginMethod = 'password'"
|
@click="loginMethod === 'password' ? loginMethod = 'credential' : loginMethod = 'password'"
|
||||||
type="info" quaternary size="tiny">
|
type="info" quaternary size="tiny">
|
||||||
{{ loginMethod === 'password' ? t('credentialLogin') : t('passwordLogin') }}
|
{{ loginMethod === 'password' ? t('credentialLogin') : t('passwordLogin') }}
|
||||||
@@ -298,6 +305,16 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
{{ t('getNewEmail') }}
|
{{ t('getNewEmail') }}
|
||||||
</n-button>
|
</n-button>
|
||||||
|
<div v-if="openSettings.enableRedeemCode" class="redeem-entry-section">
|
||||||
|
<n-divider />
|
||||||
|
<n-text type="info" class="redeem-entry-tip">{{ t('redeemEntryTip') }}</n-text>
|
||||||
|
<n-button data-testid="redeem-entry" block secondary strong @click="openRedeemPage">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon :component="RedeemOutlined" />
|
||||||
|
</template>
|
||||||
|
{{ t('useRedeemCode') }}
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
</n-form>
|
</n-form>
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
<n-tab-pane v-if="showNewAddressTab" name="register" :tab="t('getNewEmail')">
|
<n-tab-pane v-if="showNewAddressTab" name="register" :tab="t('getNewEmail')">
|
||||||
@@ -350,6 +367,17 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
{{ t('getNewEmail') }}
|
{{ t('getNewEmail') }}
|
||||||
</n-button>
|
</n-button>
|
||||||
|
<div v-if="openSettings.enableRedeemCode" class="redeem-entry-section">
|
||||||
|
<n-divider />
|
||||||
|
<n-text type="info" class="redeem-entry-tip">{{ t('redeemEntryTip') }}</n-text>
|
||||||
|
<n-button data-testid="redeem-entry-register" block secondary strong
|
||||||
|
@click="openRedeemPage">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon :component="RedeemOutlined" />
|
||||||
|
</template>
|
||||||
|
{{ t('useRedeemCode') }}
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
</n-form>
|
</n-form>
|
||||||
</n-spin>
|
</n-spin>
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
@@ -375,6 +403,21 @@ onMounted(async () => {
|
|||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.redeem-entry-section {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-entry-section :deep(.n-divider) {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redeem-entry-tip {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.switch-login-button {
|
.switch-login-button {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ const changePassword = async () => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="center" v-if="settings.address">
|
<div class="center" v-if="settings.address">
|
||||||
<n-card :bordered="false" embedded class="account-card">
|
<n-card :bordered="false" embedded class="account-card">
|
||||||
<n-button @click="showAddressCredential = true" type="primary" secondary block strong>
|
<n-button v-if="!openSettings.addressPasswordLoginOnly" @click="showAddressCredential = true" type="primary" secondary block strong>
|
||||||
{{ t('showAddressCredential') }}
|
{{ t('showAddressCredential') }}
|
||||||
</n-button>
|
</n-button>
|
||||||
<n-button v-if="openSettings?.enableAddressPassword" @click="showChangePassword = true" type="info" secondary block strong>
|
<n-button v-if="openSettings?.enableAddressPassword" @click="showChangePassword = true" type="info" secondary block strong>
|
||||||
@@ -113,7 +113,7 @@ const changePassword = async () => {
|
|||||||
<n-divider v-if="openSettings.enableUserDeleteEmail" />
|
<n-divider v-if="openSettings.enableUserDeleteEmail" />
|
||||||
<n-button v-if="openSettings.enableUserDeleteEmail" @click="showDeleteAccount = true" type="error" secondary
|
<n-button v-if="openSettings.enableUserDeleteEmail" @click="showDeleteAccount = true" type="error" secondary
|
||||||
block strong>
|
block strong>
|
||||||
{{ t('deleteAccount') }}
|
{{ t('deleteMailbox') }}
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-card>
|
</n-card>
|
||||||
|
|
||||||
@@ -125,11 +125,11 @@ const changePassword = async () => {
|
|||||||
</n-button>
|
</n-button>
|
||||||
</template>
|
</template>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteAccount')">
|
<n-modal v-model:show="showDeleteAccount" preset="dialog" :title="t('deleteMailbox')">
|
||||||
<p>{{ t('deleteAccountConfirm') }}</p>
|
<p>{{ t('deleteAccountConfirm') }}</p>
|
||||||
<template #action>
|
<template #action>
|
||||||
<n-button :loading="loading" @click="deleteAccount" size="small" tertiary type="error">
|
<n-button :loading="loading" @click="deleteAccount" size="small" tertiary type="error">
|
||||||
{{ t('deleteAccount') }}
|
{{ t('deleteMailbox') }}
|
||||||
</n-button>
|
</n-button>
|
||||||
</template>
|
</template>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ onMounted(async () => {
|
|||||||
<template #icon>
|
<template #icon>
|
||||||
<n-icon :component="User" />
|
<n-icon :component="User" />
|
||||||
</template>
|
</template>
|
||||||
{{ t('userLogin') }}
|
{{ t('userCenter') }}
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-card>
|
</n-card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,63 +3,39 @@ import { ref, h, computed } from 'vue';
|
|||||||
import { useLocalStorage } from '@vueuse/core';
|
import { useLocalStorage } from '@vueuse/core';
|
||||||
import { useScopedI18n } from '@/i18n/app'
|
import { useScopedI18n } from '@/i18n/app'
|
||||||
import { NPopconfirm, NButton } from 'naive-ui'
|
import { NPopconfirm, NButton } from 'naive-ui'
|
||||||
|
import { getCachedAddresses } from '../../utils/local-address-cache'
|
||||||
|
import type { CachedAddress } from '../../utils/local-address-cache'
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { useGlobalState } from '../../store'
|
import { useGlobalState } from '../../store'
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import Login from '../common/Login.vue';
|
import Login from '../common/Login.vue';
|
||||||
|
|
||||||
const { jwt } = useGlobalState()
|
const { jwt, openSettings } = useGlobalState()
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
const { t } = useScopedI18n('views.index.LocalAddress')
|
const { t } = useScopedI18n('views.index.LocalAddress')
|
||||||
|
const { t: loginT } = useScopedI18n('views.common.Login')
|
||||||
|
|
||||||
const tabValue = ref('address')
|
const tabValue = ref('address')
|
||||||
const localAddressCache = useLocalStorage("LocalAddressCache", []);
|
const localAddressCache = useLocalStorage<(string | CachedAddress)[]>("LocalAddressCache", []);
|
||||||
const data = computed(() => {
|
const data = computed(() => {
|
||||||
// @ts-ignore
|
return getCachedAddresses(localAddressCache.value).map(({ token, address, type }, index) => {
|
||||||
if (!localAddressCache.value.includes(jwt.value)) {
|
const isPasswordLogin = type === 'address_password_login';
|
||||||
// @ts-ignore
|
if (address && openSettings.value.addressPasswordLoginOnly && !isPasswordLogin) return null;
|
||||||
localAddressCache.value.push(jwt.value)
|
return {
|
||||||
}
|
address: address
|
||||||
return localAddressCache.value.map((curJwt: string) => {
|
? `${address} (${loginT(isPasswordLogin ? 'passwordLogin' : 'credentialLogin')})`
|
||||||
try {
|
: t('savedMailbox', { index: index + 1 }),
|
||||||
const payload = JSON.parse(
|
jwt: token
|
||||||
decodeURIComponent(
|
|
||||||
atob(curJwt.split(".")[1]
|
|
||||||
.replace(/-/g, "+").replace(/_/g, "/")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
valid: true,
|
|
||||||
address: payload.address,
|
|
||||||
jwt: curJwt
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
address: `invalid jwt [${curJwt}]`,
|
|
||||||
jwt: curJwt
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
}).filter(Boolean)
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const bindAddress = async () => {
|
const bindAddress = () => {
|
||||||
try {
|
tabValue.value = 'address'
|
||||||
// @ts-ignore
|
message.success(t('bindAddressSuccess'));
|
||||||
if (!localAddressCache.value.includes(jwt.value)) {
|
|
||||||
// @ts-ignore
|
|
||||||
localAddressCache.value.push(jwt.value)
|
|
||||||
}
|
|
||||||
tabValue.value = 'address'
|
|
||||||
message.success(t('bindAddressSuccess'));
|
|
||||||
} catch (error) {
|
|
||||||
message.error((error as Error).message || "error");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
@@ -96,8 +72,8 @@ const columns = [
|
|||||||
if (jwt.value === row.jwt) {
|
if (jwt.value === row.jwt) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
localAddressCache.value = localAddressCache.value.filter(
|
localAddressCache.value = getCachedAddresses(localAddressCache.value).filter(
|
||||||
(curJwt: string) => curJwt !== row.jwt
|
entry => entry.token !== row.jwt
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
import '@wangeditor/editor/dist/css/style.css'
|
import '@wangeditor/editor/dist/css/style.css'
|
||||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||||
import { useScopedI18n } from '@/i18n/app'
|
import { useScopedI18n } from '@/i18n/app'
|
||||||
import { onMounted, onBeforeUnmount, ref, shallowRef } from 'vue'
|
import { computed, onMounted, onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||||
|
import { SendRound } from '@vicons/material'
|
||||||
import AdminContact from '../common/AdminContact.vue'
|
import AdminContact from '../common/AdminContact.vue'
|
||||||
|
import ShadowHtmlComponent from '../../components/ShadowHtmlComponent.vue'
|
||||||
|
|
||||||
import { useGlobalState } from '../../store'
|
import { useGlobalState } from '../../store'
|
||||||
import { api } from '../../api'
|
import { api } from '../../api'
|
||||||
|
import { blockRemoteContent } from '../../utils/remote-content-policy'
|
||||||
|
import { sanitizeHtml } from '../../utils/sanitize-html'
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const isPreview = ref(false)
|
const isPreview = ref(false)
|
||||||
@@ -14,15 +18,25 @@ const editorRef = shallowRef()
|
|||||||
const sending = ref(false)
|
const sending = ref(false)
|
||||||
|
|
||||||
|
|
||||||
const { settings, sendMailModel, indexTab, userSettings } = useGlobalState()
|
const {
|
||||||
|
settings, sendMailModel, indexTab, userSettings,
|
||||||
|
autoLoadRemoteImages, isDark,
|
||||||
|
} = useGlobalState()
|
||||||
|
|
||||||
const { t } = useScopedI18n('views.index.SendMail')
|
const { t } = useScopedI18n('views.index.SendMail')
|
||||||
|
|
||||||
const contentTypes = [
|
const contentTypes = computed(() => [
|
||||||
{ label: t('text'), value: 'text' },
|
{ label: t('text'), value: 'text' },
|
||||||
{ label: t('html'), value: 'html' },
|
{ label: t('html'), value: 'html' },
|
||||||
{ label: t('rich text'), value: 'rich' },
|
{ label: t('rich text'), value: 'rich' },
|
||||||
]
|
])
|
||||||
|
|
||||||
|
const previewContent = computed(() => {
|
||||||
|
const content = `${sendMailModel.value.content ?? ''}`
|
||||||
|
return autoLoadRemoteImages.value
|
||||||
|
? sanitizeHtml(content)
|
||||||
|
: blockRemoteContent(content).html
|
||||||
|
})
|
||||||
|
|
||||||
const normalizeSendMailText = (content) => {
|
const normalizeSendMailText = (content) => {
|
||||||
return content
|
return content
|
||||||
@@ -157,95 +171,292 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="center" v-if="settings.address">
|
<div class="composer-page" v-if="settings.address">
|
||||||
<n-card :bordered="false" embedded>
|
<n-card class="composer-card" :bordered="false" embedded>
|
||||||
<div v-if="!settings.send_balance || settings.send_balance <= 0">
|
<template #header>
|
||||||
<n-alert type="warning" :show-icon="false" :bordered="false">
|
<div class="composer-title">
|
||||||
{{ t('requestAccessTip', { address: settings.address }) }}
|
<h2>{{ t('composeMail') }}</h2>
|
||||||
<n-button type="primary" tertiary @click="requestAccess" size="small">{{ t('requestAccess')
|
<n-text depth="3">{{ settings.address }}</n-text>
|
||||||
}}</n-button>
|
|
||||||
</n-alert>
|
|
||||||
<AdminContact />
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<n-alert type="info" :show-icon="false" :bordered="false" closable>
|
|
||||||
{{ t('send_balance') }}: {{ settings.send_balance }}
|
|
||||||
</n-alert>
|
|
||||||
<n-flex justify="end">
|
|
||||||
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">{{ t('send') }}</n-button>
|
|
||||||
</n-flex>
|
|
||||||
<div class="left">
|
|
||||||
<n-form :model="sendMailModel">
|
|
||||||
<n-form-item :label="t('fromName')" label-placement="top">
|
|
||||||
<n-input-group>
|
|
||||||
<n-input v-model:value="sendMailModel.fromName" />
|
|
||||||
<n-input :value="settings.address" disabled />
|
|
||||||
</n-input-group>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item :label="t('toName')" label-placement="top">
|
|
||||||
<n-input-group>
|
|
||||||
<n-input v-model:value="sendMailModel.toName" />
|
|
||||||
<n-input v-model:value="sendMailModel.toMail" />
|
|
||||||
</n-input-group>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item :label="t('subject')" label-placement="top">
|
|
||||||
<n-input v-model:value="sendMailModel.subject" />
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item :label="t('options')" label-placement="top">
|
|
||||||
<n-radio-group v-model:value="sendMailModel.contentType">
|
|
||||||
<n-radio-button v-for="option in contentTypes" :key="option.value" :value="option.value"
|
|
||||||
:label="option.label" />
|
|
||||||
</n-radio-group>
|
|
||||||
<n-button v-if="sendMailModel.contentType != 'text'" @click="isPreview = !isPreview"
|
|
||||||
style="margin-left: 10px;">
|
|
||||||
{{ isPreview ? t('edit') : t('preview') }}
|
|
||||||
</n-button>
|
|
||||||
</n-form-item>
|
|
||||||
<n-form-item :label="t('content')" label-placement="top">
|
|
||||||
<n-card :bordered="false" embedded v-if="isPreview">
|
|
||||||
<div v-html="sendMailModel.content" />
|
|
||||||
</n-card>
|
|
||||||
<div v-else-if="sendMailModel.contentType == 'rich'" style="border: 1px solid #ccc">
|
|
||||||
<Toolbar style="border-bottom: 1px solid #ccc" :defaultConfig="toolbarConfig"
|
|
||||||
:editor="editorRef" mode="default" />
|
|
||||||
<Editor style="height: 500px; overflow-y: hidden;" v-model="sendMailModel.content"
|
|
||||||
:defaultConfig="editorConfig" mode="default" @onCreated="handleCreated" />
|
|
||||||
</div>
|
|
||||||
<n-input v-else type="textarea" v-model:value="sendMailModel.content" :autosize="{
|
|
||||||
minRows: 3
|
|
||||||
}" />
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #header-extra>
|
||||||
|
<n-tag v-if="settings.send_balance > 0" type="success" round :bordered="false">
|
||||||
|
{{ t('send_balance') }} · {{ settings.send_balance }}
|
||||||
|
</n-tag>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="!settings.send_balance || settings.send_balance <= 0">
|
||||||
|
<div class="access-state">
|
||||||
|
<div class="access-copy">
|
||||||
|
<h3>{{ t('balanceUnavailable') }}</h3>
|
||||||
|
<p>{{ t('requestAccessTip', { address: settings.address }) }}</p>
|
||||||
|
</div>
|
||||||
|
<n-button type="primary" @click="requestAccess">{{ t('requestAccess') }}</n-button>
|
||||||
|
</div>
|
||||||
|
<div class="admin-contact"><AdminContact /></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<n-form class="composer-form" :model="sendMailModel" label-placement="top">
|
||||||
|
<n-grid cols="1 m:2" responsive="screen" :x-gap="16">
|
||||||
|
<n-grid-item>
|
||||||
|
<n-form-item :label="t('senderAddress')" :label-props="{ for: 'send-mail-sender-address' }">
|
||||||
|
<n-input :value="settings.address" readonly
|
||||||
|
:input-props="{ id: 'send-mail-sender-address' }" />
|
||||||
|
</n-form-item>
|
||||||
|
</n-grid-item>
|
||||||
|
<n-grid-item>
|
||||||
|
<n-form-item :label="t('senderName')" :label-props="{ for: 'send-mail-sender-name' }">
|
||||||
|
<n-input v-model:value="sendMailModel.fromName"
|
||||||
|
:input-props="{ id: 'send-mail-sender-name' }" />
|
||||||
|
</n-form-item>
|
||||||
|
</n-grid-item>
|
||||||
|
<n-grid-item>
|
||||||
|
<n-form-item :label="t('recipientAddress')" required
|
||||||
|
:label-props="{ for: 'send-mail-recipient-address' }">
|
||||||
|
<n-input v-model:value="sendMailModel.toMail"
|
||||||
|
:input-props="{ id: 'send-mail-recipient-address' }" />
|
||||||
|
</n-form-item>
|
||||||
|
</n-grid-item>
|
||||||
|
<n-grid-item>
|
||||||
|
<n-form-item :label="t('recipientName')" :label-props="{ for: 'send-mail-recipient-name' }">
|
||||||
|
<n-input v-model:value="sendMailModel.toName"
|
||||||
|
:input-props="{ id: 'send-mail-recipient-name' }" />
|
||||||
|
</n-form-item>
|
||||||
|
</n-grid-item>
|
||||||
|
</n-grid>
|
||||||
|
|
||||||
|
<n-form-item :label="t('subject')" required :label-props="{ for: 'send-mail-subject' }">
|
||||||
|
<n-input v-model:value="sendMailModel.subject"
|
||||||
|
:input-props="{ id: 'send-mail-subject' }" />
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<div class="editor-panel">
|
||||||
|
<div class="editor-panel-header">
|
||||||
|
<n-text id="send-mail-content-label" strong>{{ t('content') }} <span
|
||||||
|
class="required-mark">*</span></n-text>
|
||||||
|
<div class="editor-controls">
|
||||||
|
<n-radio-group class="format-options" v-model:value="sendMailModel.contentType"
|
||||||
|
size="small" aria-labelledby="send-mail-content-label">
|
||||||
|
<n-radio-button v-for="option in contentTypes" :key="option.value"
|
||||||
|
:value="option.value" :label="option.label" />
|
||||||
|
</n-radio-group>
|
||||||
|
<n-button v-if="sendMailModel.contentType !== 'text'" tertiary size="small"
|
||||||
|
@click="isPreview = !isPreview">
|
||||||
|
{{ isPreview ? t('edit') : t('preview') }}
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isPreview && sendMailModel.contentType !== 'text'" class="compose-preview">
|
||||||
|
<ShadowHtmlComponent :htmlContent="previewContent" :isDark="isDark" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="sendMailModel.contentType === 'rich'" class="rich-editor">
|
||||||
|
<Toolbar :defaultConfig="toolbarConfig" :editor="editorRef" mode="default" />
|
||||||
|
<Editor v-model="sendMailModel.content" :defaultConfig="editorConfig" mode="default"
|
||||||
|
@onCreated="handleCreated" />
|
||||||
|
</div>
|
||||||
|
<n-input v-else class="compose-textarea" type="textarea" :bordered="false"
|
||||||
|
v-model:value="sendMailModel.content" :placeholder="t('contentPlaceholder')"
|
||||||
|
:input-props="{ 'aria-label': t('content') }"
|
||||||
|
:autosize="{ minRows: 14, maxRows: 24 }" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="composer-actions">
|
||||||
|
<n-text depth="3" class="draft-status">{{ t('draftSaved') }}</n-text>
|
||||||
|
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">
|
||||||
|
<template #icon><n-icon :component="SendRound" /></template>
|
||||||
|
{{ t('send') }}
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</n-form>
|
||||||
|
</template>
|
||||||
</n-card>
|
</n-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.n-card {
|
.composer-page {
|
||||||
max-width: 800px;
|
width: 100%;
|
||||||
}
|
padding: 14px 0 24px;
|
||||||
|
|
||||||
.n-button {
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
margin-right: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.center {
|
.composer-card {
|
||||||
|
width: min(900px, 100%);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-title > h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-title .n-text {
|
||||||
|
font-size: 13px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 6px 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-copy h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-copy p {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
line-height: 1.65;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-contact {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-panel {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.24);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 46px;
|
||||||
|
padding: 6px 10px 6px 14px;
|
||||||
|
border-bottom: 1px solid rgba(128, 128, 128, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required-mark {
|
||||||
|
color: #d03050;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-options {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-options :deep(.n-radio-button) {
|
||||||
|
min-width: 72px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
place-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.left {
|
.compose-preview {
|
||||||
|
min-height: 360px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor :deep(.w-e-toolbar) {
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor :deep(.w-e-text-container) {
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor :deep(.w-e-scroll) {
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-textarea :deep(.n-input__textarea-el),
|
||||||
|
.compose-textarea :deep(.n-input__placeholder) {
|
||||||
|
line-height: 1.7;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
place-items: left;
|
|
||||||
justify-content: left;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.n-alert {
|
.composer-form :deep(.n-input__input-el) {
|
||||||
margin-bottom: 10px;
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid rgba(128, 128, 128, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.draft-status {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.composer-page {
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-state {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-card :deep(.n-card-header) {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-card :deep(.n-card-header__main) {
|
||||||
|
flex: 1 1 180px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-card :deep(.n-card-header__extra) {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-panel-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-controls {
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-options {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-options :deep(.n-radio-button) {
|
||||||
|
min-width: 0;
|
||||||
|
padding-right: 8px;
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor :deep(.w-e-toolbar) {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -56,6 +56,24 @@ const fetchMails = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleCurrentMailUnread = async () => {
|
||||||
|
if (!currentMail.value || !openSettings.value.enableMailReadStatus) return
|
||||||
|
const mail = currentMail.value
|
||||||
|
const previousValue = mail.is_unread
|
||||||
|
const isUnread = previousValue !== 1
|
||||||
|
mail.is_unread = isUnread ? 1 : 0
|
||||||
|
try {
|
||||||
|
await api.fetch(`/api/mails/${mail.id}/read`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ isUnread }),
|
||||||
|
showLoading: false
|
||||||
|
})
|
||||||
|
message.success(t('readStatusUpdated'))
|
||||||
|
} catch {
|
||||||
|
mail.is_unread = previousValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 删除邮件
|
// 删除邮件
|
||||||
const deleteMail = async () => {
|
const deleteMail = async () => {
|
||||||
if (!currentMail.value) return;
|
if (!currentMail.value) return;
|
||||||
@@ -170,7 +188,7 @@ onBeforeUnmount(() => {
|
|||||||
<SettingsFilled />
|
<SettingsFilled />
|
||||||
</n-icon>
|
</n-icon>
|
||||||
</template>
|
</template>
|
||||||
{{ t('accountSettings') }}
|
{{ t('mailboxSettings') }}
|
||||||
</n-button>
|
</n-button>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<div v-if="isFirstPage" style="text-align: center; margin-top: 12px;">
|
<div v-if="isFirstPage" style="text-align: center; margin-top: 12px;">
|
||||||
@@ -182,7 +200,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<!-- 账户设置卡片 -->
|
<!-- 账户设置卡片 -->
|
||||||
<n-card v-if="showAccountSettingsCard" :bordered="false" embedded closable
|
<n-card v-if="showAccountSettingsCard" :bordered="false" embedded closable
|
||||||
@close="showAccountSettingsCard = false" :title="t('accountSettings')">
|
@close="showAccountSettingsCard = false" :title="t('mailboxSettings')">
|
||||||
<AccountSettings />
|
<AccountSettings />
|
||||||
</n-card>
|
</n-card>
|
||||||
|
|
||||||
@@ -216,10 +234,15 @@ onBeforeUnmount(() => {
|
|||||||
<n-empty :description="t('noMails')" />
|
<n-empty :description="t('noMails')" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<h3 v-if="currentMail.subject">{{ currentMail.subject }}</h3>
|
<h3 v-if="currentMail.subject"
|
||||||
|
:class="{ 'mail-title-unread': openSettings.enableMailReadStatus && currentMail.is_unread === 1 }">
|
||||||
|
{{ currentMail.subject }}
|
||||||
|
</h3>
|
||||||
<div style="margin-top: 16px;">
|
<div style="margin-top: 16px;">
|
||||||
<MailContentRenderer :mail="currentMail" :showEMailTo="false" :showReply="false"
|
<MailContentRenderer :mail="currentMail" :showEMailTo="false" :showReply="false"
|
||||||
:enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :showSaveS3="false"
|
:enableUserDeleteEmail="openSettings.enableUserDeleteEmail" :showSaveS3="false"
|
||||||
|
:enableMailReadStatus="openSettings.enableMailReadStatus"
|
||||||
|
:onUpdateMailReadStatus="toggleCurrentMailUnread"
|
||||||
:onDelete="deleteMail" />
|
:onDelete="deleteMail" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,4 +269,19 @@ onBeforeUnmount(() => {
|
|||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mail-title-unread {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mail-title-unread::before {
|
||||||
|
display: inline-block;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
margin-right: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #2080f0;
|
||||||
|
content: '';
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,15 +6,17 @@ import { NBadge, NPopconfirm, NButton } from 'naive-ui'
|
|||||||
|
|
||||||
import { useGlobalState } from '../../store'
|
import { useGlobalState } from '../../store'
|
||||||
import { api } from '../../api'
|
import { api } from '../../api'
|
||||||
import { getRouterPathWithLang } from '../../utils'
|
import { getRouterPathWithLang, hashPassword } from '../../utils'
|
||||||
|
import AddressCredentialModal from '../../components/AddressCredentialModal.vue'
|
||||||
|
|
||||||
import Login from '../common/Login.vue';
|
import Login from '../common/Login.vue';
|
||||||
|
|
||||||
const { jwt } = useGlobalState()
|
const { jwt, openSettings, loading } = useGlobalState()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const { locale, t } = useScopedI18n('views.user.AddressManagement')
|
const { locale, t } = useScopedI18n('views.user.AddressManagement')
|
||||||
|
const { t: credentialT } = useScopedI18n('components.AddressCredentialModal')
|
||||||
|
|
||||||
const data = ref([])
|
const data = ref([])
|
||||||
const count = ref(0)
|
const count = ref(0)
|
||||||
@@ -24,11 +26,61 @@ const showTranferAddress = ref(false)
|
|||||||
const currentAddress = ref("")
|
const currentAddress = ref("")
|
||||||
const currentAddressId = ref(0)
|
const currentAddressId = ref(0)
|
||||||
const targetUserEmail = ref('')
|
const targetUserEmail = ref('')
|
||||||
|
const showAddressCredential = ref(false)
|
||||||
|
const currentAddressCredential = ref('')
|
||||||
|
const credentialAddress = ref('')
|
||||||
|
const passwordResetAddress = ref(null)
|
||||||
|
const newPassword = ref('')
|
||||||
|
const confirmPassword = ref('')
|
||||||
|
const isResettingPassword = ref(false)
|
||||||
|
const { t: accountSettingsT } = useScopedI18n('views.index.AccountSettings')
|
||||||
|
|
||||||
|
const clearPasswordResetForm = () => {
|
||||||
|
passwordResetAddress.value = null;
|
||||||
|
newPassword.value = '';
|
||||||
|
confirmPassword.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetBoundAddressPassword = async () => {
|
||||||
|
if (!passwordResetAddress.value || isResettingPassword.value) return;
|
||||||
|
if (!newPassword.value) {
|
||||||
|
message.error(t('newPasswordRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword.value !== confirmPassword.value) {
|
||||||
|
message.error(accountSettingsT('passwordMismatch'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isResettingPassword.value = true;
|
||||||
|
try {
|
||||||
|
await api.fetch(`/user_api/address/${passwordResetAddress.value.id}/reset_password`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ new_password: await hashPassword(newPassword.value) }),
|
||||||
|
});
|
||||||
|
message.success(accountSettingsT('passwordChanged'));
|
||||||
|
clearPasswordResetForm();
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error.message || 'error');
|
||||||
|
} finally {
|
||||||
|
isResettingPassword.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const showCredential = async (row) => {
|
||||||
|
try {
|
||||||
|
const { jwt: addressCredential } = await api.fetch(`/user_api/bind_address_jwt/${row.id}`)
|
||||||
|
currentAddressCredential.value = addressCredential
|
||||||
|
credentialAddress.value = row.name
|
||||||
|
showAddressCredential.value = true
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error.message || "error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const changeMailAddress = async (address_id) => {
|
const changeMailAddress = async (address_id) => {
|
||||||
try {
|
try {
|
||||||
const res = await api.fetch(`/user_api/bind_address_jwt/${address_id}`);
|
const res = await api.fetch(`/user_api/bind_address_jwt/${address_id}`);
|
||||||
message.success(t('changeMailAddress') + " " + t('success'));
|
|
||||||
if (!res.jwt) {
|
if (!res.jwt) {
|
||||||
message.error("jwt not found");
|
message.error("jwt not found");
|
||||||
return;
|
return;
|
||||||
@@ -114,7 +166,7 @@ const fetchData = async () => {
|
|||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: t('name'),
|
title: t('emailAddress'),
|
||||||
key: "name"
|
key: "name"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -146,6 +198,26 @@ const columns = [
|
|||||||
key: 'actions',
|
key: 'actions',
|
||||||
render(row) {
|
render(row) {
|
||||||
return h('div', [
|
return h('div', [
|
||||||
|
!openSettings.value.addressPasswordLoginOnly ? h(NButton,
|
||||||
|
{
|
||||||
|
tertiary: true,
|
||||||
|
type: "primary",
|
||||||
|
onClick: () => showCredential(row)
|
||||||
|
},
|
||||||
|
{ default: () => credentialT('addressCredential') }
|
||||||
|
) : null,
|
||||||
|
openSettings.value.enableAddressPassword ? h(NButton,
|
||||||
|
{
|
||||||
|
tertiary: true,
|
||||||
|
type: 'warning',
|
||||||
|
onClick: () => {
|
||||||
|
newPassword.value = '';
|
||||||
|
confirmPassword.value = '';
|
||||||
|
passwordResetAddress.value = row;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ default: () => t('resetPassword') }
|
||||||
|
) : null,
|
||||||
h(NPopconfirm,
|
h(NPopconfirm,
|
||||||
{
|
{
|
||||||
onPositiveClick: () => changeMailAddress(row.id)
|
onPositiveClick: () => changeMailAddress(row.id)
|
||||||
@@ -156,9 +228,9 @@ const columns = [
|
|||||||
tertiary: true,
|
tertiary: true,
|
||||||
type: "primary",
|
type: "primary",
|
||||||
},
|
},
|
||||||
{ default: () => t('changeMailAddress') }
|
{ default: () => t('openMailbox') }
|
||||||
),
|
),
|
||||||
default: () => `${t('changeMailAddress')}?`
|
default: () => `${t('openMailbox')}?`
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
h(NButton,
|
h(NButton,
|
||||||
@@ -185,7 +257,7 @@ const columns = [
|
|||||||
},
|
},
|
||||||
{ default: () => t('unbindAddress') }
|
{ default: () => t('unbindAddress') }
|
||||||
),
|
),
|
||||||
default: () => t('unbindAddressTip')
|
default: () => t(openSettings.value.addressPasswordLoginOnly ? 'unbindPasswordTip' : 'unbindAddressTip')
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
@@ -204,6 +276,25 @@ watch([page, pageSize], async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
|
<n-modal :show="!!passwordResetAddress" @update:show="show => { if (!show && !isResettingPassword) clearPasswordResetForm() }"
|
||||||
|
preset="dialog" :title="t('resetPassword')" :mask-closable="!isResettingPassword" :closable="!isResettingPassword">
|
||||||
|
<p>{{ passwordResetAddress?.name }}</p>
|
||||||
|
<p>{{ t('resetPasswordTip') }}</p>
|
||||||
|
<n-form @submit.prevent="resetBoundAddressPassword">
|
||||||
|
<n-form-item :label="accountSettingsT('newPassword')">
|
||||||
|
<n-input v-model:value="newPassword" type="password" show-password-on="click" :disabled="isResettingPassword" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item :label="accountSettingsT('confirmPassword')">
|
||||||
|
<n-input v-model:value="confirmPassword" type="password" show-password-on="click"
|
||||||
|
:disabled="isResettingPassword" @keyup.enter="resetBoundAddressPassword" />
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<template #action>
|
||||||
|
<n-button type="warning" :loading="isResettingPassword" @click="resetBoundAddressPassword">{{ t('resetPassword') }}</n-button>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
<AddressCredentialModal v-model:show="showAddressCredential" :address="credentialAddress"
|
||||||
|
:jwt="currentAddressCredential" />
|
||||||
<n-modal v-model:show="showTranferAddress" preset="dialog" :title="t('transferAddress')">
|
<n-modal v-model:show="showTranferAddress" preset="dialog" :title="t('transferAddress')">
|
||||||
<span>
|
<span>
|
||||||
<p>{{ t("transferAddressTip") }}</p>
|
<p>{{ t("transferAddressTip") }}</p>
|
||||||
|
|||||||
@@ -0,0 +1,491 @@
|
|||||||
|
<script setup>
|
||||||
|
import '@wangeditor/editor/dist/css/style.css'
|
||||||
|
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||||
|
import { useScopedI18n } from '@/i18n/app'
|
||||||
|
import { computed, onMounted, onBeforeUnmount, ref, shallowRef } from 'vue'
|
||||||
|
import { SendRound } from '@vicons/material'
|
||||||
|
import AdminContact from '../common/AdminContact.vue'
|
||||||
|
import ShadowHtmlComponent from '../../components/ShadowHtmlComponent.vue'
|
||||||
|
|
||||||
|
import { useGlobalState } from '../../store'
|
||||||
|
import { api } from '../../api'
|
||||||
|
import { blockRemoteContent } from '../../utils/remote-content-policy'
|
||||||
|
import { sanitizeHtml } from '../../utils/sanitize-html'
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const isPreview = ref(false)
|
||||||
|
const editorRef = shallowRef()
|
||||||
|
const sending = ref(false)
|
||||||
|
const settings = ref({
|
||||||
|
address: '',
|
||||||
|
send_balance: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
addressId: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
addressOptions: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
addressLoading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['addressScroll', 'sent', 'update:addressId'])
|
||||||
|
|
||||||
|
const {
|
||||||
|
sendMailModel, userSettings, autoLoadRemoteImages, isDark,
|
||||||
|
} = useGlobalState()
|
||||||
|
|
||||||
|
const { t } = useScopedI18n('views.index.SendMail')
|
||||||
|
|
||||||
|
const getApiPath = (path) => `/user_api/address/${props.addressId}/${path}`
|
||||||
|
|
||||||
|
const refreshSettings = async () => {
|
||||||
|
settings.value = await api.fetch(getApiPath('settings'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentTypes = computed(() => [
|
||||||
|
{ label: t('text'), value: 'text' },
|
||||||
|
{ label: t('html'), value: 'html' },
|
||||||
|
{ label: t('rich text'), value: 'rich' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const previewContent = computed(() => {
|
||||||
|
const content = `${sendMailModel.value.content ?? ''}`
|
||||||
|
return autoLoadRemoteImages.value
|
||||||
|
? sanitizeHtml(content)
|
||||||
|
: blockRemoteContent(content).html
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalizeSendMailText = (content) => {
|
||||||
|
return content
|
||||||
|
.replace(/[\u00AD\u200B-\u200D\u2060\uFEFF]/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasSendMailContent = (content, contentType) => {
|
||||||
|
if (typeof content !== 'string' || !content) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentType === 'text') {
|
||||||
|
return normalizeSendMailText(content).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.createElement('div')
|
||||||
|
container.innerHTML = content
|
||||||
|
container.querySelectorAll('script, style, noscript, template').forEach((node) => node.remove())
|
||||||
|
|
||||||
|
const plainContent = normalizeSendMailText(container.textContent ?? '')
|
||||||
|
if (plainContent.length > 0) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(container.querySelector('img, audio, video, iframe, svg, canvas, table'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
if (sending.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject = `${sendMailModel.value.subject ?? ''}`.trim()
|
||||||
|
const toMail = `${sendMailModel.value.toMail ?? ''}`.trim()
|
||||||
|
const content = `${sendMailModel.value.content ?? ''}`
|
||||||
|
|
||||||
|
if (!subject) {
|
||||||
|
message.error(t('subjectEmpty'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!toMail) {
|
||||||
|
message.error(t('toMailEmpty'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!hasSendMailContent(content, sendMailModel.value.contentType)) {
|
||||||
|
message.error(t('contentEmpty'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
from_name: sendMailModel.value.fromName,
|
||||||
|
to_name: sendMailModel.value.toName,
|
||||||
|
to_mail: toMail,
|
||||||
|
subject,
|
||||||
|
is_html: sendMailModel.value.contentType != 'text',
|
||||||
|
content,
|
||||||
|
}
|
||||||
|
|
||||||
|
sending.value = true
|
||||||
|
try {
|
||||||
|
await api.fetch(getApiPath('send_mail'),
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
sendMailModel.value = {
|
||||||
|
fromName: "",
|
||||||
|
toName: "",
|
||||||
|
toMail: "",
|
||||||
|
subject: "",
|
||||||
|
contentType: 'text',
|
||||||
|
content: "",
|
||||||
|
}
|
||||||
|
isPreview.value = false
|
||||||
|
message.success(t("successSend"));
|
||||||
|
emit('sent')
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error.message || "error");
|
||||||
|
} finally {
|
||||||
|
sending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestAccess = async () => {
|
||||||
|
try {
|
||||||
|
await api.fetch(getApiPath('request_send_mail_access'),
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
message.success(t("requestSuccess"))
|
||||||
|
await refreshSettings();
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error.message || "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolbarConfig = {
|
||||||
|
excludeKeys: ["uploadVideo"]
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorConfig = {
|
||||||
|
MENU_CONF: {
|
||||||
|
'uploadImage': {
|
||||||
|
async customUpload() {
|
||||||
|
message.error(t('tooLarge'))
|
||||||
|
},
|
||||||
|
maxFileSize: 1 * 1024 * 1024,
|
||||||
|
base64LimitSize: 1 * 1024 * 1024,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
const editor = editorRef.value
|
||||||
|
if (editor == null) return
|
||||||
|
editor.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCreated = (editor) => {
|
||||||
|
editorRef.value = editor;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// make sure user_id is fetched
|
||||||
|
if (!userSettings.value.user_id) await api.getUserSettings(message);
|
||||||
|
await refreshSettings();
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="composer-page" v-if="settings.address">
|
||||||
|
<n-card class="composer-card" :bordered="false" embedded>
|
||||||
|
<template #header>
|
||||||
|
<div class="composer-title">
|
||||||
|
<h2>{{ t('composeMail') }}</h2>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #header-extra>
|
||||||
|
<n-tag v-if="settings.send_balance > 0" type="success" round :bordered="false">
|
||||||
|
{{ t('send_balance') }} · {{ settings.send_balance }}
|
||||||
|
</n-tag>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<n-form class="composer-form" :model="sendMailModel" label-placement="top">
|
||||||
|
<n-grid cols="1 m:2" responsive="screen" :x-gap="16">
|
||||||
|
<n-grid-item>
|
||||||
|
<n-form-item :label="t('senderAddress')" :label-props="{ for: 'send-mail-sender-address' }">
|
||||||
|
<n-select class="address-picker-select" :value="addressId"
|
||||||
|
:options="addressOptions" :loading="addressLoading" filterable
|
||||||
|
@scroll="emit('addressScroll', $event)"
|
||||||
|
@update:value="emit('update:addressId', $event)" />
|
||||||
|
</n-form-item>
|
||||||
|
</n-grid-item>
|
||||||
|
<n-grid-item>
|
||||||
|
<n-form-item :label="t('senderName')" :label-props="{ for: 'send-mail-sender-name' }">
|
||||||
|
<n-input v-model:value="sendMailModel.fromName"
|
||||||
|
:input-props="{ id: 'send-mail-sender-name' }" />
|
||||||
|
</n-form-item>
|
||||||
|
</n-grid-item>
|
||||||
|
</n-grid>
|
||||||
|
|
||||||
|
<div v-if="!settings.send_balance || settings.send_balance <= 0">
|
||||||
|
<div class="access-state">
|
||||||
|
<div class="access-copy">
|
||||||
|
<h3>{{ t('balanceUnavailable') }}</h3>
|
||||||
|
<p>{{ t('requestAccessTip', { address: settings.address }) }}</p>
|
||||||
|
</div>
|
||||||
|
<n-button type="primary" @click="requestAccess">{{ t('requestAccess') }}</n-button>
|
||||||
|
</div>
|
||||||
|
<div class="admin-contact"><AdminContact /></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<n-grid cols="1 m:2" responsive="screen" :x-gap="16">
|
||||||
|
<n-grid-item>
|
||||||
|
<n-form-item :label="t('recipientAddress')" required
|
||||||
|
:label-props="{ for: 'send-mail-recipient-address' }">
|
||||||
|
<n-input v-model:value="sendMailModel.toMail"
|
||||||
|
:input-props="{ id: 'send-mail-recipient-address' }" />
|
||||||
|
</n-form-item>
|
||||||
|
</n-grid-item>
|
||||||
|
<n-grid-item>
|
||||||
|
<n-form-item :label="t('recipientName')" :label-props="{ for: 'send-mail-recipient-name' }">
|
||||||
|
<n-input v-model:value="sendMailModel.toName"
|
||||||
|
:input-props="{ id: 'send-mail-recipient-name' }" />
|
||||||
|
</n-form-item>
|
||||||
|
</n-grid-item>
|
||||||
|
</n-grid>
|
||||||
|
|
||||||
|
<n-form-item :label="t('subject')" required :label-props="{ for: 'send-mail-subject' }">
|
||||||
|
<n-input v-model:value="sendMailModel.subject"
|
||||||
|
:input-props="{ id: 'send-mail-subject' }" />
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<div class="editor-panel">
|
||||||
|
<div class="editor-panel-header">
|
||||||
|
<n-text id="send-mail-content-label" strong>{{ t('content') }} <span
|
||||||
|
class="required-mark">*</span></n-text>
|
||||||
|
<div class="editor-controls">
|
||||||
|
<n-radio-group class="format-options" v-model:value="sendMailModel.contentType"
|
||||||
|
size="small" aria-labelledby="send-mail-content-label">
|
||||||
|
<n-radio-button v-for="option in contentTypes" :key="option.value"
|
||||||
|
:value="option.value" :label="option.label" />
|
||||||
|
</n-radio-group>
|
||||||
|
<n-button v-if="sendMailModel.contentType !== 'text'" tertiary size="small"
|
||||||
|
@click="isPreview = !isPreview">
|
||||||
|
{{ isPreview ? t('edit') : t('preview') }}
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isPreview && sendMailModel.contentType !== 'text'" class="compose-preview">
|
||||||
|
<ShadowHtmlComponent :htmlContent="previewContent" :isDark="isDark" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="sendMailModel.contentType === 'rich'" class="rich-editor">
|
||||||
|
<Toolbar :defaultConfig="toolbarConfig" :editor="editorRef" mode="default" />
|
||||||
|
<Editor v-model="sendMailModel.content" :defaultConfig="editorConfig" mode="default"
|
||||||
|
@onCreated="handleCreated" />
|
||||||
|
</div>
|
||||||
|
<n-input v-else class="compose-textarea" type="textarea" :bordered="false"
|
||||||
|
v-model:value="sendMailModel.content" :placeholder="t('contentPlaceholder')"
|
||||||
|
:input-props="{ 'aria-label': t('content') }"
|
||||||
|
:autosize="{ minRows: 14, maxRows: 24 }" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="composer-actions">
|
||||||
|
<n-text depth="3" class="draft-status">{{ t('draftSaved') }}</n-text>
|
||||||
|
<n-button type="primary" :loading="sending" :disabled="sending" @click="send">
|
||||||
|
<template #icon><n-icon :component="SendRound" /></template>
|
||||||
|
{{ t('send') }}
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</n-form>
|
||||||
|
</n-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.composer-page {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 0 24px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-card {
|
||||||
|
width: min(900px, 100%);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-title > h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-title .n-text {
|
||||||
|
font-size: 13px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 6px 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-copy h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-copy p {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 8px 0 0;
|
||||||
|
line-height: 1.65;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-contact {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-panel {
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.24);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 46px;
|
||||||
|
padding: 6px 10px 6px 14px;
|
||||||
|
border-bottom: 1px solid rgba(128, 128, 128, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required-mark {
|
||||||
|
color: #d03050;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-options {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-options :deep(.n-radio-button) {
|
||||||
|
min-width: 72px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-preview {
|
||||||
|
min-height: 360px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor :deep(.w-e-toolbar) {
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor :deep(.w-e-text-container) {
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor :deep(.w-e-scroll) {
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-textarea :deep(.n-input__textarea-el),
|
||||||
|
.compose-textarea :deep(.n-input__placeholder) {
|
||||||
|
line-height: 1.7;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-form :deep(.n-input__input-el) {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid rgba(128, 128, 128, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.draft-status {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.composer-page {
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-state {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-card :deep(.n-card-header) {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-card :deep(.n-card-header__main) {
|
||||||
|
flex: 1 1 180px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-card :deep(.n-card-header__extra) {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-panel-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-controls {
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-options {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-options :deep(.n-radio-button) {
|
||||||
|
min-width: 0;
|
||||||
|
padding-right: 8px;
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-editor :deep(.w-e-toolbar) {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useScopedI18n } from '@/i18n/app'
|
||||||
|
|
||||||
|
import { api } from '../../api'
|
||||||
|
import { useGlobalState } from '../../store'
|
||||||
|
import SendBox from '../../components/SendBox.vue'
|
||||||
|
|
||||||
|
const SendMail = defineAsyncComponent(() => import('./SendMail.vue'))
|
||||||
|
|
||||||
|
const ADDRESS_PAGE_SIZE = 100
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
default: 'send_mail',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['sent'])
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const { openSettings } = useGlobalState()
|
||||||
|
const { t } = useScopedI18n('views.user.UserSendBox')
|
||||||
|
const { t: mailboxT } = useScopedI18n('views.user.UserMailBox')
|
||||||
|
|
||||||
|
const selectedAddressId = ref(null)
|
||||||
|
const addressFilter = ref(null)
|
||||||
|
const addressOptions = ref([])
|
||||||
|
const addressCount = ref(0)
|
||||||
|
const addressLoading = ref(false)
|
||||||
|
const sendboxKey = ref(0)
|
||||||
|
|
||||||
|
const hasMoreAddresses = computed(() => addressOptions.value.length < addressCount.value)
|
||||||
|
const addressFilterOptions = computed(() => addressOptions.value.map((address) => ({
|
||||||
|
label: address.label,
|
||||||
|
value: address.address,
|
||||||
|
})))
|
||||||
|
|
||||||
|
const fetchAddresses = async () => {
|
||||||
|
if (addressLoading.value || (!hasMoreAddresses.value && addressOptions.value.length > 0)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addressLoading.value = true
|
||||||
|
try {
|
||||||
|
const offset = addressOptions.value.length
|
||||||
|
const { results, count } = await api.fetch(
|
||||||
|
`/user_api/bind_address?limit=${ADDRESS_PAGE_SIZE}&offset=${offset}`
|
||||||
|
)
|
||||||
|
addressOptions.value.push(...results.map((address) => ({
|
||||||
|
label: address.name,
|
||||||
|
value: address.id,
|
||||||
|
address: address.name,
|
||||||
|
})))
|
||||||
|
if (offset === 0) {
|
||||||
|
addressCount.value = count
|
||||||
|
}
|
||||||
|
if (props.mode === 'send_mail' && !selectedAddressId.value && addressOptions.value.length > 0) {
|
||||||
|
selectedAddressId.value = addressOptions.value[0].value
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error.message || 'error')
|
||||||
|
} finally {
|
||||||
|
addressLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddressScroll = async (event) => {
|
||||||
|
const target = event.currentTarget
|
||||||
|
if (!target || target.scrollTop + target.clientHeight < target.scrollHeight - 24) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await fetchAddresses()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchSendbox = async (limit, offset) => {
|
||||||
|
return await api.fetch(
|
||||||
|
`/user_api/sendbox?limit=${limit}&offset=${offset}`
|
||||||
|
+ (addressFilter.value ? `&address=${encodeURIComponent(addressFilter.value)}` : '')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteSendboxMail = async (mailId) => {
|
||||||
|
await api.fetch(`/user_api/sendbox/${mailId}`, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const querySendbox = () => {
|
||||||
|
sendboxKey.value = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(addressFilter, querySendbox)
|
||||||
|
|
||||||
|
onMounted(fetchAddresses)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="user-send-box">
|
||||||
|
<template v-if="mode === 'send_mail'">
|
||||||
|
<n-empty v-if="!selectedAddressId" class="address-empty" :description="t('noAddress')" />
|
||||||
|
<SendMail v-else :key="selectedAddressId" v-model:address-id="selectedAddressId"
|
||||||
|
:address-options="addressOptions" :address-loading="addressLoading"
|
||||||
|
@address-scroll="handleAddressScroll" @sent="emit('sent')" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<n-input-group>
|
||||||
|
<n-select v-model:value="addressFilter" :options="addressFilterOptions" clearable
|
||||||
|
:loading="addressLoading" :placeholder="mailboxT('addressQueryTip')"
|
||||||
|
@scroll="handleAddressScroll" />
|
||||||
|
<n-button @click="querySendbox" type="primary" tertiary>
|
||||||
|
{{ mailboxT('query') }}
|
||||||
|
</n-button>
|
||||||
|
</n-input-group>
|
||||||
|
<div class="filter-spacing"></div>
|
||||||
|
<SendBox :key="sendboxKey" :fetch-mail-data="fetchSendbox" show-e-mail-from
|
||||||
|
:enable-user-delete-email="openSettings.enableUserDeleteEmail"
|
||||||
|
:delete-mail="deleteSendboxMail" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.user-send-box {
|
||||||
|
padding-top: 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-spacing {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.address-empty {
|
||||||
|
padding: 72px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
@@ -5,6 +5,7 @@ const API_PATHS = [
|
|||||||
"/admin/",
|
"/admin/",
|
||||||
"/telegram/",
|
"/telegram/",
|
||||||
"/external/",
|
"/external/",
|
||||||
|
"/redeem_api/",
|
||||||
];
|
];
|
||||||
|
|
||||||
export async function onRequest(context) {
|
export async function onRequest(context) {
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "temp-email-pages",
|
"name": "temp-email-pages",
|
||||||
"version": "1.11.1",
|
"version": "1.12.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"wrangler": "^4.124.0"
|
"wrangler": "^4.129.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
|||||||
collapsed: false,
|
collapsed: false,
|
||||||
items: [
|
items: [
|
||||||
{ text: 'Worker Variables', link: 'worker-vars' },
|
{ text: 'Worker Variables', link: 'worker-vars' },
|
||||||
|
{ text: 'Frontend Variables', link: 'frontend-vars' },
|
||||||
{ text: 'Configure Email Sending', link: 'config-send-mail' },
|
{ text: 'Configure Email Sending', link: 'config-send-mail' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -164,11 +165,13 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
|||||||
text: 'Advanced Features',
|
text: 'Advanced Features',
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
items: [
|
items: [
|
||||||
|
{ text: 'Mailbox Password Login', link: 'feature/mailbox-password-login' },
|
||||||
{ text: 'AI Email Recognition', link: 'feature/ai-extract' },
|
{ text: 'AI Email Recognition', link: 'feature/ai-extract' },
|
||||||
{ text: 'Configure Subdomain Email', link: 'feature/subdomain' },
|
{ text: 'Configure Subdomain Email', link: 'feature/subdomain' },
|
||||||
{ text: 'Configure S3 Attachments', link: 'feature/s3-attachment' },
|
{ text: 'Configure S3 Attachments', link: 'feature/s3-attachment' },
|
||||||
{ text: 'Configure WASM Email Parser', link: 'feature/mail_parser_wasm_worker' },
|
{ text: 'Configure WASM Email Parser', link: 'feature/mail_parser_wasm_worker' },
|
||||||
{ text: 'Enhance with Other Workers', link: 'feature/another-worker-enhanced' },
|
{ text: 'Enhance with Other Workers', link: 'feature/another-worker-enhanced' },
|
||||||
|
{ text: 'Configure Redemption Codes', link: 'feature/redeem-code' },
|
||||||
{ text: 'Add Google Ads', link: 'feature/google-ads.md' },
|
{ text: 'Add Google Ads', link: 'feature/google-ads.md' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
|||||||
collapsed: false,
|
collapsed: false,
|
||||||
items: [
|
items: [
|
||||||
{ text: 'Worker 变量说明', link: 'worker-vars' },
|
{ text: 'Worker 变量说明', link: 'worker-vars' },
|
||||||
|
{ text: '前端变量说明', link: 'frontend-vars' },
|
||||||
{ text: '配置发送邮件', link: 'config-send-mail' },
|
{ text: '配置发送邮件', link: 'config-send-mail' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -164,11 +165,13 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] {
|
|||||||
text: '高级功能',
|
text: '高级功能',
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
items: [
|
items: [
|
||||||
|
{ text: '邮箱密码登录', link: 'feature/mailbox-password-login' },
|
||||||
{ text: 'AI 邮件识别', link: 'feature/ai-extract' },
|
{ text: 'AI 邮件识别', link: 'feature/ai-extract' },
|
||||||
{ text: '配置子域名邮箱', link: 'feature/subdomain' },
|
{ text: '配置子域名邮箱', link: 'feature/subdomain' },
|
||||||
{ text: '配置 S3 附件', link: 'feature/s3-attachment' },
|
{ text: '配置 S3 附件', link: 'feature/s3-attachment' },
|
||||||
{ text: '配置 worker 使用 wasm 解析邮件', link: 'feature/mail_parser_wasm_worker' },
|
{ text: '配置 worker 使用 wasm 解析邮件', link: 'feature/mail_parser_wasm_worker' },
|
||||||
{ text: '配置其他 worker 增强', link: 'feature/another-worker-enhanced' },
|
{ text: '配置其他 worker 增强', link: 'feature/another-worker-enhanced' },
|
||||||
|
{ text: '配置兑换码', link: 'feature/redeem-code' },
|
||||||
{ text: '给网页增加 Google Ads', link: 'feature/google-ads.md' },
|
{ text: '给网页增加 Google Ads', link: 'feature/google-ads.md' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ Then go to the repository page `Settings` -> `Secrets and variables` -> `Actions
|
|||||||
|
|
||||||
| Name | Description |
|
| Name | Description |
|
||||||
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `FRONTEND_ENV` | Frontend configuration file used by the `Deploy Frontend` workflow. Copy the content from `frontend/.env.example`, [and modify it according to this guide](/en/guide/cli/pages.html). For separate frontend/backend deployment that talks to Worker directly, `VITE_API_BASE` should be the backend Worker API root URL, must start with `https://`, and must not include a trailing `/`. When this address is configured incorrectly, common symptoms are the `map` error or `405` API responses |
|
| `FRONTEND_ENV` | Frontend configuration file used by the `Deploy Frontend` workflow. Copy the content from `frontend/.env.example` and modify it according to [Frontend Variables](/en/guide/frontend-vars) |
|
||||||
| `FRONTEND_NAME` | The project name you created in Cloudflare Pages, can be created via [UI](https://temp-mail-docs.awsl.uk/en/guide/ui/pages.html) or [Command Line](https://temp-mail-docs.awsl.uk/en/guide/cli/pages.html) |
|
| `FRONTEND_NAME` | The project name you created in Cloudflare Pages, can be created via [UI](https://temp-mail-docs.awsl.uk/en/guide/ui/pages.html) or [Command Line](https://temp-mail-docs.awsl.uk/en/guide/cli/pages.html) |
|
||||||
| `FRONTEND_BRANCH` | (Optional) Branch for pages deployment, can be left unconfigured, defaults to `production` |
|
| `FRONTEND_BRANCH` | (Optional) Branch for pages deployment, can be left unconfigured, defaults to `production` |
|
||||||
| `PAGE_TOML` | (Optional) Used only by the `Deploy Frontend with page function` workflow. Required when using page functions to forward backend requests. Please copy the content from `pages/wrangler.toml` and modify the `service` field to your worker backend name according to actual situation. This workflow builds the frontend in Pages mode and uses same-origin requests, so it does not read `FRONTEND_ENV` |
|
| `PAGE_TOML` | (Optional) Used only by the `Deploy Frontend with page function` workflow. Required when using page functions to forward backend requests. Please copy the content from `pages/wrangler.toml` and modify the `service` field to your worker backend name according to actual situation. This workflow builds the frontend in Pages mode and uses same-origin requests, so it does not read `FRONTEND_ENV` |
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ Change `VITE_API_BASE` to the `worker` `url` created in the previous step. Do no
|
|||||||
|
|
||||||
For example: `VITE_API_BASE=https://xxx.xxx.workers.dev`
|
For example: `VITE_API_BASE=https://xxx.xxx.workers.dev`
|
||||||
|
|
||||||
|
Set the frontend default language with `VITE_DEFAULT_LANG`. Supported values are `zh`, `en`, `es`, `pt-BR`, `ja`, and `de`; an unset or invalid value falls back to `zh`.
|
||||||
|
|
||||||
|
For example: `VITE_DEFAULT_LANG=en`
|
||||||
|
|
||||||
|
See [Frontend Variables](/en/guide/frontend-vars) for other settings.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm build --emptyOutDir
|
pnpm build --emptyOutDir
|
||||||
# The first deployment will prompt you to create a project, for production branch enter production
|
# The first deployment will prompt you to create a project, for production branch enter production
|
||||||
@@ -47,6 +53,8 @@ The first deployment will prompt you to create a project. For the `production` b
|
|||||||
|
|
||||||
If your worker backend name is not `cloudflare_temp_email`, please modify `pages/wrangler.toml`.
|
If your worker backend name is not `cloudflare_temp_email`, please modify `pages/wrangler.toml`.
|
||||||
|
|
||||||
|
To set the default language, add `VITE_DEFAULT_LANG=en` to `frontend/.env.pages.local`. Same-origin requests do not require `VITE_API_BASE`; see [Frontend Variables](/en/guide/frontend-vars) for other settings.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
pnpm install
|
pnpm install
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user