Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18f8d870e8 | |||
| 6059954596 | |||
| 65bc16bd92 | |||
| 7eac123f0a | |||
| 550d40a023 | |||
| 476f1250a3 | |||
| ea7d58b610 | |||
| fbbd126ef4 | |||
| a1469da670 | |||
| 12eb9f7a7f | |||
| dd82a3b975 | |||
| a8170dfe4d | |||
| 92ff7dbfaf | |||
| a05c9db83c | |||
| 8b9250c2a7 | |||
| 969a9cb6c4 | |||
| 9edfee38ad | |||
| 72c96765ef | |||
| aa87315e94 | |||
| 8f85dc6482 | |||
| cfa84c39de | |||
| 25cbd58e1f | |||
| b8632feb86 | |||
| 89e46b5748 | |||
| 0d55200485 | |||
| 9b59629993 | |||
| 7c6b0838b5 | |||
| 2fffb27033 | |||
| 704b976812 | |||
| 39f783d3b0 | |||
| c7be61d3f0 | |||
| a46f375816 | |||
| 4ab38c6c69 | |||
| e9806b4a2a | |||
| 50d7c091bf | |||
| 21be227d7b | |||
| 68bcce8091 | |||
| 83ca0dd7d7 | |||
| 9f330fdf57 | |||
| 7f05a70f60 | |||
| 77a0fd254c | |||
| 5141917d93 | |||
| 47f782a03c | |||
| 25f54bc42f | |||
| 3afbdc4d4a | |||
| cb07d5d501 | |||
| f87ce5fa4f | |||
| eb8b999ce5 | |||
| 6a30f8ac60 | |||
| d2901dd51f | |||
| a05a7ee503 | |||
| ecc3e8a246 |
@@ -0,0 +1,48 @@
|
||||
name: Bug report / Bug 报告
|
||||
description: Report something that is not working as expected. / 报告异常或不可用的问题。
|
||||
title: "[Bug]: "
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please write in Chinese or English.
|
||||
|
||||
你可以使用中文或英文填写。
|
||||
|
||||
Please do not include passwords, tokens, API keys, verification codes, session cookies, private keys, real mailbox lists, or unreleased security vulnerability details.
|
||||
|
||||
请不要粘贴密码、Token、API Key、验证码、Session Cookie、私钥、真实邮箱列表,或未公开安全漏洞细节。
|
||||
|
||||
- type: textarea
|
||||
id: details
|
||||
attributes:
|
||||
label: Details / 详情
|
||||
description: What happened? What did you expect instead? / 发生了什么?你原本期望什么?
|
||||
placeholder: "Example / 示例:发送邮件失败,页面提示 500。"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version / 版本
|
||||
description: The LanQin Email version you are using. / 你正在使用的 LanQin Email 版本。
|
||||
placeholder: "v1.2.3"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment / 环境(可选)
|
||||
description: Add browser, OS, deployment mode, or related config only if helpful. / 如果有帮助,可以补充浏览器、系统、部署方式或相关配置。
|
||||
placeholder: |
|
||||
Browser and OS:
|
||||
Deployment: Docker / local / not sure
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs, screenshots, or steps / 日志、截图或复现步骤(可选)
|
||||
description: Redact secrets and personal data before posting. / 粘贴前请先隐藏敏感信息和个人数据。
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Security vulnerability / 安全漏洞
|
||||
url: https://github.com/zxyszx/NewSzxcn-Email/security/advisories/new
|
||||
about: Please report security vulnerabilities privately. / 请通过私密渠道报告安全漏洞,不要公开提交漏洞细节。
|
||||
- name: Community chat / 社区交流
|
||||
url: https://t.me/+EhII7MSyi3QwNDQ5
|
||||
about: For informal discussion and quick usage questions. / 适合非正式交流和快速使用问题。
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Deployment or configuration / 部署或配置问题
|
||||
description: Get help with Docker, domains, DNS, SMTP, IMAP, or runtime configuration. / 获取 Docker、域名、DNS、SMTP、IMAP 或运行配置帮助。
|
||||
title: "[Deploy]: "
|
||||
labels: ["question"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please write in Chinese or English.
|
||||
|
||||
你可以使用中文或英文填写。
|
||||
|
||||
Please do not include real passwords, tokens, API keys, private keys, full mail headers with personal data, or production mailbox lists.
|
||||
|
||||
请不要粘贴真实密码、Token、API Key、私钥、包含个人数据的完整邮件头,或生产邮箱列表。
|
||||
|
||||
- type: textarea
|
||||
id: details
|
||||
attributes:
|
||||
label: Details / 详情
|
||||
description: What are you trying to do, and what failed? / 你想完成什么?哪里失败了?
|
||||
placeholder: "Example / 示例:Docker Compose 启动后 Web 可以打开,但 SMTP 587 连接失败。"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version / 版本
|
||||
description: The LanQin Email version you are using. / 你正在使用的 LanQin Email 版本。
|
||||
placeholder: "v1.2.3"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment and config / 环境与配置(可选)
|
||||
description: Add OS, Docker version, DNS status, or sanitized `.env` lines only if helpful. / 如果有帮助,可以补充系统、Docker 版本、DNS 状态或脱敏后的 `.env` 行。
|
||||
placeholder: |
|
||||
OS:
|
||||
Docker / Compose:
|
||||
Domain or DNS status:
|
||||
Related config, redacted:
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Logs or screenshots / 日志或截图(可选)
|
||||
description: Redact secrets, mailbox addresses, Message-ID values, and IPs when needed. / 请按需隐藏密钥、邮箱地址、Message-ID 和 IP。
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Feature request / 功能建议
|
||||
description: Suggest an improvement for LanQin Email. / 提出一个改进建议。
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please write in Chinese or English.
|
||||
|
||||
你可以使用中文或英文填写。
|
||||
|
||||
- type: textarea
|
||||
id: details
|
||||
attributes:
|
||||
label: Details / 详情
|
||||
description: What do you want to improve, and why? / 你想改进什么?为什么?
|
||||
placeholder: "Example / 示例:希望管理员可以批量创建邮箱,因为目前一个个添加比较慢。"
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Question or usage help / 使用问题
|
||||
description: Ask a usage question that is not clearly a bug or feature request. / 提交不明显属于 Bug 或功能建议的使用问题。
|
||||
title: "[Question]: "
|
||||
labels: ["question"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please write in Chinese or English.
|
||||
|
||||
你可以使用中文或英文填写。
|
||||
|
||||
Please do not include secrets, private mailbox data, or full production logs.
|
||||
|
||||
请不要包含密钥、私人邮箱数据或完整生产日志。
|
||||
|
||||
- type: textarea
|
||||
id: details
|
||||
attributes:
|
||||
label: Details / 详情
|
||||
description: What are you trying to do, and where are you stuck? / 你想完成什么?卡在哪里?
|
||||
placeholder: "Example / 示例:如何给第三方客户端配置 SMTP 和 IMAP?"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Version / 版本(可选)
|
||||
description: Add the LanQin Email version if relevant. / 如相关,可以补充 LanQin Email 版本。
|
||||
placeholder: "v1.2.3"
|
||||
@@ -5,6 +5,14 @@ on:
|
||||
branches:
|
||||
- main
|
||||
types: [opened, synchronize, reopened]
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
- ".github/ISSUE_TEMPLATE/**"
|
||||
- ".github/release.yml"
|
||||
- ".ai-reviewignore"
|
||||
- "LICENSE"
|
||||
- "deploy/.env.example"
|
||||
issue_comment:
|
||||
types: [created]
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
- ".github/ISSUE_TEMPLATE/**"
|
||||
- ".github/release.yml"
|
||||
- ".ai-reviewignore"
|
||||
- "LICENSE"
|
||||
- "deploy/.env.example"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- "**/*.md"
|
||||
- "docs/**"
|
||||
- ".github/ISSUE_TEMPLATE/**"
|
||||
- ".github/release.yml"
|
||||
- ".ai-reviewignore"
|
||||
- "LICENSE"
|
||||
- "deploy/.env.example"
|
||||
|
||||
permissions:
|
||||
@@ -42,7 +53,7 @@ jobs:
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Install web dependencies
|
||||
run: pnpm install --frozen-lockfile --filter lanqin-email-web...
|
||||
run: pnpm install --frozen-lockfile --filter newszxcn-email-web...
|
||||
|
||||
- name: Check shadcn/ui usage
|
||||
run: pnpm --dir apps/web run check:shadcn
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: Docker Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
@@ -17,7 +18,6 @@ concurrency:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
PLATFORMS: linux/amd64
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
@@ -35,12 +35,12 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Install web dependencies
|
||||
run: pnpm install --frozen-lockfile --filter lanqin-email-web...
|
||||
run: pnpm install --frozen-lockfile --filter newszxcn-email-web...
|
||||
|
||||
- name: Check shadcn/ui usage
|
||||
run: pnpm --dir apps/web run check:shadcn
|
||||
@@ -73,10 +73,14 @@ jobs:
|
||||
run: |
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
version="${tag#v}"
|
||||
version="${version#.}"
|
||||
version="${version#-}"
|
||||
release_url="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${tag}"
|
||||
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||
echo "release_url=${release_url}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "tag=${tag}"
|
||||
echo "version=${version}"
|
||||
echo "release_url=${release_url}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Docker release tag: ${tag}"
|
||||
|
||||
docker:
|
||||
@@ -91,35 +95,42 @@ jobs:
|
||||
suffix: ""
|
||||
context: .
|
||||
file: ./deploy/all-in-one/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
- name: api
|
||||
suffix: -api
|
||||
context: .
|
||||
file: ./deploy/api.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
- name: web
|
||||
suffix: -web
|
||||
context: .
|
||||
file: ./deploy/web.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
- name: postfix
|
||||
suffix: -postfix
|
||||
context: ./deploy/postfix
|
||||
file: ./deploy/postfix/Dockerfile
|
||||
platforms: linux/amd64
|
||||
- name: dovecot
|
||||
suffix: -dovecot
|
||||
context: ./deploy/dovecot
|
||||
file: ./deploy/dovecot/Dockerfile
|
||||
platforms: linux/amd64
|
||||
- name: rspamd
|
||||
suffix: -rspamd
|
||||
context: ./deploy/rspamd
|
||||
file: ./deploy/rspamd/Dockerfile
|
||||
platforms: linux/amd64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: docker
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
@@ -133,9 +144,11 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
image="${REGISTRY}/${GITHUB_REPOSITORY}${{ matrix.suffix }}"
|
||||
echo "name=${image,,}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${{ needs.release.outputs.tag }}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${{ needs.release.outputs.version }}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "name=${image,,}"
|
||||
echo "tag=${{ needs.release.outputs.tag }}"
|
||||
echo "version=${{ needs.release.outputs.version }}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
@@ -148,7 +161,7 @@ jobs:
|
||||
type=raw,value=latest
|
||||
type=sha,prefix=sha-
|
||||
labels: |
|
||||
org.opencontainers.image.title=LanQin Email ${{ matrix.name }}
|
||||
org.opencontainers.image.title=NewSzxcn Email ${{ matrix.name }}
|
||||
org.opencontainers.image.version=${{ steps.image.outputs.tag }}
|
||||
|
||||
- name: Build and push
|
||||
@@ -156,9 +169,11 @@ jobs:
|
||||
with:
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.file }}
|
||||
platforms: ${{ env.PLATFORMS }}
|
||||
platforms: ${{ matrix.platforms }}
|
||||
push: true
|
||||
build-args: |
|
||||
APP_VERSION=${{ needs.release.outputs.tag }}
|
||||
APP_COMMIT=${{ github.sha }}
|
||||
VITE_APP_VERSION=${{ needs.release.outputs.tag }}
|
||||
VITE_RELEASE_URL=${{ needs.release.outputs.release_url }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
@@ -195,22 +210,24 @@ jobs:
|
||||
fi
|
||||
|
||||
if ! gh api -X POST "repos/${repo}/releases/generate-notes" "${generate_args[@]}" --jq '.body' > generated-release-notes.md; then
|
||||
echo "GitHub 自动生成更新日志失败,已回退到提交列表。" > generated-release-notes.md
|
||||
echo >> generated-release-notes.md
|
||||
if [[ -n "${previous_tag}" ]]; then
|
||||
git log --reverse --pretty=format:"- %s ([%h](${repo_url}/commit/%H))" "${previous_tag}..${tag}" >> generated-release-notes.md
|
||||
echo >> generated-release-notes.md
|
||||
echo >> generated-release-notes.md
|
||||
echo "完整更新日志: [${previous_tag}...${tag}](${repo_url}/compare/${previous_tag}...${tag})" >> generated-release-notes.md
|
||||
else
|
||||
echo "- 首个公开版本。" >> generated-release-notes.md
|
||||
echo >> generated-release-notes.md
|
||||
echo "当前提交: [${GITHUB_SHA:0:7}](${repo_url}/commit/${GITHUB_SHA})" >> generated-release-notes.md
|
||||
fi
|
||||
{
|
||||
echo "GitHub 自动生成更新日志失败,已回退到提交列表。"
|
||||
echo
|
||||
if [[ -n "${previous_tag}" ]]; then
|
||||
git log --reverse --pretty=format:"- %s ([%h](${repo_url}/commit/%H))" "${previous_tag}..${tag}"
|
||||
echo
|
||||
echo
|
||||
echo "完整更新日志: [${previous_tag}...${tag}](${repo_url}/compare/${previous_tag}...${tag})"
|
||||
else
|
||||
echo "- 首个公开版本。"
|
||||
echo
|
||||
echo "当前提交: [${GITHUB_SHA:0:7}](${repo_url}/commit/${GITHUB_SHA})"
|
||||
fi
|
||||
} > generated-release-notes.md
|
||||
fi
|
||||
|
||||
cat > release-notes.md <<EOF
|
||||
# LanQin Email ${tag}
|
||||
# NewSzxcn Email ${tag}
|
||||
|
||||
自建邮箱 Webmail 全栈方案,包含 Web、API、Postfix、Dovecot、Rspamd 等组件。
|
||||
|
||||
@@ -248,7 +265,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
tag="${{ needs.release.outputs.tag }}"
|
||||
title="LanQin Email ${tag}"
|
||||
title="NewSzxcn Email ${tag}"
|
||||
if gh release view "${tag}" >/dev/null 2>&1; then
|
||||
gh release edit "${tag}" --title "${title}" --notes-file release-notes.md --latest
|
||||
else
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# NewSzxcn Email
|
||||
|
||||
NewSzxcn Email is a self-hosted mail server with a complete Webmail client and administration console. It bundles Go, React, Postfix, Dovecot, Rspamd, and SQLite into an all-in-one Docker deployment.
|
||||
|
||||
[Releases](https://github.com/zxyszx/NewSzxcn-Email/releases) · [Chinese README](README.md)
|
||||
|
||||
## Features
|
||||
|
||||
- Webmail with compose, drafts, attachments, search, labels, folders, reminders, import, and export
|
||||
- Multiple domains and mailboxes, DKIM, DNS checks, verified forwarding, and external IMAP
|
||||
- Incoming mail rules with conditions, ordering, forwarding, moving, and bulk application
|
||||
- Administration for users, permission quotas, domains, mailboxes, messages, and send queues
|
||||
- SMTP, IMAP, POP3, Postfix, Dovecot, Rspamd, and SMTP Submission
|
||||
- Release checks, admin-only web updates, pre-update database backups, and CLI rollback
|
||||
|
||||
## One-command install
|
||||
|
||||
Debian and Ubuntu on `amd64` or `arm64` are supported.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
The installer configures `/opt/newszxcn-email`, starts the Docker services, and waits for the health check. DNS records and provider port restrictions must still be configured by the operator.
|
||||
|
||||
## Update
|
||||
|
||||
System administrators can click the version badge in the admin sidebar to review and install a GitHub release. The updater is only reachable on the internal Docker network.
|
||||
|
||||
CLI update and rollback:
|
||||
|
||||
```bash
|
||||
sudo newszxcn-email update
|
||||
sudo newszxcn-email rollback
|
||||
```
|
||||
|
||||
Useful commands:
|
||||
|
||||
```bash
|
||||
sudo newszxcn-email status
|
||||
sudo newszxcn-email logs
|
||||
sudo newszxcn-email uninstall
|
||||
```
|
||||
|
||||
The uninstall command preserves configuration, messages, and the database under `/opt/newszxcn-email`.
|
||||
|
||||
## Required ports
|
||||
|
||||
Open TCP ports `25`, `80`, `443`, `465`, `587`, `993`, and `995` as needed. Public delivery also requires correct MX, SPF, DKIM, and DMARC records.
|
||||
|
||||
## Manual source deployment
|
||||
|
||||
```bash
|
||||
git clone https://github.com/zxyszx/NewSzxcn-Email.git
|
||||
cd NewSzxcn-Email/deploy
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
@@ -1,240 +1,163 @@
|
||||
# LanQin Email
|
||||
# NewSzxcn-Email
|
||||
|
||||
[](./README.md)
|
||||
[](./README.zh-CN.md)
|
||||
NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后台的开源邮箱系统。
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
[](https://github.com/zxyszx/NewSzxcn-Email/releases)
|
||||
[](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/docker.yml)
|
||||
[](https://github.com/zxyszx/NewSzxcn-Email/actions/workflows/ci.yml)
|
||||
[](LICENSE)
|
||||
|
||||
LanQin Email is a self-hosted full-stack webmail solution. The frontend is built with React + TypeScript + shadcn/ui, the backend uses Go + SQLite, and deployment can run as a single all-in-one container with API, Web, Nginx, Postfix, Dovecot, and Rspamd integrated.
|
||||
[版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
|
||||
|
||||
Community: [Telegram group](https://t.me/+EhII7MSyi3QwNDQ5)
|
||||
## 主要功能
|
||||
|
||||
## Features
|
||||
|
||||
- **Webmail client**: multiple mailbox switching, folders, reading and composing messages, drafts, scheduled sending, attachments, search, labels, stars, move/delete, read/unread status.
|
||||
- **Mailbox enhancements**: contacts, signatures, inbox rules, sender blacklist, mail statistics, archive read messages, empty Trash/Spam.
|
||||
- **Multi-domain / multi-mailbox**: domain management, DKIM key generation, DNS record display and checks, mailbox accounts, alias forwarding, catch-all toggle.
|
||||
- **Accounts and permissions**: login/registration, session management, TOTP two-factor authentication, Cloudflare Turnstile, user self-service mailbox requests, permission groups/RBAC.
|
||||
- **Admin panel**: overview checklist, user/permission group/domain/mailbox/alias/all-message management, system settings, mail templates, SMTP testing.
|
||||
- **Mail service stack**: Postfix delivery, Dovecot IMAP/POP3, Rspamd anti-spam and DKIM signing, Maildir-to-SQLite sync.
|
||||
- **Deployment friendly**: default all-in-one single container, plus a multi-container stack for debugging Postfix/Dovecot/Rspamd.
|
||||
|
||||
## UI Preview
|
||||
|
||||
| Webmail reading and list | Compose · rich-text toolbar |
|
||||
| 模块 | 能力 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
| Switch mailboxes, folders, search, labels, stars, and message reading panel. | Rich-text toolbar supports fonts, headings, bold, italic, underline, colors, highlights, lists, alignment, quotes, code blocks, attachments, emoji, and scheduled sending. |
|
||||
| Admin panel · system overview | Third-party client configuration |
|
||||
|  |  |
|
||||
| Manage users, permission groups, domains, mailboxes, aliases, system settings, and send audits. | View IMAP / POP3 / SMTP servers, ports, security modes, and account information in one place. |
|
||||
| Webmail | 收发邮件、草稿、附件、搜索、星标、标签、自定义文件夹、稍后提醒、导入与导出 |
|
||||
| 邮箱管理 | 多邮箱切换、邮箱申请、暂停收信、账号级与邮箱级转发、外部 IMAP |
|
||||
| 收信规则 | 多条件匹配、移动、标记、删除、转发、规则排序与应用到已有邮件 |
|
||||
| 管理后台 | 账号、权限配额、域名、邮箱、转发、全部邮件、发送队列、系统设置 |
|
||||
| 邮件服务 | Postfix、Dovecot、Rspamd、DKIM、IMAP、POP3、SMTP Submission |
|
||||
| 安全 | 2FA、Turnstile、权限组、API Token、转发邮箱验证、SSRF 防护 |
|
||||
| 运维 | Docker 单镜像部署、在线检查更新、页面一键更新、自动备份、命令行回滚 |
|
||||
|
||||
## Repository Structure
|
||||
## 一键安装
|
||||
|
||||
```text
|
||||
.
|
||||
├── apps/api # Go API, SQLite schema, mail sync, and business logic
|
||||
├── apps/web # React/Vite Webmail and admin panel
|
||||
├── deploy # Docker Compose, image build, Postfix/Dovecot/Rspamd config
|
||||
└── .github/workflows # Docker image release workflows
|
||||
支持 Debian / Ubuntu 的 `amd64` 与 `arm64` 服务器。建议至少 2 核、2 GB 内存,并准备一个已解析到服务器的邮件主机名,例如 `mail.example.com`。
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
## Requirements
|
||||
脚本会自动完成:
|
||||
|
||||
### Development
|
||||
- 安装或检查 Docker Engine 与 Docker Compose v2
|
||||
- 询问邮件域名、访问地址、管理员用户名和密码
|
||||
- 创建 `/opt/newszxcn-email` 持久化目录
|
||||
- 拉取 GHCR 镜像并启动邮件服务
|
||||
- 生成后台在线更新所需的内部鉴权令牌
|
||||
- 等待 Web 与 API 健康检查通过
|
||||
|
||||
- Go 1.25+
|
||||
- Node.js 20+
|
||||
- pnpm 10.28.2 (can be enabled through corepack)
|
||||
安装完成后访问配置的 `LANQIN_PUBLIC_BASE_URL`。首次登录后,在后台添加邮件域名并按照 DNS 检测页配置记录。
|
||||
|
||||
### Deployment
|
||||
> 一键安装不会替你修改 DNS,也不能绕过云厂商对 25 端口的限制。公网收信前必须确认 25 端口可入站,公网发信前需确认 25 端口可出站。
|
||||
|
||||
- Docker Engine
|
||||
- Docker Compose v2
|
||||
- A resolvable mail domain, plus available ports such as 25 / 465 / 587 / 993 / 995
|
||||
## 更新与回滚
|
||||
|
||||
> Public email sending and receiving also requires correct MX, SPF, DKIM, and DMARC records, and you should confirm that your cloud provider does not block SMTP ports.
|
||||
### 后台页面更新
|
||||
|
||||
## Quick Start
|
||||
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
|
||||
|
||||
### Local Development
|
||||
更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。
|
||||
|
||||
Backend:
|
||||
### 命令行更新
|
||||
|
||||
```bash
|
||||
sudo newszxcn-email update
|
||||
```
|
||||
|
||||
命令行更新会保留当前镜像、备份数据库并执行健康检查。需要回滚时运行:
|
||||
|
||||
```bash
|
||||
sudo newszxcn-email rollback
|
||||
```
|
||||
|
||||
常用运维命令:
|
||||
|
||||
```bash
|
||||
sudo newszxcn-email status
|
||||
sudo newszxcn-email logs
|
||||
sudo newszxcn-email uninstall
|
||||
```
|
||||
|
||||
`uninstall` 只移除容器,不删除 `/opt/newszxcn-email` 中的配置、数据库与邮件。
|
||||
|
||||
## DNS 与端口
|
||||
|
||||
至少需要以下 DNS 记录:
|
||||
|
||||
| 类型 | 示例 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| A / AAAA | `mail.example.com -> 服务器 IP` | 邮件主机与 Webmail |
|
||||
| MX | `example.com -> mail.example.com` | 接收邮件 |
|
||||
| SPF TXT | 后台生成 | 声明允许发信的服务器 |
|
||||
| DKIM TXT | 后台按域名生成 | 邮件签名验证 |
|
||||
| DMARC TXT | 后台生成建议值 | 发信策略与报告 |
|
||||
|
||||
服务器防火墙和云安全组应按需开放:
|
||||
|
||||
| 端口 | 协议 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| 25 | TCP | SMTP 服务器间收发信 |
|
||||
| 80 / 443 | TCP | Webmail 与证书签发 |
|
||||
| 465 / 587 | TCP | 邮件客户端 SMTP 发信 |
|
||||
| 993 | TCP | IMAP SSL |
|
||||
| 995 | TCP | POP3 SSL |
|
||||
|
||||
## 数据目录
|
||||
|
||||
默认部署目录为 `/opt/newszxcn-email`:
|
||||
|
||||
```text
|
||||
/opt/newszxcn-email/
|
||||
|-- .env # 环境配置与内部更新令牌
|
||||
|-- docker-compose.yml # 邮箱主服务与内部更新服务
|
||||
|-- data/ # SQLite、附件和更新前备份
|
||||
|-- mail/ # Maildir 邮件原文
|
||||
`-- dkim/ # DKIM 私钥
|
||||
```
|
||||
|
||||
升级和重建容器不会删除这些目录。备份时应同时保存 `data`、`mail`、`dkim` 与 `.env`。
|
||||
|
||||
## 手动部署
|
||||
|
||||
需要自行控制 Compose 配置时:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/zxyszx/NewSzxcn-Email.git
|
||||
cd NewSzxcn-Email/deploy
|
||||
cp .env.example .env
|
||||
# 编辑 .env
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
本地源码构建:
|
||||
|
||||
```bash
|
||||
cd deploy
|
||||
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
```
|
||||
|
||||
更完整的证书、外部 SMTP、Webhook 和排错说明见 [deploy/README.md](deploy/README.md)。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 后端:Go、Chi、SQLite
|
||||
- 前端:React、TypeScript、TanStack Query、shadcn/ui、Tailwind CSS
|
||||
- 邮件:Postfix、Dovecot、Rspamd
|
||||
- 部署:Docker、Docker Compose、GitHub Actions、GHCR
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
cd apps/api
|
||||
go mod download
|
||||
go test ./...
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
Frontend (new terminal):
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.28.2 --activate
|
||||
pnpm install
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
Open:
|
||||
|
||||
- Web: `http://localhost:5173`
|
||||
- API: `http://localhost:8080`
|
||||
|
||||
The default admin email is `admin@lanqin.local`. For development, explicitly set `LANQIN_ADMIN_PASSWORD`; if it is not set, the backend generates a random password on first startup and prints it to the logs.
|
||||
|
||||
### Docker Deployment (single container)
|
||||
|
||||
A server only needs the Compose files and configuration under `deploy/`; building from source is not required:
|
||||
提交前建议运行:
|
||||
|
||||
```bash
|
||||
cd deploy
|
||||
cp .env.example .env
|
||||
# Edit .env: domain, public URL, admin email, admin password, etc.
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
cd apps/api && go test ./...
|
||||
cd apps/web && pnpm run check
|
||||
```
|
||||
|
||||
Common commands:
|
||||
## 开源协议
|
||||
|
||||
```bash
|
||||
# View logs
|
||||
docker compose logs -f lanqin-email
|
||||
|
||||
# Pull the latest image and restart
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
|
||||
# Stop services
|
||||
docker compose down
|
||||
```
|
||||
|
||||
To build the image locally from the full source repository:
|
||||
|
||||
```bash
|
||||
cd deploy
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
```
|
||||
|
||||
See [`deploy/README.md`](./deploy/README.md) for more deployment details.
|
||||
|
||||
## First Deployment Checklist
|
||||
|
||||
1. Edit `deploy/.env`: at minimum, change `LANQIN_PUBLIC_HOSTNAME`, `LANQIN_PUBLIC_BASE_URL`, `LANQIN_ADMIN_EMAIL`, and `LANQIN_ADMIN_PASSWORD`.
|
||||
2. In production, mount real TLS certificates and set `LANQIN_TLS_CERT_FILE` / `LANQIN_TLS_KEY_FILE`.
|
||||
3. Log in to the admin panel and add your mail domain.
|
||||
4. Copy and configure MX, SPF, DKIM, and DMARC records from domain management, then run the DNS check.
|
||||
5. Create mailbox accounts, alias forwarding, or permission groups; enable registration, 2FA, Turnstile, and self-service mailbox requests as needed.
|
||||
6. Use the admin SMTP test and Webmail send/receive tests to confirm the full path works.
|
||||
|
||||
## Key Environment Variables
|
||||
|
||||
See [`deploy/.env.example`](./deploy/.env.example) for the full configuration. Common variables:
|
||||
|
||||
| Variable | Description | Default / Example |
|
||||
|------|------|-----------|
|
||||
| `LANQIN_IMAGE` | All-in-one image | `ghcr.io/lanqin996/lanqin-email:latest` |
|
||||
| `LANQIN_PUBLIC_HOSTNAME` | Mail server hostname; affects Postfix/DNS display/links | `mail.example.com` |
|
||||
| `LANQIN_PUBLIC_BASE_URL` | Public Webmail URL | `https://mail.example.com` |
|
||||
| `LANQIN_ADMIN_EMAIL` | Initial admin email | `admin@example.com` |
|
||||
| `LANQIN_ADMIN_PASSWORD` | Initial admin password; must be changed in production | `ChangeMe123!` |
|
||||
| `LANQIN_DB_PATH` | SQLite database path | `/data/lanqin.db` |
|
||||
| `LANQIN_ALLOW_INSECURE_HTTP` | Allow non-HTTPS cookies; useful for local debugging | `false` |
|
||||
| `LANQIN_OPEN_REGISTRATION` | Enable public registration | `false` |
|
||||
| `LANQIN_TWO_FACTOR_ENABLED` | Global 2FA feature toggle | `false` |
|
||||
| `LANQIN_TURNSTILE_ENABLED` | Enable Turnstile | `false` |
|
||||
| `LANQIN_SMTP_HOST` / `LANQIN_SMTP_PORT` | Webmail outbound SMTP | `127.0.0.1` / `25` |
|
||||
| `LANQIN_MAILDIR_ROOT` | Maildir root directory | `/var/mail/vhosts` |
|
||||
| `LANQIN_CATCH_ALL_ENABLED` | Whether unregistered recipient addresses go into all messages | `false` |
|
||||
| `LANQIN_USER_MAILBOX_APPLY_ENABLED` | Allow users to request mailboxes by themselves | `false` |
|
||||
| `LANQIN_EXTERNAL_IMAP_ENABLED` | Enable external IMAP access; also configurable in Admin > System Settings > External IMAP | `false` |
|
||||
| `LANQIN_EXTERNAL_IMAP_SECRET_KEY` | Encryption key for external IMAP passwords; required before enabling access; also configurable in admin | Random long string |
|
||||
| `LANQIN_EXTERNAL_IMAP_SYNC_SECONDS` | Sync interval for external IMAP local-storage mode; also configurable in admin | `300` |
|
||||
| `LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS` | Allow external IMAP to connect to private/localhost hosts; also configurable in admin | `false` |
|
||||
| `LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_ID` / `LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET` | Gmail external IMAP OAuth2; callback is `/api/external-imap-oauth/gmail/callback` | Empty |
|
||||
| `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID` / `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET` | Microsoft 365 / Outlook external IMAP OAuth2; callback is `/api/external-imap-oauth/outlook/callback` | Empty |
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ lanqin-email single container │
|
||||
│ │
|
||||
│ ┌─────────┐ ┌────────────┐ ┌──────────────┐ │
|
||||
│ │ Nginx │ ───▶ │ Go API │ ───▶ │ SQLite /data │ │
|
||||
│ │ Web │ │ Webmail API│ └──────┬───────┘ │
|
||||
│ │ static │ └─────┬──────┘ │ │
|
||||
│ └─────────┘ │ Maildir sync │ maps │
|
||||
│ ┌─────────┐ ┌─────▼──────┐ ┌──────▼───────┐ │
|
||||
│ │ Rspamd │ ◀───▶ │ Postfix │ ───▶ │ Dovecot/LMTP │ │
|
||||
│ │ DKIM/AS │ │ SMTP/MTA │ │ IMAP/POP3 │ │
|
||||
│ └─────────┘ └────────────┘ └──────────────┘ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Mail flow:
|
||||
|
||||
1. **Receiving**: Postfix receives mail → Rspamd scores/marks it → Dovecot writes to Maildir → API worker syncs it into SQLite → Webmail displays it.
|
||||
2. **Sending**: Webmail calls the API → API builds MIME → SMTP submits to Postfix or an external SMTP server → mail is delivered to the destination.
|
||||
3. **Local delivery**: In development, internal mailboxes can send directly into the recipient Inbox; if `LANQIN_SMTP_HOST` is not configured, external recipients are not actually delivered.
|
||||
4. **Third-party clients**: Connect with SMTP 465/587, IMAP 993, or POP3 995; in production, configure certificates that match `LANQIN_PUBLIC_HOSTNAME`.
|
||||
5. **External mailbox access**: Users can add external IMAP accounts in personal mailbox management. Local-storage mode syncs mail into the database; remote-direct mode reads from the remote server each time and does not write into local mail tables.
|
||||
|
||||
## Development and Verification
|
||||
|
||||
```bash
|
||||
# API tests
|
||||
cd apps/api
|
||||
go test ./...
|
||||
|
||||
# Web checks and build
|
||||
cd apps/web
|
||||
pnpm run check
|
||||
|
||||
# Single-container source build verification
|
||||
cd deploy
|
||||
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
```
|
||||
|
||||
## Production Notes
|
||||
|
||||
- In production, always change the default admin password and protect `.env`, the SQLite database, Maildir, and DKIM private keys.
|
||||
- The Web UI can sit behind host Nginx / aaPanel / an edge gateway, but SMTP/IMAP/POP3 certificates must be mounted separately for Postfix/Dovecot inside the container.
|
||||
- Cloud providers often block port 25 by default; if public email does not send or receive, first check ports, security groups, firewalls, and reverse DNS.
|
||||
- SQLite is suitable for single-node deployments; before multi-node deployment, migrate the database and adjust Postfix/Dovecot query configuration accordingly.
|
||||
|
||||
## SMTP Submission
|
||||
|
||||
- Third-party client SMTP submission on `465/587` is handled by the LanQin API process.
|
||||
- Before enabling SMTP submission, configure `LANQIN_TLS_CERT_FILE` / `LANQIN_TLS_KEY_FILE`; the API will not expose 465/587 externally with a localhost self-signed certificate.
|
||||
- Postfix only keeps port `25` for public inbound mail and internal/external relay.
|
||||
- Webmail/API and third-party client sends are first written into Sent, then enter the send queue.
|
||||
- The send queue is relayed by a LanQin API background worker to `LANQIN_SMTP_HOST:LANQIN_SMTP_PORT`; failures are audited and retried with backoff.
|
||||
- v1 supports sending from the user's own mailbox. For send-as, use an enabled alias forwarding source that points to the user's mailbox, or configure `send_as_grants` in the database.
|
||||
- If the client later writes its own Sent copy through IMAP APPEND, Maildir sync deduplicates by `Message-ID` within the Sent folder.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](./LICENSE)
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=LanQin996%2FLanQin-Email&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
Friends: [LINUX DO](https://linux.do/) — a new ideal community
|
||||
[MIT](LICENSE)
|
||||
|
||||
@@ -1,242 +1,158 @@
|
||||
# LanQin Email
|
||||
# NewSzxcn-Email
|
||||
|
||||
[](./README.md)
|
||||
[](./README.zh-CN.md)
|
||||
NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后台的开源邮箱系统。
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
[版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
|
||||
|
||||
LanQin Email 是一个自建邮箱 Webmail 全栈方案:前端使用 React + TypeScript + shadcn/ui,后端使用 Go + SQLite,部署时可用单容器集成 API、Web、Nginx、Postfix、Dovecot、Rspamd。
|
||||
## 主要功能
|
||||
|
||||
交流群组:[Telegram 群组](https://t.me/+EhII7MSyi3QwNDQ5)
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **Webmail 客户端**:多邮箱切换、文件夹、邮件读写、草稿、定时发送、附件、搜索、标签、星标、移动/删除、已读/未读。
|
||||
- **邮箱增强**:联系人、签名、收件规则、发件人黑名单、邮件统计、归档已读、清空回收站/垃圾邮件。
|
||||
- **多域名/多邮箱**:域名管理、DKIM 密钥生成、DNS 记录展示与检测、邮箱账号、别名转发、无人收件开关。
|
||||
- **账号与权限**:登录/注册、会话管理、TOTP 两步验证、Cloudflare Turnstile、用户自助申请邮箱、权限组/RBAC。
|
||||
- **管理员面板**:概览清单、用户/权限组/域名/邮箱/别名/全部邮件管理、系统设置、邮件模板、SMTP 测试。
|
||||
- **邮件服务栈**:Postfix 投递、Dovecot IMAP/POP3、Rspamd 反垃圾与 DKIM 签名、Maildir 到 SQLite 同步。
|
||||
- **部署友好**:默认 all-in-one 单容器,也提供多容器 stack 方便调试 Postfix/Dovecot/Rspamd。
|
||||
|
||||
## 界面预览
|
||||
|
||||
| Webmail 邮件阅读与列表 | 写邮件 · 富文本编辑工具栏 |
|
||||
| 模块 | 能力 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
| 多邮箱切换、文件夹、搜索、标签、星标与邮件阅读面板。 | 富文本工具栏支持字体、标题、加粗、斜体、下划线、颜色、高亮、列表、对齐、引用、代码块、附件、表情与定时发送。 |
|
||||
| 管理后台 · 系统概览 | 第三方客户端配置 |
|
||||
|  |  |
|
||||
| 管理用户、权限组、域名、邮箱、别名、系统设置与发送审计。 | 一键查看 IMAP / POP3 / SMTP 服务器、端口、安全方式与账号信息。 |
|
||||
| Webmail | 收发邮件、草稿、附件、搜索、星标、标签、自定义文件夹、稍后提醒、导入与导出 |
|
||||
| 邮箱管理 | 多邮箱切换、邮箱申请、暂停收信、账号级与邮箱级转发、外部 IMAP |
|
||||
| 收信规则 | 多条件匹配、移动、标记、删除、转发、规则排序与应用到已有邮件 |
|
||||
| 管理后台 | 账号、权限配额、域名、邮箱、转发、全部邮件、发送队列、系统设置 |
|
||||
| 邮件服务 | Postfix、Dovecot、Rspamd、DKIM、IMAP、POP3、SMTP Submission |
|
||||
| 安全 | 2FA、Turnstile、权限组、API Token、转发邮箱验证、SSRF 防护 |
|
||||
| 运维 | Docker 单镜像部署、在线检查更新、页面一键更新、自动备份、命令行回滚 |
|
||||
|
||||
## 目录结构
|
||||
## 一键安装
|
||||
|
||||
```text
|
||||
.
|
||||
├── apps/api # Go API、SQLite schema、邮件同步与业务逻辑
|
||||
├── apps/web # React/Vite Webmail 与管理后台
|
||||
├── deploy # Docker Compose、镜像构建、Postfix/Dovecot/Rspamd 配置
|
||||
└── .github/workflows # Docker 镜像发布流水线
|
||||
支持 Debian / Ubuntu 的 `amd64` 与 `arm64` 服务器。建议至少 2 核、2 GB 内存,并准备一个已解析到服务器的邮件主机名,例如 `mail.example.com`。
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
## 环境要求
|
||||
脚本会自动完成:
|
||||
|
||||
### 开发环境
|
||||
- 安装或检查 Docker Engine 与 Docker Compose v2
|
||||
- 询问邮件域名、访问地址、管理员邮箱和密码
|
||||
- 创建 `/opt/newszxcn-email` 持久化目录
|
||||
- 拉取 GHCR 镜像并启动邮件服务
|
||||
- 生成后台在线更新所需的内部鉴权令牌
|
||||
- 等待 Web 与 API 健康检查通过
|
||||
|
||||
- Go 1.25+
|
||||
- Node.js 20+
|
||||
- pnpm 10.28.2(可通过 corepack 启用)
|
||||
安装完成后访问配置的 `LANQIN_PUBLIC_BASE_URL`。首次登录后,在后台添加邮件域名并按照 DNS 检测页配置记录。
|
||||
|
||||
### 部署环境
|
||||
> 一键安装不会替你修改 DNS,也不能绕过云厂商对 25 端口的限制。公网收信前必须确认 25 端口可入站,公网发信前需确认 25 端口可出站。
|
||||
|
||||
- Docker Engine
|
||||
- Docker Compose v2
|
||||
- 可解析的邮件域名,以及可用的 25 / 465 / 587 / 993 / 995 等端口
|
||||
## 更新与回滚
|
||||
|
||||
> 公网收发邮件还需要正确配置 MX、SPF、DKIM、DMARC,并确认云厂商未封禁 SMTP 端口。
|
||||
### 后台页面更新
|
||||
|
||||
## 快速开始
|
||||
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
|
||||
|
||||
### 本地开发
|
||||
更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。
|
||||
|
||||
后端:
|
||||
### 命令行更新
|
||||
|
||||
```bash
|
||||
sudo newszxcn-email update
|
||||
```
|
||||
|
||||
命令行更新会保留当前镜像、备份数据库并执行健康检查。需要回滚时运行:
|
||||
|
||||
```bash
|
||||
sudo newszxcn-email rollback
|
||||
```
|
||||
|
||||
常用运维命令:
|
||||
|
||||
```bash
|
||||
sudo newszxcn-email status
|
||||
sudo newszxcn-email logs
|
||||
sudo newszxcn-email uninstall
|
||||
```
|
||||
|
||||
`uninstall` 只移除容器,不删除 `/opt/newszxcn-email` 中的配置、数据库与邮件。
|
||||
|
||||
## DNS 与端口
|
||||
|
||||
至少需要以下 DNS 记录:
|
||||
|
||||
| 类型 | 示例 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| A / AAAA | `mail.example.com -> 服务器 IP` | 邮件主机与 Webmail |
|
||||
| MX | `example.com -> mail.example.com` | 接收邮件 |
|
||||
| SPF TXT | 后台生成 | 声明允许发信的服务器 |
|
||||
| DKIM TXT | 后台按域名生成 | 邮件签名验证 |
|
||||
| DMARC TXT | 后台生成建议值 | 发信策略与报告 |
|
||||
|
||||
服务器防火墙和云安全组应按需开放:
|
||||
|
||||
| 端口 | 协议 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| 25 | TCP | SMTP 服务器间收发信 |
|
||||
| 80 / 443 | TCP | Webmail 与证书签发 |
|
||||
| 465 / 587 | TCP | 邮件客户端 SMTP 发信 |
|
||||
| 993 | TCP | IMAP SSL |
|
||||
| 995 | TCP | POP3 SSL |
|
||||
|
||||
## 数据目录
|
||||
|
||||
默认部署目录为 `/opt/newszxcn-email`:
|
||||
|
||||
```text
|
||||
/opt/newszxcn-email/
|
||||
├── .env # 环境配置与内部更新令牌
|
||||
├── docker-compose.yml # 邮箱主服务与内部更新服务
|
||||
├── data/ # SQLite、附件和更新前备份
|
||||
├── mail/ # Maildir 邮件原文
|
||||
└── dkim/ # DKIM 私钥
|
||||
```
|
||||
|
||||
升级和重建容器不会删除这些目录。备份时应同时保存 `data`、`mail`、`dkim` 与 `.env`。
|
||||
|
||||
## 手动部署
|
||||
|
||||
需要自行控制 Compose 配置时:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/zxyszx/NewSzxcn-Email.git
|
||||
cd NewSzxcn-Email/deploy
|
||||
cp .env.example .env
|
||||
# 编辑 .env
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
本地源码构建:
|
||||
|
||||
```bash
|
||||
cd deploy
|
||||
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
```
|
||||
|
||||
更完整的证书、外部 SMTP、Webhook 和排错说明见 [deploy/README.md](deploy/README.md)。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 后端:Go、Chi、SQLite
|
||||
- 前端:React、TypeScript、TanStack Query、shadcn/ui、Tailwind CSS
|
||||
- 邮件:Postfix、Dovecot、Rspamd
|
||||
- 部署:Docker、Docker Compose、GitHub Actions、GHCR
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
cd apps/api
|
||||
go mod download
|
||||
go test ./...
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
前端(新终端):
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.28.2 --activate
|
||||
pnpm install
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
访问:
|
||||
|
||||
- Web:`http://localhost:5173`
|
||||
- API:`http://localhost:8080`
|
||||
|
||||
默认管理员邮箱为 `admin@lanqin.local`。建议开发时显式设置 `LANQIN_ADMIN_PASSWORD`;如果未设置,后端首次启动会随机生成密码并输出到日志。
|
||||
|
||||
### Docker 部署(单容器)
|
||||
|
||||
服务器只需要 `deploy/` 下的 Compose 文件和配置,不需要源码构建:
|
||||
提交前建议运行:
|
||||
|
||||
```bash
|
||||
cd deploy
|
||||
cp .env.example .env
|
||||
# 修改 .env:域名、访问地址、管理员邮箱、管理员密码等
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
cd apps/api && go test ./...
|
||||
cd apps/web && pnpm run build
|
||||
```
|
||||
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
# 查看日志
|
||||
docker compose logs -f lanqin-email
|
||||
|
||||
# 更新镜像并重启
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
|
||||
# 停止服务
|
||||
docker compose down
|
||||
```
|
||||
|
||||
如需在完整源码仓库中本地构建镜像:
|
||||
|
||||
```bash
|
||||
cd deploy
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
```
|
||||
|
||||
更多部署细节见 [`deploy/README.md`](./deploy/README.md)。
|
||||
|
||||
## 首次部署清单
|
||||
|
||||
1. 编辑 `deploy/.env`:至少修改 `LANQIN_PUBLIC_HOSTNAME`、`LANQIN_PUBLIC_BASE_URL`、`LANQIN_ADMIN_EMAIL`、`LANQIN_ADMIN_PASSWORD`。
|
||||
2. 生产环境建议挂载真实 TLS 证书,并设置 `LANQIN_TLS_CERT_FILE` / `LANQIN_TLS_KEY_FILE`。
|
||||
3. 登录管理后台,添加邮件域名。
|
||||
4. 在域名管理中复制并配置 MX、SPF、DKIM、DMARC 记录,然后点击 DNS 检测。
|
||||
5. 创建邮箱账号、别名转发或权限组,按需开启注册、2FA、Turnstile、自助申请邮箱。
|
||||
6. 使用后台 SMTP 测试与 Webmail 收发测试确认链路正常。
|
||||
|
||||
## 关键环境变量
|
||||
|
||||
完整配置见 [`deploy/.env.example`](./deploy/.env.example)。常用变量如下:
|
||||
|
||||
| 变量 | 说明 | 默认/示例 |
|
||||
|------|------|-----------|
|
||||
| `LANQIN_IMAGE` | all-in-one 镜像 | `ghcr.io/lanqin996/lanqin-email:latest` |
|
||||
| `LANQIN_PUBLIC_HOSTNAME` | 邮件服务器主机名,影响 Postfix/DNS 展示/链接 | `mail.example.com` |
|
||||
| `LANQIN_PUBLIC_BASE_URL` | Webmail 对外访问地址 | `https://mail.example.com` |
|
||||
| `LANQIN_ADMIN_EMAIL` | 初始管理员邮箱 | `admin@example.com` |
|
||||
| `LANQIN_ADMIN_PASSWORD` | 初始管理员密码,生产必须修改 | `ChangeMe123!` |
|
||||
| `LANQIN_DB_PATH` | SQLite 数据库路径 | `/data/lanqin.db` |
|
||||
| `LANQIN_ALLOW_INSECURE_HTTP` | 是否允许非 HTTPS Cookie,本地调试可开 | `false` |
|
||||
| `LANQIN_OPEN_REGISTRATION` | 是否开放注册 | `false` |
|
||||
| `LANQIN_TWO_FACTOR_ENABLED` | 2FA 功能总开关 | `false` |
|
||||
| `LANQIN_TURNSTILE_ENABLED` | 是否启用 Turnstile | `false` |
|
||||
| `LANQIN_SMTP_HOST` / `LANQIN_SMTP_PORT` | Webmail 发信 SMTP | `127.0.0.1` / `25` |
|
||||
| `LANQIN_MAILDIR_ROOT` | Maildir 根目录 | `/var/mail/vhosts` |
|
||||
| `LANQIN_CATCH_ALL_ENABLED` | 未注册收件地址是否进入全部邮件 | `false` |
|
||||
| `LANQIN_USER_MAILBOX_APPLY_ENABLED` | 是否允许用户自助申请邮箱 | `false` |
|
||||
| `LANQIN_EXTERNAL_IMAP_ENABLED` | 是否启用外部 IMAP 接入;也可在后台“系统设置 > 外部 IMAP”配置 | `false` |
|
||||
| `LANQIN_EXTERNAL_IMAP_SECRET_KEY` | 外部 IMAP 密码加密密钥,启用接入前必须设置;也可在后台配置 | 随机长字符串 |
|
||||
| `LANQIN_EXTERNAL_IMAP_SYNC_SECONDS` | 外部 IMAP 本地存储模式同步间隔;也可在后台配置 | `300` |
|
||||
| `LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS` | 是否允许外部 IMAP 连接内网/localhost 主机;也可在后台配置 | `false` |
|
||||
| `LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_ID` / `LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET` | Gmail 外部 IMAP OAuth2,回调为 `/api/external-imap-oauth/gmail/callback` | 空 |
|
||||
| `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID` / `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET` | Microsoft 365 / Outlook 外部 IMAP OAuth2,回调为 `/api/external-imap-oauth/outlook/callback` | 空 |
|
||||
|
||||
## 架构
|
||||
|
||||
```text
|
||||
┌────────────────────────────────────────────────────────────┐
|
||||
│ lanqin-email 单容器 │
|
||||
│ │
|
||||
│ ┌─────────┐ ┌────────────┐ ┌──────────────┐ │
|
||||
│ │ Nginx │ ───▶ │ Go API │ ───▶ │ SQLite /data │ │
|
||||
│ │ Web 静态│ │ Webmail API│ └──────┬───────┘ │
|
||||
│ └─────────┘ └─────┬──────┘ │ │
|
||||
│ │ Maildir sync │ maps │
|
||||
│ ┌─────────┐ ┌─────▼──────┐ ┌──────▼───────┐ │
|
||||
│ │ Rspamd │ ◀───▶ │ Postfix │ ───▶ │ Dovecot/LMTP │ │
|
||||
│ │ DKIM/AS │ │ SMTP/MTA │ │ IMAP/POP3 │ │
|
||||
│ └─────────┘ └────────────┘ └──────────────┘ │
|
||||
└────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
邮件流转:
|
||||
|
||||
1. **收件**:Postfix 接收邮件 → Rspamd 评分/标记 → Dovecot 写入 Maildir → API worker 同步到 SQLite → Webmail 展示。
|
||||
2. **发件**:Webmail 调用 API → API 构造 MIME → SMTP 提交给 Postfix 或外部 SMTP → 投递到目标地址。
|
||||
3. **本地投递**:开发环境中,系统内邮箱互发可直接写入对方 Inbox;未配置 `LANQIN_SMTP_HOST` 时不会真正投递外部收件人。
|
||||
4. **第三方客户端**:可通过 SMTP 465/587、IMAP 993、POP3 995 连接;生产环境请配置匹配 `LANQIN_PUBLIC_HOSTNAME` 的证书。
|
||||
5. **外部邮箱接入**:个人邮箱管理可添加外部 IMAP 账号。本地存储模式会同步入库;远端直连模式每次读取远端,不写入本地邮件表。
|
||||
|
||||
## 开发与验证
|
||||
|
||||
```bash
|
||||
# API 测试
|
||||
cd apps/api
|
||||
go test ./...
|
||||
|
||||
# Web 检查与构建
|
||||
cd apps/web
|
||||
pnpm run check
|
||||
|
||||
# 单容器源码构建验证
|
||||
cd deploy
|
||||
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
```
|
||||
|
||||
## 生产注意事项
|
||||
|
||||
- 生产环境必须修改默认管理员密码,并妥善保管 `.env`、SQLite 数据库、Maildir 与 DKIM 私钥。
|
||||
- Web 可放在宿主机 Nginx/宝塔/边缘网关后,但 SMTP/IMAP/POP3 证书需要单独挂载给容器内 Postfix/Dovecot。
|
||||
- 云厂商常默认封禁 25 端口;无法收发公网邮件时先检查端口、安全组、防火墙与反向 DNS。
|
||||
- SQLite 适合单机部署;多节点部署前需要迁移数据库,并同步调整 Postfix/Dovecot 查询配置。
|
||||
|
||||
## SMTP 提交
|
||||
|
||||
- 第三方客户端的 SMTP 提交 `465/587` 由 LanQin API 进程处理。
|
||||
- 启用 SMTP 提交前必须配置 `LANQIN_TLS_CERT_FILE` / `LANQIN_TLS_KEY_FILE`;API 不会用 localhost 自签证书对外提供 465/587。
|
||||
- Postfix 只保留 `25` 端口,用于公网入站邮件和内部/外部 relay。
|
||||
- Webmail/API 和第三方客户端发信都会先写入 Sent,再进入发送队列。
|
||||
- 发送队列由 LanQin API 后台 worker relay 到 `LANQIN_SMTP_HOST:LANQIN_SMTP_PORT`,失败会记录审计并按退避策略重试。
|
||||
- v1 支持本人邮箱发信;如需 send-as,可使用启用的别名转发 source 指向本人邮箱,或在数据库中配置 `send_as_grants`。
|
||||
- 如果客户端随后又通过 IMAP APPEND 写入自己的 Sent 副本,Maildir 同步会按 Sent 文件夹内的 `Message-ID` 去重。
|
||||
|
||||
## License
|
||||
|
||||
[MIT](./LICENSE)
|
||||
|
||||
|
||||
## Star 趋势
|
||||
|
||||
<a href="https://www.star-history.com/?repos=LanQin996%2FLanQin-Email&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
友情链接:[LINUX DO](https://linux.do/) —— 新的理想型社区
|
||||
## 开源协议
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
@@ -49,9 +49,9 @@ func (a *App) handleAdminOverview(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
|
||||
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
|
||||
GROUP BY u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at
|
||||
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at
|
||||
ORDER BY u.created_at DESC`)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list users")
|
||||
@@ -62,13 +62,15 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
for rows.Next() {
|
||||
var item AdminUser
|
||||
var disabled, twoFactorEnabled int
|
||||
var mailboxLimitOverride sql.NullInt64
|
||||
var created, mailboxCSV string
|
||||
if err := rows.Scan(&item.ID, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &created, &item.MailboxCount, &mailboxCSV); err != nil {
|
||||
if err := rows.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created, &item.MailboxCount, &mailboxCSV); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan users")
|
||||
return
|
||||
}
|
||||
item.Disabled = intBool(disabled)
|
||||
item.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
item.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
|
||||
item.CreatedAt = parseTime(created)
|
||||
item.Mailboxes = splitCSV(mailboxCSV)
|
||||
items = append(items, item)
|
||||
@@ -92,26 +94,34 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Password string `json:"password"`
|
||||
Disabled bool `json:"disabled"`
|
||||
PermissionGroupIDs []string `json:"permissionGroupIds"`
|
||||
LoginName string `json:"loginName"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Password string `json:"password"`
|
||||
Disabled bool `json:"disabled"`
|
||||
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
|
||||
PermissionGroupIDs []string `json:"permissionGroupIds"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
actor := currentUser(r)
|
||||
email := normalizeEmail(req.Email)
|
||||
if email == "" || !strings.Contains(email, "@") {
|
||||
badRequest(w, errors.New("invalid email"))
|
||||
var loginName string
|
||||
var err error
|
||||
if strings.TrimSpace(req.LoginName) != "" {
|
||||
loginName, err = cleanUsername(req.LoginName)
|
||||
} else {
|
||||
loginName, err = cleanLoginName(req.Email)
|
||||
}
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
displayName := strings.TrimSpace(req.DisplayName)
|
||||
if displayName == "" {
|
||||
displayName = email
|
||||
displayName = loginName
|
||||
}
|
||||
role := strings.TrimSpace(req.Role)
|
||||
if role == "" {
|
||||
@@ -125,6 +135,14 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusForbidden, "only administrators can create administrator users")
|
||||
return
|
||||
}
|
||||
mailboxLimitOverride, err := normalizeMailboxLimitOverride(req.MailboxLimitOverride)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if role == "admin" {
|
||||
mailboxLimitOverride = nil
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
||||
return
|
||||
@@ -142,8 +160,8 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`, id, email, displayName, role, string(passwordHash), boolInt(req.Disabled), now, now); err != nil {
|
||||
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,mailbox_limit_override,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?)`, id, loginName, loginName, displayName, role, string(passwordHash), boolInt(req.Disabled), nullableInt(mailboxLimitOverride), now, now); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
@@ -171,10 +189,12 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
current := currentUser(r)
|
||||
var req struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
PermissionGroupIDs *[]string `json:"permissionGroupIds"`
|
||||
LoginName string `json:"loginName"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
MailboxLimitOverride *int `json:"mailboxLimitOverride"`
|
||||
PermissionGroupIDs *[]string `json:"permissionGroupIds"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
@@ -198,6 +218,15 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
requestedLoginName := strings.TrimSpace(req.LoginName)
|
||||
loginName := existing.LoginName
|
||||
if requestedLoginName != "" {
|
||||
loginName, err = cleanUsername(requestedLoginName)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if current == nil || (current.Role != "admin" && (existing.Role == "admin" || role == "admin")) {
|
||||
respondError(w, http.StatusForbidden, "only administrators can modify administrator users")
|
||||
return
|
||||
@@ -210,6 +239,17 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("default administrator must remain an active super administrator"))
|
||||
return
|
||||
}
|
||||
mailboxLimitOverride := existing.MailboxLimitOverride
|
||||
if req.MailboxLimitOverride != nil {
|
||||
mailboxLimitOverride, err = normalizeMailboxLimitOverride(req.MailboxLimitOverride)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if role == "admin" {
|
||||
mailboxLimitOverride = nil
|
||||
}
|
||||
if err := a.ensureAdminRemains(r.Context(), id, role, disabled); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
@@ -254,8 +294,16 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET display_name=?, role=?, disabled=?, updated_at=? WHERE id=?`,
|
||||
displayName, role, boolInt(disabled), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||
emailIdentity := existing.Email
|
||||
if normalizeLoginName(existing.Email) == normalizeLoginName(existing.LoginName) {
|
||||
emailIdentity = loginName
|
||||
}
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET login_name=?, email=?, display_name=?, role=?, disabled=?, mailbox_limit_override=?, updated_at=? WHERE id=?`,
|
||||
loginName, emailIdentity, displayName, role, boolInt(disabled), nullableInt(mailboxLimitOverride), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
badRequest(w, errors.New("登录名已被使用"))
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to update user")
|
||||
return
|
||||
}
|
||||
@@ -492,14 +540,15 @@ func (a *App) handleListMailboxes(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
DomainID string `json:"domainId"`
|
||||
LocalPart string `json:"localPart"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Password string `json:"password"`
|
||||
QuotaMB int `json:"quotaMb"`
|
||||
Role string `json:"role"`
|
||||
OwnerEmail string `json:"ownerEmail"`
|
||||
UserID string `json:"userId"`
|
||||
DomainID string `json:"domainId"`
|
||||
LocalPart string `json:"localPart"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Password string `json:"password"`
|
||||
QuotaMB int `json:"quotaMb"`
|
||||
Role string `json:"role"`
|
||||
OwnerLoginName string `json:"ownerLoginName"`
|
||||
OwnerEmail string `json:"ownerEmail"`
|
||||
UserID string `json:"userId"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
@@ -568,15 +617,12 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
ownerEmail := normalizeEmail(req.OwnerEmail)
|
||||
if ownerEmail == "" {
|
||||
ownerEmail = address
|
||||
}
|
||||
if !strings.Contains(ownerEmail, "@") {
|
||||
badRequest(w, errors.New("invalid owner email"))
|
||||
ownerLoginName, err := cleanLoginName(req.OwnerLoginName, req.OwnerEmail, address)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE email=? AND disabled=0`, ownerEmail).Scan(&userID)
|
||||
err = tx.QueryRowContext(r.Context(), `SELECT id FROM users WHERE (login_name=? OR email=?) AND disabled=0`, ownerLoginName, ownerLoginName).Scan(&userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
@@ -585,11 +631,11 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
userID = newID("usr")
|
||||
ownerDisplayName := displayName
|
||||
if !strings.EqualFold(ownerEmail, address) {
|
||||
ownerDisplayName = ownerEmail
|
||||
if !strings.EqualFold(ownerLoginName, address) {
|
||||
ownerDisplayName = ownerLoginName
|
||||
}
|
||||
_, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`, userID, ownerEmail, ownerDisplayName, role, string(passwordHash), 0, now, now)
|
||||
_, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`, userID, ownerLoginName, ownerLoginName, ownerDisplayName, role, string(passwordHash), 0, now, now)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
@@ -757,6 +803,13 @@ func (a *App) handleAdminMessages(w http.ResponseWriter, r *http.Request) {
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
|
||||
folder := strings.TrimSpace(r.URL.Query().Get("folder"))
|
||||
user := currentUser(r)
|
||||
isSystemAdmin := user != nil && user.Role == "admin"
|
||||
wantsUnregistered := mailboxID == "unregistered" || strings.EqualFold(folder, "Unregistered")
|
||||
if wantsUnregistered && !isSystemAdmin {
|
||||
respondError(w, http.StatusForbidden, "system admin required")
|
||||
return
|
||||
}
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("cursor"))
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
@@ -765,6 +818,9 @@ func (a *App) handleAdminMessages(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
where := []string{"1=1"}
|
||||
args := []any{}
|
||||
if !isSystemAdmin {
|
||||
where = append(where, "m.mailbox_id IS NOT NULL")
|
||||
}
|
||||
if mailboxID == "unregistered" {
|
||||
where = append(where, "m.mailbox_id IS NULL")
|
||||
} else if mailboxID != "" && mailboxID != "all" {
|
||||
@@ -822,6 +878,11 @@ func (a *App) handleAdminMessage(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusNotFound, "message not found")
|
||||
return
|
||||
}
|
||||
user := currentUser(r)
|
||||
if msg.MailboxID == "" && (user == nil || user.Role != "admin") {
|
||||
respondError(w, http.StatusForbidden, "system admin required")
|
||||
return
|
||||
}
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COALESCE(mb.address,''),COALESCE(u.email,''),COALESCE(m.recipient_addr,'')
|
||||
FROM messages m
|
||||
LEFT JOIN mailboxes mb ON mb.id=m.mailbox_id
|
||||
@@ -964,7 +1025,7 @@ func (a *App) handleCreateAlias(w http.ResponseWriter, r *http.Request) {
|
||||
source = normalizeLocalPart(source) + "@" + domain.Name
|
||||
}
|
||||
destination := normalizeEmail(req.Destination)
|
||||
if source == "" || destination == "" || !strings.Contains(destination, "@") {
|
||||
if source == "" || !strings.HasSuffix(source, "@"+domain.Name) || destination == "" || !strings.Contains(destination, "@") {
|
||||
badRequest(w, errors.New("invalid alias"))
|
||||
return
|
||||
}
|
||||
@@ -1009,7 +1070,7 @@ func (a *App) handleUpdateAlias(w http.ResponseWriter, r *http.Request) {
|
||||
source = normalizeLocalPart(source) + "@" + domain.Name
|
||||
}
|
||||
destination := normalizeEmail(req.Destination)
|
||||
if source == "" || destination == "" || !strings.Contains(destination, "@") {
|
||||
if source == "" || !strings.HasSuffix(source, "@"+domain.Name) || destination == "" || !strings.Contains(destination, "@") {
|
||||
badRequest(w, errors.New("invalid alias"))
|
||||
return
|
||||
}
|
||||
@@ -1055,18 +1116,20 @@ func (a *App) domainByID(ctx context.Context, id string) (*Domain, error) {
|
||||
}
|
||||
|
||||
func (a *App) adminUserByID(ctx context.Context, id string) (*AdminUser, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
|
||||
row := a.db.QueryRowContext(ctx, `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
|
||||
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
|
||||
WHERE u.id=?
|
||||
GROUP BY u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at`, id)
|
||||
GROUP BY u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at`, id)
|
||||
var item AdminUser
|
||||
var disabled, twoFactorEnabled int
|
||||
var mailboxLimitOverride sql.NullInt64
|
||||
var created, mailboxCSV string
|
||||
if err := row.Scan(&item.ID, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &created, &item.MailboxCount, &mailboxCSV); err != nil {
|
||||
if err := row.Scan(&item.ID, &item.LoginName, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created, &item.MailboxCount, &mailboxCSV); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Disabled = intBool(disabled)
|
||||
item.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
item.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
|
||||
item.CreatedAt = parseTime(created)
|
||||
item.Mailboxes = splitCSV(mailboxCSV)
|
||||
if err := a.attachUserAuthorization(ctx, &item.User); err != nil {
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const defaultAPITokenTTL = 90 * 24 * time.Hour
|
||||
|
||||
var validAPITokenScopes = map[string]bool{
|
||||
"*": true,
|
||||
"domains:read": true,
|
||||
"domains:write": true,
|
||||
"mailboxes:read": true,
|
||||
"mailboxes:write": true,
|
||||
"messages:read": true,
|
||||
"messages:send": true,
|
||||
"messages:manage": true,
|
||||
"aliases:read": true,
|
||||
"aliases:write": true,
|
||||
"dns:read": true,
|
||||
"dns:check": true,
|
||||
}
|
||||
|
||||
func (a *App) handleListAPITokens(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,last_used_at,expires_at,disabled,scopes_json,created_at,updated_at
|
||||
FROM api_tokens WHERE user_id=? ORDER BY created_at DESC`, user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list api tokens")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []APIToken{}
|
||||
for rows.Next() {
|
||||
item, err := scanAPIToken(rows)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan api tokens")
|
||||
return
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list api tokens")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (a *App) handleCreateAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
Scopes json.RawMessage `json:"scopes"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
badRequest(w, errors.New("name is required"))
|
||||
return
|
||||
}
|
||||
if len([]rune(name)) > 80 {
|
||||
badRequest(w, errors.New("name cannot exceed 80 characters"))
|
||||
return
|
||||
}
|
||||
expiresAt, err := parseOptionalFutureTime(req.ExpiresAt, a.now().UTC())
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if expiresAt == nil {
|
||||
defaultExpiry := a.now().UTC().Add(defaultAPITokenTTL)
|
||||
expiresAt = &defaultExpiry
|
||||
}
|
||||
var requestedScopes []string
|
||||
if len(req.Scopes) > 0 {
|
||||
if string(req.Scopes) == "null" || json.Unmarshal(req.Scopes, &requestedScopes) != nil {
|
||||
badRequest(w, errors.New("scopes must be an array of strings"))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
requestedScopes = nil
|
||||
}
|
||||
scopes, err := normalizeAPITokenScopes(requestedScopes)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
id := newID("apt")
|
||||
token := "lq_" + randomToken()
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
var expiresValue any
|
||||
if expiresAt != nil {
|
||||
expiresValue = expiresAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO api_tokens(id,user_id,name,token_hash,expires_at,disabled,scopes_json,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`, id, user.ID, name, hashToken(token), expiresValue, 0, jsonEncode(scopes), now, now); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to create api token")
|
||||
return
|
||||
}
|
||||
item, err := a.apiTokenByID(r.Context(), user.ID, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load api token")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, map[string]any{"token": token, "item": item})
|
||||
}
|
||||
|
||||
func (a *App) handleUpdateAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
id := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
if id == "" {
|
||||
respondError(w, http.StatusNotFound, "api token not found")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
ExpiresAt *string `json:"expiresAt"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
Scopes *[]string `json:"scopes"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
current, err := a.apiTokenByID(r.Context(), user.ID, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "api token not found")
|
||||
return
|
||||
}
|
||||
name := current.Name
|
||||
if req.Name != nil {
|
||||
name = strings.TrimSpace(*req.Name)
|
||||
if name == "" {
|
||||
badRequest(w, errors.New("name is required"))
|
||||
return
|
||||
}
|
||||
if len([]rune(name)) > 80 {
|
||||
badRequest(w, errors.New("name cannot exceed 80 characters"))
|
||||
return
|
||||
}
|
||||
}
|
||||
var expiresValue any
|
||||
if current.ExpiresAt != nil {
|
||||
expiresValue = current.ExpiresAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
if req.ExpiresAt != nil {
|
||||
if strings.TrimSpace(*req.ExpiresAt) == "" {
|
||||
badRequest(w, errors.New("expiresAt must be an RFC3339 timestamp"))
|
||||
return
|
||||
}
|
||||
expiresAt, err := parseOptionalFutureTime(*req.ExpiresAt, a.now().UTC())
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
expiresValue = nil
|
||||
if expiresAt != nil {
|
||||
expiresValue = expiresAt.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
}
|
||||
disabled := current.Disabled
|
||||
if req.Disabled != nil {
|
||||
disabled = *req.Disabled
|
||||
}
|
||||
scopes := current.Scopes
|
||||
if req.Scopes != nil {
|
||||
scopes, err = normalizeAPITokenScopes(*req.Scopes)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `UPDATE api_tokens SET name=?,expires_at=?,disabled=?,scopes_json=?,updated_at=? WHERE id=? AND user_id=?`,
|
||||
name, expiresValue, boolInt(disabled), jsonEncode(scopes), a.now().UTC().Format(time.RFC3339Nano), id, user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update api token")
|
||||
return
|
||||
}
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
respondError(w, http.StatusNotFound, "api token not found")
|
||||
return
|
||||
}
|
||||
item, err := a.apiTokenByID(r.Context(), user.ID, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load api token")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
func (a *App) handleDeleteAPIToken(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
res, err := a.db.ExecContext(r.Context(), `DELETE FROM api_tokens WHERE id=? AND user_id=?`, chi.URLParam(r, "id"), user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete api token")
|
||||
return
|
||||
}
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
respondError(w, http.StatusNotFound, "api token not found")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) apiTokenByID(ctx context.Context, userID, id string) (APIToken, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,name,last_used_at,expires_at,disabled,scopes_json,created_at,updated_at
|
||||
FROM api_tokens WHERE id=? AND user_id=?`, id, userID)
|
||||
return scanAPIToken(row)
|
||||
}
|
||||
|
||||
type apiTokenScanner interface{ Scan(dest ...any) error }
|
||||
|
||||
func scanAPIToken(row apiTokenScanner) (APIToken, error) {
|
||||
var item APIToken
|
||||
var lastUsed, expires sql.NullString
|
||||
var disabled int
|
||||
var scopesJSON, created, updated string
|
||||
if err := row.Scan(&item.ID, &item.Name, &lastUsed, &expires, &disabled, &scopesJSON, &created, &updated); err != nil {
|
||||
return item, err
|
||||
}
|
||||
item.LastUsedAt = nullableTime(lastUsed)
|
||||
item.ExpiresAt = nullableTime(expires)
|
||||
item.Disabled = intBool(disabled)
|
||||
item.Scopes = jsonDecodeSlice(scopesJSON)
|
||||
item.CreatedAt = parseTime(created)
|
||||
item.UpdatedAt = parseTime(updated)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func normalizeAPITokenScopes(scopes []string) ([]string, error) {
|
||||
if scopes == nil {
|
||||
return []string{"*"}, nil
|
||||
}
|
||||
if len(scopes) == 0 {
|
||||
return nil, errors.New("at least one api token scope is required")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(scopes))
|
||||
for _, scope := range scopes {
|
||||
scope = strings.ToLower(strings.TrimSpace(scope))
|
||||
if !validAPITokenScopes[scope] {
|
||||
return nil, fmt.Errorf("invalid api token scope: %s", scope)
|
||||
}
|
||||
if !seen[scope] {
|
||||
seen[scope] = true
|
||||
out = append(out, scope)
|
||||
}
|
||||
}
|
||||
if seen["*"] && len(out) != 1 {
|
||||
return nil, errors.New("wildcard scope cannot be combined with other scopes")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseOptionalFutureTime(value string, now time.Time) (*time.Time, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil, nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339Nano, value)
|
||||
if err != nil {
|
||||
return nil, errors.New("expiresAt must be an RFC3339 timestamp")
|
||||
}
|
||||
t = t.UTC()
|
||||
if !t.After(now) {
|
||||
return nil, errors.New("expiresAt must be in the future")
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
@@ -82,6 +82,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
a.startWorker(func() { a.sendQueueWorker(workerCtx) })
|
||||
a.startWorker(func() { a.externalIMAPWorker(workerCtx) })
|
||||
a.startWorker(func() { a.smtpEventsCleanupWorker(workerCtx) })
|
||||
a.startWorker(func() { a.statusWebhookWorker(workerCtx) })
|
||||
return a, nil
|
||||
}
|
||||
|
||||
@@ -122,12 +123,14 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
login_name TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK(role IN ('admin','user')),
|
||||
password_hash TEXT NOT NULL,
|
||||
two_factor_secret TEXT NOT NULL DEFAULT '',
|
||||
two_factor_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
mailbox_limit_override INTEGER,
|
||||
disabled INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
@@ -137,7 +140,7 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
permissions_json TEXT NOT NULL DEFAULT '[]',
|
||||
limits_json TEXT NOT NULL DEFAULT '{"maxAttachmentMb":25,"smtpDailyLimit":200,"smtpMinuteLimit":20,"imapMinuteLimit":200,"pop3MinuteLimit":150}',
|
||||
limits_json TEXT NOT NULL DEFAULT '{"maxAttachmentMb":25,"maxMailboxCount":9,"smtpDailyLimit":200,"smtpMinuteLimit":20,"imapMinuteLimit":200,"pop3MinuteLimit":150}',
|
||||
system INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
@@ -163,6 +166,30 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
last_used_at TEXT,
|
||||
expires_at TEXT NOT NULL,
|
||||
disabled INTEGER NOT NULL DEFAULT 0,
|
||||
scopes_json TEXT NOT NULL DEFAULT '["*"]',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS send_idempotency_keys (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
idempotency_key TEXT NOT NULL,
|
||||
request_hash TEXT NOT NULL,
|
||||
sent_message_id TEXT NOT NULL DEFAULT '',
|
||||
queue_id TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id, idempotency_key)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_send_idempotency_created ON send_idempotency_keys(created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id, created_at DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash)`,
|
||||
`CREATE TABLE IF NOT EXISTS system_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
@@ -202,6 +229,35 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(domain_id, local_part)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS forwarding_verified_emails (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
email TEXT NOT NULL,
|
||||
verified INTEGER NOT NULL DEFAULT 0,
|
||||
verified_at TEXT,
|
||||
verification_token_hash TEXT NOT NULL DEFAULT '',
|
||||
verification_sent_at TEXT,
|
||||
verification_expires_at TEXT,
|
||||
delivery_queue_id TEXT NOT NULL DEFAULT '',
|
||||
delivery_status TEXT NOT NULL DEFAULT '',
|
||||
delivery_error TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(user_id, email)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_forwarding_verified_emails_user ON forwarding_verified_emails(user_id, email)`,
|
||||
`CREATE TABLE IF NOT EXISTS account_forwarding_settings (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
target_email TEXT NOT NULL DEFAULT '',
|
||||
target_emails TEXT NOT NULL DEFAULT '[]',
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS mailbox_forwarding_settings (
|
||||
mailbox_id TEXT PRIMARY KEY REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
target_email TEXT NOT NULL DEFAULT '',
|
||||
target_emails TEXT NOT NULL DEFAULT '[]',
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS aliases (
|
||||
id TEXT PRIMARY KEY,
|
||||
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
||||
@@ -314,6 +370,45 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_send_audit_events_created ON send_audit_events(created_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS delivery_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
external_id TEXT NOT NULL,
|
||||
provider TEXT NOT NULL,
|
||||
queue_id TEXT NOT NULL DEFAULT '',
|
||||
sent_message_id TEXT NOT NULL DEFAULT '',
|
||||
rfc_message_id TEXT NOT NULL DEFAULT '',
|
||||
recipient TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
occurred_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(provider, external_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_delivery_events_message ON delivery_events(sent_message_id, occurred_at, id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_delivery_events_rfc_message ON delivery_events(rfc_message_id, occurred_at, id)`,
|
||||
`CREATE TABLE IF NOT EXISTS status_webhook_outbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
event_key TEXT NOT NULL UNIQUE,
|
||||
event_type TEXT NOT NULL,
|
||||
mailbox_id TEXT NOT NULL DEFAULT '',
|
||||
payload_json TEXT NOT NULL,
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
next_attempt_at TEXT NOT NULL,
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
delivered_at TEXT
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_status_webhook_outbox_due ON status_webhook_outbox(delivered_at,next_attempt_at,created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_status_webhook_outbox_mailbox ON status_webhook_outbox(mailbox_id,created_at)`,
|
||||
`CREATE TRIGGER IF NOT EXISTS trg_mailbox_delete_status_webhook_outbox
|
||||
AFTER DELETE ON mailboxes BEGIN
|
||||
DELETE FROM status_webhook_outbox WHERE mailbox_id=OLD.id;
|
||||
END`,
|
||||
`CREATE TRIGGER IF NOT EXISTS trg_send_queue_delete_delivery_events
|
||||
AFTER DELETE ON send_queue BEGIN
|
||||
DELETE FROM delivery_events WHERE queue_id=OLD.id;
|
||||
END`,
|
||||
`CREATE TABLE IF NOT EXISTS attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
@@ -509,9 +604,15 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
if err := a.rebuildHTMLOnlyMessageSnippets(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateUserLoginNames(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateUsersForTwoFactor(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateUserMailboxLimitOverride(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateMailRulesBuilder(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -533,12 +634,124 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
if err := a.migrateExternalIMAP(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateForwardingVerification(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateForwardingTargets(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateAPITokenScopes(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) migrateForwardingVerification(ctx context.Context) error {
|
||||
columns := []struct {
|
||||
name string
|
||||
sql string
|
||||
}{
|
||||
{"verified_at", `ALTER TABLE forwarding_verified_emails ADD COLUMN verified_at TEXT`},
|
||||
{"verification_token_hash", `ALTER TABLE forwarding_verified_emails ADD COLUMN verification_token_hash TEXT NOT NULL DEFAULT ''`},
|
||||
{"verification_sent_at", `ALTER TABLE forwarding_verified_emails ADD COLUMN verification_sent_at TEXT`},
|
||||
{"verification_expires_at", `ALTER TABLE forwarding_verified_emails ADD COLUMN verification_expires_at TEXT`},
|
||||
{"delivery_queue_id", `ALTER TABLE forwarding_verified_emails ADD COLUMN delivery_queue_id TEXT NOT NULL DEFAULT ''`},
|
||||
{"delivery_status", `ALTER TABLE forwarding_verified_emails ADD COLUMN delivery_status TEXT NOT NULL DEFAULT ''`},
|
||||
{"delivery_error", `ALTER TABLE forwarding_verified_emails ADD COLUMN delivery_error TEXT NOT NULL DEFAULT ''`},
|
||||
}
|
||||
for _, column := range columns {
|
||||
if err := a.ensureTableColumn(ctx, "forwarding_verified_emails", column.name, column.sql); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx, `UPDATE forwarding_verified_emails SET verified_at=created_at WHERE verified=1 AND (verified_at IS NULL OR verified_at='')`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) migrateForwardingTargets(ctx context.Context) error {
|
||||
if err := a.ensureTableColumn(ctx, "account_forwarding_settings", "target_emails", `ALTER TABLE account_forwarding_settings ADD COLUMN target_emails TEXT NOT NULL DEFAULT '[]'`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.ensureTableColumn(ctx, "mailbox_forwarding_settings", "target_emails", `ALTER TABLE mailbox_forwarding_settings ADD COLUMN target_emails TEXT NOT NULL DEFAULT '[]'`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.backfillForwardingTargets(ctx, "account_forwarding_settings", "user_id"); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.backfillForwardingTargets(ctx, "mailbox_forwarding_settings", "mailbox_id")
|
||||
}
|
||||
|
||||
func (a *App) backfillForwardingTargets(ctx context.Context, table, keyColumn string) error {
|
||||
rows, err := a.db.QueryContext(ctx, fmt.Sprintf(`SELECT %s,target_email,target_emails FROM %s`, keyColumn, table))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
type row struct {
|
||||
key string
|
||||
targetEmail string
|
||||
targetsJSON string
|
||||
}
|
||||
var updates []row
|
||||
for rows.Next() {
|
||||
var item row
|
||||
if err := rows.Scan(&item.key, &item.targetEmail, &item.targetsJSON); err != nil {
|
||||
return err
|
||||
}
|
||||
targets := forwardingTargetsFromStored(item.targetEmail, item.targetsJSON)
|
||||
if len(targets) == 0 || len(jsonDecodeSlice(item.targetsJSON)) > 0 {
|
||||
continue
|
||||
}
|
||||
updates = append(updates, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range updates {
|
||||
targets := forwardingTargetsFromStored(item.targetEmail, item.targetsJSON)
|
||||
if _, err := a.db.ExecContext(ctx, fmt.Sprintf(`UPDATE %s SET target_emails=? WHERE %s=?`, table, keyColumn), jsonEncode(targets), item.key); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) migrateAPITokenScopes(ctx context.Context) error {
|
||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(api_tokens)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hasScopes := false
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, typ string
|
||||
var notnull int
|
||||
var dflt any
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if name == "scopes_json" {
|
||||
hasScopes = true
|
||||
}
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if hasScopes {
|
||||
return nil
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `ALTER TABLE api_tokens ADD COLUMN scopes_json TEXT NOT NULL DEFAULT '["*"]'`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) migrateMessageAuthentication(ctx context.Context) error {
|
||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`)
|
||||
if err != nil {
|
||||
@@ -703,7 +916,7 @@ func (a *App) migratePermissionGroupLimits(ctx context.Context) error {
|
||||
if hasLimits {
|
||||
return nil
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `ALTER TABLE permission_groups ADD COLUMN limits_json TEXT NOT NULL DEFAULT '{"maxAttachmentMb":25,"smtpDailyLimit":200,"smtpMinuteLimit":20,"imapMinuteLimit":200,"pop3MinuteLimit":150}'`)
|
||||
_, err = a.db.ExecContext(ctx, `ALTER TABLE permission_groups ADD COLUMN limits_json TEXT NOT NULL DEFAULT '{"maxAttachmentMb":25,"maxMailboxCount":9,"smtpDailyLimit":200,"smtpMinuteLimit":20,"imapMinuteLimit":200,"pop3MinuteLimit":150}'`)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -901,6 +1114,129 @@ func (a *App) migrateUsersForTwoFactor(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) migrateUserLoginNames(ctx context.Context) error {
|
||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(users)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
columns := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, typ string
|
||||
var notnull int
|
||||
var dflt any
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
columns[name] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !columns["login_name"] {
|
||||
if _, err := a.db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN login_name TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
type loginUser struct {
|
||||
id string
|
||||
email string
|
||||
loginName string
|
||||
}
|
||||
userRows, err := a.db.QueryContext(ctx, `SELECT id,email,login_name FROM users ORDER BY created_at,id`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items := []loginUser{}
|
||||
localCounts := map[string]int{}
|
||||
used := map[string]bool{}
|
||||
for userRows.Next() {
|
||||
var item loginUser
|
||||
if err := userRows.Scan(&item.id, &item.email, &item.loginName); err != nil {
|
||||
userRows.Close()
|
||||
return err
|
||||
}
|
||||
item.email = normalizeEmail(item.email)
|
||||
item.loginName = normalizeLoginName(item.loginName)
|
||||
if item.loginName != "" {
|
||||
used[item.loginName] = true
|
||||
}
|
||||
if strings.Contains(item.email, "@") {
|
||||
localCounts[strings.SplitN(item.email, "@", 2)[0]]++
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := userRows.Err(); err != nil {
|
||||
userRows.Close()
|
||||
return err
|
||||
}
|
||||
if err := userRows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
for _, item := range items {
|
||||
if item.loginName != "" {
|
||||
continue
|
||||
}
|
||||
candidate := item.email
|
||||
if strings.Contains(item.email, "@") {
|
||||
local := strings.SplitN(item.email, "@", 2)[0]
|
||||
if localCounts[local] == 1 && !used[local] {
|
||||
candidate = local
|
||||
}
|
||||
}
|
||||
if candidate == "" {
|
||||
candidate = normalizeLoginName(item.id)
|
||||
}
|
||||
base := candidate
|
||||
for suffix := 2; used[candidate]; suffix++ {
|
||||
candidate = fmt.Sprintf("%s-%d", base, suffix)
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE users SET login_name=?, updated_at=? WHERE id=?`, candidate, now, item.id); err != nil {
|
||||
return err
|
||||
}
|
||||
used[candidate] = true
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_login_name ON users(login_name) WHERE login_name <> ''`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) migrateUserMailboxLimitOverride(ctx context.Context) error {
|
||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(users)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
hasColumn := false
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, typ string
|
||||
var notnull int
|
||||
var dflt any
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
|
||||
return err
|
||||
}
|
||||
if name == "mailbox_limit_override" {
|
||||
hasColumn = true
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if hasColumn {
|
||||
return nil
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN mailbox_limit_override INTEGER`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) migrateMessagesForUnregistered(ctx context.Context) error {
|
||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`)
|
||||
if err != nil {
|
||||
@@ -1074,12 +1410,25 @@ func (a *App) seed(ctx context.Context) error {
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
userID := newID("usr")
|
||||
if strings.TrimSpace(a.cfg.AdminUsername) != "" {
|
||||
adminUsername, err := cleanUsername(a.cfg.AdminUsername)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid admin username: %w", err)
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`, userID, adminUsername, adminUsername, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "username", adminUsername)
|
||||
return nil
|
||||
}
|
||||
adminEmail := normalizeEmail(a.cfg.AdminEmail)
|
||||
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
|
||||
return errors.New("invalid admin email")
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`, userID, adminEmail, "LanQin Admin", "admin", string(passwordHash), 0, now, now); err != nil {
|
||||
adminLoginName := normalizeLoginName(strings.SplitN(adminEmail, "@", 2)[0])
|
||||
if _, err := a.db.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`, userID, adminLoginName, adminEmail, "NewSzxcn Admin", "admin", string(passwordHash), 0, now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
a.log.Warn("created default administrator; change LANQIN_ADMIN_PASSWORD in production", "email", adminEmail)
|
||||
@@ -1114,6 +1463,11 @@ func (a *App) seed(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (a *App) ensureConfiguredAdminSuperAdmin(ctx context.Context) error {
|
||||
if adminUsername := normalizeLoginName(a.cfg.AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") {
|
||||
_, err := a.db.ExecContext(ctx, `UPDATE users SET role='admin', disabled=0, updated_at=? WHERE login_name=?`,
|
||||
a.now().UTC().Format(time.RFC3339Nano), adminUsername)
|
||||
return err
|
||||
}
|
||||
adminEmail := normalizeEmail(a.cfg.AdminEmail)
|
||||
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
|
||||
return nil
|
||||
@@ -1183,6 +1537,22 @@ func (a *App) createMailbox(ctx context.Context, userID, domainID, localPart, di
|
||||
}
|
||||
|
||||
func (a *App) createMailboxWithPasswordHash(ctx context.Context, userID, domainID, localPart, displayName, passwordHash string, quotaMB int, status string) (string, error) {
|
||||
tx, err := a.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
id, err := a.createMailboxWithPasswordHashTx(ctx, tx, userID, domainID, localPart, displayName, passwordHash, quotaMB, status)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (a *App) createMailboxWithPasswordHashTx(ctx context.Context, tx *sql.Tx, userID, domainID, localPart, displayName, passwordHash string, quotaMB int, status string) (string, error) {
|
||||
localPart = normalizeLocalPart(localPart)
|
||||
if localPart == "" {
|
||||
return "", errors.New("invalid local part")
|
||||
@@ -1194,7 +1564,7 @@ func (a *App) createMailboxWithPasswordHash(ctx context.Context, userID, domainI
|
||||
status = "active"
|
||||
}
|
||||
var domain string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT name FROM domains WHERE id=?`, domainID).Scan(&domain); err != nil {
|
||||
if err := tx.QueryRowContext(ctx, `SELECT name FROM domains WHERE id=?`, domainID).Scan(&domain); err != nil {
|
||||
return "", err
|
||||
}
|
||||
address := localPart + "@" + domain
|
||||
@@ -1202,15 +1572,9 @@ func (a *App) createMailboxWithPasswordHash(ctx context.Context, userID, domainI
|
||||
displayName = address
|
||||
}
|
||||
|
||||
tx, err := a.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
id := newID("mbx")
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO mailboxes(id,user_id,domain_id,local_part,address,display_name,password_hash,quota_mb,status,created_at,updated_at)
|
||||
_, err := tx.ExecContext(ctx, `INSERT INTO mailboxes(id,user_id,domain_id,local_part,address,display_name,password_hash,quota_mb,status,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?)`, id, userID, domainID, localPart, address, displayName, passwordHash, quotaMB, status, now, now)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -1221,9 +1585,6 @@ func (a *App) createMailboxWithPasswordHash(ctx context.Context, userID, domainI
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
@@ -1233,7 +1594,7 @@ func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
|
||||
return err
|
||||
}
|
||||
now := a.now().UTC()
|
||||
subject := "欢迎使用 LanQin Email"
|
||||
subject := "欢迎使用 NewSzxcn 邮箱"
|
||||
bodyText := "你的自建邮箱 Webmail 已经初始化完成。请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。"
|
||||
bodyHTML := "<p>你的自建邮箱 Webmail 已经初始化完成。</p><p>请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。</p>"
|
||||
if tpl, err := a.mailTemplate(ctx, "welcome"); err == nil {
|
||||
@@ -1253,7 +1614,7 @@ func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
|
||||
MessageID: fmt.Sprintf("<%s@lanqin.local>", newID("msg")),
|
||||
Subject: subject,
|
||||
From: "system@lanqin.local",
|
||||
FromName: "LanQin Email",
|
||||
FromName: "NewSzxcn 邮箱",
|
||||
To: []string{a.cfg.AdminEmail},
|
||||
SentAt: now,
|
||||
ReceivedAt: now,
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
LoginName string `json:"loginName"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
TurnstileToken string `json:"turnstileToken"`
|
||||
@@ -49,14 +50,24 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusUnauthorized, "人机验证失败,请重试")
|
||||
return
|
||||
}
|
||||
email := normalizeEmail(req.Email)
|
||||
user, passwordHash, err := a.userByEmail(r.Context(), email)
|
||||
var loginName string
|
||||
var err error
|
||||
if strings.TrimSpace(req.LoginName) != "" {
|
||||
loginName, err = cleanUsername(req.LoginName)
|
||||
} else {
|
||||
loginName, err = cleanLoginName(req.Email)
|
||||
}
|
||||
if err != nil {
|
||||
respondError(w, http.StatusUnauthorized, "账号或密码错误")
|
||||
return
|
||||
}
|
||||
user, passwordHash, err := a.userByEmail(r.Context(), loginName)
|
||||
if err != nil || user.Disabled {
|
||||
respondError(w, http.StatusUnauthorized, "邮箱或密码错误")
|
||||
respondError(w, http.StatusUnauthorized, "账号或密码错误")
|
||||
return
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
|
||||
respondError(w, http.StatusUnauthorized, "邮箱或密码错误")
|
||||
respondError(w, http.StatusUnauthorized, "账号或密码错误")
|
||||
return
|
||||
}
|
||||
if a.cfg.TwoFactorEnabled && user.TwoFactorEnabled {
|
||||
@@ -127,8 +138,8 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
userID := newID("usr")
|
||||
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`, userID, email, displayName, "user", string(passwordHash), 0, now, now); err != nil {
|
||||
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", string(passwordHash), 0, now, now); err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "unique") {
|
||||
respondError(w, http.StatusConflict, "该邮箱已被注册")
|
||||
return
|
||||
|
||||
@@ -9,10 +9,12 @@ import (
|
||||
|
||||
type Config struct {
|
||||
Addr string
|
||||
AppVersion string
|
||||
DBPath string
|
||||
DataDir string
|
||||
CookieName string
|
||||
SessionTTLHours int
|
||||
AdminUsername string
|
||||
AdminEmail string
|
||||
AdminPassword string
|
||||
PublicHostname string
|
||||
@@ -51,16 +53,25 @@ type Config struct {
|
||||
ExternalIMAPOutlookClientSecret string
|
||||
MailTranslateEnabled bool
|
||||
MailTranslateMaxChars int
|
||||
DeliveryWebhookSecret string
|
||||
StatusWebhookURL string
|
||||
StatusWebhookSecret string
|
||||
StatusWebhookAllowPrivateHosts bool
|
||||
ReleaseAPIURL string
|
||||
UpdateServiceURL string
|
||||
UpdateServiceToken string
|
||||
}
|
||||
|
||||
func LoadConfig() Config {
|
||||
dataDir := getenv("LANQIN_DATA_DIR", "./data")
|
||||
return Config{
|
||||
Addr: getenv("LANQIN_ADDR", ":8080"),
|
||||
AppVersion: getenv("LANQIN_APP_VERSION", BuildVersion),
|
||||
DBPath: getenv("LANQIN_DB_PATH", filepath.Join(dataDir, "lanqin.db")),
|
||||
DataDir: dataDir,
|
||||
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
|
||||
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
|
||||
AdminUsername: normalizeLoginName(getenv("LANQIN_ADMIN_USERNAME", "")),
|
||||
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
|
||||
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""),
|
||||
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
|
||||
@@ -99,6 +110,13 @@ func LoadConfig() Config {
|
||||
ExternalIMAPOutlookClientSecret: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET", ""),
|
||||
MailTranslateEnabled: getenvBool("LANQIN_MAIL_TRANSLATE_ENABLED", true),
|
||||
MailTranslateMaxChars: getenvInt("LANQIN_MAIL_TRANSLATE_MAX_CHARS", 8000),
|
||||
DeliveryWebhookSecret: getenv("LANQIN_DELIVERY_WEBHOOK_SECRET", ""),
|
||||
StatusWebhookURL: getenv("LANQIN_STATUS_WEBHOOK_URL", ""),
|
||||
StatusWebhookSecret: getenv("LANQIN_STATUS_WEBHOOK_SECRET", ""),
|
||||
StatusWebhookAllowPrivateHosts: getenvBool("LANQIN_STATUS_WEBHOOK_ALLOW_PRIVATE_HOSTS", false),
|
||||
ReleaseAPIURL: getenv("LANQIN_RELEASE_API_URL", "https://api.github.com/repos/zxyszx/NewSzxcn-Email/releases/latest"),
|
||||
UpdateServiceURL: getenv("LANQIN_UPDATE_SERVICE_URL", ""),
|
||||
UpdateServiceToken: getenv("LANQIN_UPDATE_SERVICE_TOKEN", ""),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const forwardingHeaderName = "X-LanQin-Forwarded-By"
|
||||
|
||||
func (a *App) processInboundForwarding(ctx context.Context, messageID, mailboxID string, raw []byte) {
|
||||
targets, userID, mailboxAddress, err := a.inboundForwardingTargets(ctx, mailboxID)
|
||||
if err != nil {
|
||||
a.log.Warn("failed to load forwarding target", "message", messageID, "mailbox", mailboxID, "error", err)
|
||||
return
|
||||
}
|
||||
if len(targets) == 0 || userID == "" || mailboxAddress == "" {
|
||||
return
|
||||
}
|
||||
self := normalizeEmail(mailboxAddress)
|
||||
filteredTargets := make([]string, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
if normalizeEmail(target) == self {
|
||||
continue
|
||||
}
|
||||
filteredTargets = append(filteredTargets, target)
|
||||
}
|
||||
targets = dedupeEmails(filteredTargets)
|
||||
if len(targets) == 0 {
|
||||
return
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
raw, err = a.forwardingRawMessage(ctx, messageID)
|
||||
if err != nil {
|
||||
a.log.Warn("failed to load raw message for forwarding", "message", messageID, "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if hasForwardingHeader(raw) {
|
||||
a.log.Warn("skip forwarding message that already has LanQin forwarding header", "message", messageID, "mailbox", mailboxID)
|
||||
return
|
||||
}
|
||||
forwarded := addForwardingHeaders(raw, mailboxAddress, a.cfg.PublicHostname)
|
||||
var rfcMessageID string
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT message_id FROM messages WHERE id=?`, messageID).Scan(&rfcMessageID)
|
||||
if strings.TrimSpace(rfcMessageID) == "" {
|
||||
rfcMessageID = messageID
|
||||
}
|
||||
queueID, err := a.enqueueSend(ctx, sendQueueInput{
|
||||
UserID: userID,
|
||||
MailboxID: mailboxID,
|
||||
SentMessageID: messageID,
|
||||
MessageID: rfcMessageID,
|
||||
Source: sendSourceForwarding,
|
||||
MailFrom: mailboxAddress,
|
||||
HeaderFrom: mailboxAddress,
|
||||
Recipients: targets,
|
||||
MIMEBytes: forwarded,
|
||||
Now: a.now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
a.log.Warn("failed to enqueue inbound forwarding", "message", messageID, "mailbox", mailboxID, "targets", strings.Join(targets, ","), "error", err)
|
||||
return
|
||||
}
|
||||
if queueID == "" {
|
||||
a.log.Warn("forwarding target configured but SMTP sending is not configured", "message", messageID, "mailbox", mailboxID, "targets", strings.Join(targets, ","))
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) processRuleForwarding(ctx context.Context, messageID, mailboxID string, action MailRuleAction) error {
|
||||
var userID, mailboxAddress string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT user_id,address FROM mailboxes WHERE id=? AND status='active'`, mailboxID).Scan(&userID, &mailboxAddress); err != nil {
|
||||
return err
|
||||
}
|
||||
targets, err := a.cleanForwardingTargets(ctx, userID, splitRuleForwardTargets(action.Value))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
self := normalizeEmail(mailboxAddress)
|
||||
filteredTargets := make([]string, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
if normalizeEmail(target) == self {
|
||||
continue
|
||||
}
|
||||
filteredTargets = append(filteredTargets, target)
|
||||
}
|
||||
targets = dedupeEmails(filteredTargets)
|
||||
if len(targets) == 0 {
|
||||
return nil
|
||||
}
|
||||
raw, err := a.forwardingRawMessage(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasForwardingHeader(raw) {
|
||||
a.log.Warn("skip rule forwarding message that already has LanQin forwarding header", "message", messageID, "mailbox", mailboxID)
|
||||
return nil
|
||||
}
|
||||
forwarded := addForwardingHeaders(raw, mailboxAddress, a.cfg.PublicHostname)
|
||||
var rfcMessageID string
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT message_id FROM messages WHERE id=?`, messageID).Scan(&rfcMessageID)
|
||||
if strings.TrimSpace(rfcMessageID) == "" {
|
||||
rfcMessageID = messageID
|
||||
}
|
||||
queueID, err := a.enqueueSend(ctx, sendQueueInput{
|
||||
UserID: userID,
|
||||
MailboxID: mailboxID,
|
||||
SentMessageID: messageID,
|
||||
MessageID: ruleForwardQueueMessageID(rfcMessageID, targets),
|
||||
Source: sendSourceRuleForwarding,
|
||||
MailFrom: mailboxAddress,
|
||||
HeaderFrom: mailboxAddress,
|
||||
Recipients: targets,
|
||||
MIMEBytes: forwarded,
|
||||
Now: a.now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if queueID == "" {
|
||||
a.log.Warn("rule forwarding target configured but SMTP sending is not configured", "message", messageID, "mailbox", mailboxID, "targets", strings.Join(targets, ","))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) inboundForwardingTargets(ctx context.Context, mailboxID string) (targetEmails []string, userID, mailboxAddress string, err error) {
|
||||
var mailboxTarget, mailboxTargetsJSON, accountTarget, accountTargetsJSON string
|
||||
err = a.db.QueryRowContext(ctx, `SELECT mb.user_id,mb.address,COALESCE(mfs.target_email,''),COALESCE(mfs.target_emails,'[]'),COALESCE(afs.target_email,''),COALESCE(afs.target_emails,'[]')
|
||||
FROM mailboxes mb
|
||||
LEFT JOIN mailbox_forwarding_settings mfs ON mfs.mailbox_id=mb.id
|
||||
LEFT JOIN account_forwarding_settings afs ON afs.user_id=mb.user_id
|
||||
WHERE mb.id=? AND mb.status='active'`, mailboxID).Scan(&userID, &mailboxAddress, &mailboxTarget, &mailboxTargetsJSON, &accountTarget, &accountTargetsJSON)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
targets := forwardingTargetsFromStored(mailboxTarget, mailboxTargetsJSON)
|
||||
if len(targets) == 0 {
|
||||
targets = forwardingTargetsFromStored(accountTarget, accountTargetsJSON)
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
return nil, userID, mailboxAddress, nil
|
||||
}
|
||||
verifiedTargets := make([]string, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
verified, err := a.forwardingEmailVerified(ctx, userID, target)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
if verified {
|
||||
verifiedTargets = append(verifiedTargets, target)
|
||||
}
|
||||
}
|
||||
return dedupeEmails(verifiedTargets), userID, mailboxAddress, nil
|
||||
}
|
||||
|
||||
func (a *App) forwardingRawMessage(ctx context.Context, messageID string) ([]byte, error) {
|
||||
msg, err := a.storedMessageByID(ctx, messageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(msg.RawPath) != "" {
|
||||
if ok, err := a.pathIsUnderMaildirRoot(msg.RawPath); err == nil && ok {
|
||||
if raw, err := os.ReadFile(msg.RawPath); err == nil {
|
||||
return raw, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
attachments, err := a.attachmentInputsForMessage(ctx, messageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return BuildMIME(MIMEMessage{
|
||||
From: msg.From,
|
||||
FromName: msg.FromName,
|
||||
To: msg.To,
|
||||
CC: msg.CC,
|
||||
BCC: msg.BCC,
|
||||
Subject: msg.Subject,
|
||||
Text: msg.BodyText,
|
||||
HTML: msg.BodyHTML,
|
||||
MessageID: msg.MessageID,
|
||||
Date: messageDate(msg),
|
||||
Attachments: attachments,
|
||||
})
|
||||
}
|
||||
|
||||
func splitRuleForwardTargets(value string) []string {
|
||||
return strings.FieldsFunc(value, func(r rune) bool {
|
||||
return unicode.IsSpace(r) || r == ',' || r == ',' || r == ';' || r == ';'
|
||||
})
|
||||
}
|
||||
|
||||
func ruleForwardQueueMessageID(messageID string, targets []string) string {
|
||||
base := strings.TrimSpace(messageID)
|
||||
if base == "" {
|
||||
base = newID("ruleforward")
|
||||
}
|
||||
sum := sha256.Sum256([]byte(strings.Join(dedupeEmails(targets), ",")))
|
||||
return base + "#rule-forward-" + hex.EncodeToString(sum[:])[:12]
|
||||
}
|
||||
|
||||
func hasForwardingHeader(raw []byte) bool {
|
||||
header := raw
|
||||
if idx := bytes.Index(raw, []byte("\r\n\r\n")); idx >= 0 {
|
||||
header = raw[:idx]
|
||||
} else if idx := bytes.Index(raw, []byte("\n\n")); idx >= 0 {
|
||||
header = raw[:idx]
|
||||
}
|
||||
return strings.Contains(strings.ToLower(string(header)), strings.ToLower(forwardingHeaderName)+":")
|
||||
}
|
||||
|
||||
func addForwardingHeaders(raw []byte, mailboxAddress, hostname string) []byte {
|
||||
hostname = strings.TrimSpace(hostname)
|
||||
if hostname == "" {
|
||||
hostname = "lanqin.local"
|
||||
}
|
||||
header := fmt.Sprintf("%s: %s\r\nX-LanQin-Forwarded-For: %s\r\n", forwardingHeaderName, hostname, normalizeEmail(mailboxAddress))
|
||||
if idx := bytes.Index(raw, []byte("\r\n\r\n")); idx >= 0 {
|
||||
out := make([]byte, 0, len(raw)+len(header))
|
||||
out = append(out, raw[:idx]...)
|
||||
out = append(out, []byte("\r\n"+header)...)
|
||||
out = append(out, raw[idx+2:]...)
|
||||
return out
|
||||
}
|
||||
if idx := bytes.Index(raw, []byte("\n\n")); idx >= 0 {
|
||||
lfHeader := strings.ReplaceAll(header, "\r\n", "\n")
|
||||
out := make([]byte, 0, len(raw)+len(lfHeader))
|
||||
out = append(out, raw[:idx]...)
|
||||
out = append(out, []byte("\n"+lfHeader)...)
|
||||
out = append(out, raw[idx+1:]...)
|
||||
return out
|
||||
}
|
||||
return append([]byte(header+"\r\n"), raw...)
|
||||
}
|
||||
@@ -0,0 +1,634 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const forwardingVerificationTTL = 24 * time.Hour
|
||||
|
||||
type ForwardingVerifiedEmail struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Verified bool `json:"verified"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
VerifiedAt *time.Time `json:"verifiedAt,omitempty"`
|
||||
VerificationSentAt *time.Time `json:"verificationSentAt,omitempty"`
|
||||
VerificationExpiresAt *time.Time `json:"verificationExpiresAt,omitempty"`
|
||||
DeliveryStatus string `json:"deliveryStatus,omitempty"`
|
||||
DeliveryError string `json:"deliveryError,omitempty"`
|
||||
}
|
||||
|
||||
type MailboxForwardingRule struct {
|
||||
MailboxID string `json:"mailboxId"`
|
||||
TargetEmail string `json:"targetEmail"`
|
||||
TargetEmails []string `json:"targetEmails"`
|
||||
}
|
||||
|
||||
type ForwardingSettings struct {
|
||||
VerifiedEmails []ForwardingVerifiedEmail `json:"verifiedEmails"`
|
||||
AccountTargetEmail string `json:"accountTargetEmail"`
|
||||
AccountTargetEmails []string `json:"accountTargetEmails"`
|
||||
MailboxRules []MailboxForwardingRule `json:"mailboxRules"`
|
||||
}
|
||||
|
||||
func (a *App) handleForwardingSettings(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
settings, err := a.forwardingSettings(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load forwarding settings")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
func (a *App) handleAddForwardingVerifiedEmail(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
email, ok := a.cleanForwardingVerificationEmail(w, r, user.ID, req.Email)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, verified, err := a.forwardingVerifiedEmailState(r.Context(), user.ID, email)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load verified email")
|
||||
return
|
||||
}
|
||||
if verified {
|
||||
settings, err := a.forwardingSettings(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load forwarding settings")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, settings)
|
||||
return
|
||||
}
|
||||
if id == "" {
|
||||
id = newID("fwd")
|
||||
}
|
||||
if err := a.issueForwardingVerification(r.Context(), user.ID, id, email, errors.Is(err, sql.ErrNoRows)); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to save verified email")
|
||||
return
|
||||
}
|
||||
settings, err := a.forwardingSettings(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load forwarding settings")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, settings)
|
||||
}
|
||||
|
||||
func (a *App) handleResendForwardingVerifiedEmail(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
id := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
if id == "" {
|
||||
respondError(w, http.StatusNotFound, "verified email not found")
|
||||
return
|
||||
}
|
||||
var email string
|
||||
var verified int
|
||||
err := a.db.QueryRowContext(r.Context(), `SELECT email,verified FROM forwarding_verified_emails WHERE id=? AND user_id=?`, id, user.ID).Scan(&email, &verified)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
respondError(w, http.StatusNotFound, "verified email not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load verified email")
|
||||
return
|
||||
}
|
||||
if intBool(verified) {
|
||||
settings, err := a.forwardingSettings(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load forwarding settings")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, settings)
|
||||
return
|
||||
}
|
||||
if err := a.issueForwardingVerification(r.Context(), user.ID, id, email, false); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to resend verification email")
|
||||
return
|
||||
}
|
||||
settings, err := a.forwardingSettings(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load forwarding settings")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
func (a *App) handleVerifyForwardingEmail(w http.ResponseWriter, r *http.Request) {
|
||||
token := strings.TrimSpace(r.URL.Query().Get("token"))
|
||||
if token == "" {
|
||||
a.renderForwardingVerificationPage(w, http.StatusBadRequest, false, "", "验证链接无效")
|
||||
return
|
||||
}
|
||||
var id, email string
|
||||
var verified int
|
||||
var expiresRaw sql.NullString
|
||||
err := a.db.QueryRowContext(r.Context(), `SELECT id,email,verified,verification_expires_at FROM forwarding_verified_emails WHERE verification_token_hash=?`, hashToken(token)).Scan(&id, &email, &verified, &expiresRaw)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
a.renderForwardingVerificationPage(w, http.StatusBadRequest, false, "", "验证链接无效或已使用")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
a.renderForwardingVerificationPage(w, http.StatusInternalServerError, false, "", "验证失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
if intBool(verified) {
|
||||
a.renderForwardingVerificationPage(w, http.StatusOK, true, email, "该邮箱已经验证完成")
|
||||
return
|
||||
}
|
||||
if expiresRaw.Valid && expiresRaw.String != "" && parseTime(expiresRaw.String).Before(a.now().UTC()) {
|
||||
a.renderForwardingVerificationPage(w, http.StatusBadRequest, false, email, "验证链接已过期,请回到设置页重新发送")
|
||||
return
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
_, err = a.db.ExecContext(r.Context(), `UPDATE forwarding_verified_emails
|
||||
SET verified=1,verified_at=?,delivery_status='verified',delivery_error='',updated_at=?
|
||||
WHERE id=?`, now, now, id)
|
||||
if err != nil {
|
||||
a.renderForwardingVerificationPage(w, http.StatusInternalServerError, false, email, "验证失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
a.renderForwardingVerificationPage(w, http.StatusOK, true, email, "验证完成,可以回到设置页选择此转发目标")
|
||||
}
|
||||
|
||||
func (a *App) handleDeleteForwardingVerifiedEmail(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
id := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
if id == "" {
|
||||
respondError(w, http.StatusNotFound, "verified email not found")
|
||||
return
|
||||
}
|
||||
var email string
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT email FROM forwarding_verified_emails WHERE id=? AND user_id=?`, id, user.ID).Scan(&email); err != nil {
|
||||
respondError(w, http.StatusNotFound, "verified email not found")
|
||||
return
|
||||
}
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to start transaction")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := tx.ExecContext(r.Context(), `DELETE FROM forwarding_verified_emails WHERE id=? AND user_id=?`, id, user.ID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete verified email")
|
||||
return
|
||||
}
|
||||
if err := a.removeForwardingTargetFromSettings(r.Context(), tx, user.ID, email, now); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update mailbox forwarding")
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to save forwarding settings")
|
||||
return
|
||||
}
|
||||
settings, err := a.forwardingSettings(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load forwarding settings")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
func (a *App) handleUpdateAccountForwarding(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
var req struct {
|
||||
TargetEmail string `json:"targetEmail"`
|
||||
TargetEmails []string `json:"targetEmails"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
targets, err := a.cleanForwardingTargets(r.Context(), user.ID, forwardingTargetsFromRequest(req.TargetEmail, req.TargetEmails))
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
target := firstForwardingTarget(targets)
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
_, err = a.db.ExecContext(r.Context(), `INSERT INTO account_forwarding_settings(user_id,target_email,target_emails,updated_at)
|
||||
VALUES(?,?,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET target_email=excluded.target_email,target_emails=excluded.target_emails,updated_at=excluded.updated_at`,
|
||||
user.ID, target, jsonEncode(targets), now)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to save account forwarding")
|
||||
return
|
||||
}
|
||||
settings, err := a.forwardingSettings(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load forwarding settings")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
func (a *App) handleUpdateMailboxForwarding(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
mailboxID := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
if mailboxID == "" {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
if ok, err := a.userOwnsMailboxID(r.Context(), user.ID, mailboxID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to check mailbox")
|
||||
return
|
||||
} else if !ok {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
TargetEmail string `json:"targetEmail"`
|
||||
TargetEmails []string `json:"targetEmails"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
targets, err := a.cleanForwardingTargets(r.Context(), user.ID, forwardingTargetsFromRequest(req.TargetEmail, req.TargetEmails))
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
target := firstForwardingTarget(targets)
|
||||
if len(targets) == 0 {
|
||||
if _, err := a.db.ExecContext(r.Context(), `DELETE FROM mailbox_forwarding_settings WHERE mailbox_id=?`, mailboxID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to save mailbox forwarding")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO mailbox_forwarding_settings(mailbox_id,target_email,target_emails,updated_at)
|
||||
VALUES(?,?,?,?)
|
||||
ON CONFLICT(mailbox_id) DO UPDATE SET target_email=excluded.target_email,target_emails=excluded.target_emails,updated_at=excluded.updated_at`,
|
||||
mailboxID, target, jsonEncode(targets), now); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to save mailbox forwarding")
|
||||
return
|
||||
}
|
||||
}
|
||||
settings, err := a.forwardingSettings(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load forwarding settings")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, settings)
|
||||
}
|
||||
|
||||
func (a *App) forwardingSettings(ctx context.Context, userID string) (ForwardingSettings, error) {
|
||||
settings := ForwardingSettings{
|
||||
VerifiedEmails: []ForwardingVerifiedEmail{},
|
||||
MailboxRules: []MailboxForwardingRule{},
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT fve.id,fve.email,fve.verified,fve.created_at,
|
||||
fve.verified_at,fve.verification_sent_at,fve.verification_expires_at,
|
||||
COALESCE(NULLIF(sq.status,''), fve.delivery_status),
|
||||
COALESCE(NULLIF(sq.last_error,''), fve.delivery_error)
|
||||
FROM forwarding_verified_emails fve
|
||||
LEFT JOIN send_queue sq ON sq.id=fve.delivery_queue_id
|
||||
WHERE fve.user_id=?
|
||||
ORDER BY fve.created_at DESC,fve.email`, userID)
|
||||
if err != nil {
|
||||
return settings, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var item ForwardingVerifiedEmail
|
||||
var verified int
|
||||
var created string
|
||||
var verifiedAt, sentAt, expiresAt sql.NullString
|
||||
if err := rows.Scan(&item.ID, &item.Email, &verified, &created, &verifiedAt, &sentAt, &expiresAt, &item.DeliveryStatus, &item.DeliveryError); err != nil {
|
||||
return settings, err
|
||||
}
|
||||
item.Verified = intBool(verified)
|
||||
item.CreatedAt = parseTime(created)
|
||||
item.VerifiedAt = nullableTime(verifiedAt)
|
||||
item.VerificationSentAt = nullableTime(sentAt)
|
||||
item.VerificationExpiresAt = nullableTime(expiresAt)
|
||||
settings.VerifiedEmails = append(settings.VerifiedEmails, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return settings, err
|
||||
}
|
||||
var accountTarget, accountTargetsJSON string
|
||||
err = a.db.QueryRowContext(ctx, `SELECT target_email,target_emails FROM account_forwarding_settings WHERE user_id=?`, userID).Scan(&accountTarget, &accountTargetsJSON)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return settings, err
|
||||
}
|
||||
settings.AccountTargetEmails = forwardingTargetsFromStored(accountTarget, accountTargetsJSON)
|
||||
settings.AccountTargetEmail = firstForwardingTarget(settings.AccountTargetEmails)
|
||||
rows, err = a.db.QueryContext(ctx, `SELECT mfs.mailbox_id,mfs.target_email,mfs.target_emails
|
||||
FROM mailbox_forwarding_settings mfs
|
||||
JOIN mailboxes mb ON mb.id=mfs.mailbox_id
|
||||
WHERE mb.user_id=? AND (mfs.target_email<>'' OR mfs.target_emails<>'[]')
|
||||
ORDER BY mb.address`, userID)
|
||||
if err != nil {
|
||||
return settings, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var item MailboxForwardingRule
|
||||
var target, targetsJSON string
|
||||
if err := rows.Scan(&item.MailboxID, &target, &targetsJSON); err != nil {
|
||||
return settings, err
|
||||
}
|
||||
item.TargetEmails = forwardingTargetsFromStored(target, targetsJSON)
|
||||
item.TargetEmail = firstForwardingTarget(item.TargetEmails)
|
||||
if len(item.TargetEmails) > 0 {
|
||||
settings.MailboxRules = append(settings.MailboxRules, item)
|
||||
}
|
||||
}
|
||||
return settings, rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) issueForwardingVerification(ctx context.Context, userID, id, email string, insert bool) error {
|
||||
now := a.now().UTC()
|
||||
expires := now.Add(forwardingVerificationTTL)
|
||||
token := randomToken()
|
||||
nowRaw := now.Format(time.RFC3339Nano)
|
||||
expiresRaw := expires.Format(time.RFC3339Nano)
|
||||
if insert {
|
||||
if _, err := a.db.ExecContext(ctx, `INSERT INTO forwarding_verified_emails(id,user_id,email,verified,verified_at,verification_token_hash,verification_sent_at,verification_expires_at,delivery_queue_id,delivery_status,delivery_error,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
id, userID, email, 0, nil, hashToken(token), nowRaw, expiresRaw, "", sendQueueStatusQueued, "", nowRaw, nowRaw); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE forwarding_verified_emails
|
||||
SET verified=0,verified_at=NULL,verification_token_hash=?,verification_sent_at=?,verification_expires_at=?,delivery_queue_id='',delivery_status=?,delivery_error='',updated_at=?
|
||||
WHERE id=? AND user_id=?`,
|
||||
hashToken(token), nowRaw, expiresRaw, sendQueueStatusQueued, nowRaw, id, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
queueID, err := a.sendForwardingVerificationEmail(ctx, userID, email, token, now)
|
||||
if err != nil {
|
||||
_, _ = a.db.ExecContext(ctx, `UPDATE forwarding_verified_emails SET delivery_status=?,delivery_error=?,updated_at=? WHERE id=? AND user_id=?`, sendQueueStatusFailed, err.Error(), nowRaw, id, userID)
|
||||
return nil
|
||||
}
|
||||
if queueID != "" {
|
||||
_, _ = a.db.ExecContext(ctx, `UPDATE forwarding_verified_emails SET delivery_queue_id=?,delivery_status=?,delivery_error='',updated_at=? WHERE id=? AND user_id=?`, queueID, sendQueueStatusQueued, nowRaw, id, userID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) sendForwardingVerificationEmail(ctx context.Context, userID, targetEmail, token string, now time.Time) (string, error) {
|
||||
if strings.TrimSpace(a.cfg.SMTPHost) == "" {
|
||||
return "", errors.New("SMTP 未配置,无法发送验证邮件")
|
||||
}
|
||||
mb, err := a.primaryMailboxForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fromDomain := domainPart(mb.Address)
|
||||
from := "noreply@" + fromDomain
|
||||
link := a.forwardingVerificationURL(token)
|
||||
text := "邮箱转发验证\n\n您正在将此邮箱添加为邮件转发目标地址。请打开以下链接完成验证:\n" + link + "\n\n此链接 24 小时内有效。如果您没有发起此操作,请忽略此邮件。"
|
||||
html := `<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif;color:#111827;line-height:1.6;padding:32px 24px">
|
||||
<div style="max-width:640px;margin:0 auto">
|
||||
<h1 style="font-size:28px;line-height:1.25;margin:0 0 28px;font-weight:700">邮箱转发验证</h1>
|
||||
<p style="font-size:17px;margin:0 0 28px">您正在将此邮箱添加为邮件转发目标地址。请点击下方按钮完成验证:</p>
|
||||
<p style="text-align:center;margin:0 0 34px"><a href="` + htmlEscape(link) + `" style="display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;border-radius:8px;padding:14px 38px;font-size:18px;font-weight:700">确认验证</a></p>
|
||||
<p style="font-size:15px;color:#6b7280;margin:0 0 12px">如果按钮无法点击,请复制以下链接到浏览器:</p>
|
||||
<p style="font-size:15px;color:#6b7280;word-break:break-all;margin:0 0 28px">` + htmlEscape(link) + `</p>
|
||||
<p style="font-size:15px;color:#9ca3af;margin:0">此链接 24 小时内有效。如果您没有发起此操作,请忽略此邮件。</p>
|
||||
</div></div>`
|
||||
messageID := fmt.Sprintf("<%s@%s>", newID("fwdverify"), fromDomain)
|
||||
mimeBytes, err := BuildMIME(MIMEMessage{From: from, FromName: "noreply", To: []string{targetEmail}, Subject: "邮箱转发验证", Text: text, HTML: html, MessageID: messageID, Date: now})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return a.enqueueSend(ctx, sendQueueInput{
|
||||
UserID: userID,
|
||||
MailboxID: mb.ID,
|
||||
MessageID: messageID,
|
||||
Source: sendSourceForwardingVerification,
|
||||
MailFrom: from,
|
||||
HeaderFrom: from,
|
||||
Recipients: []string{targetEmail},
|
||||
MIMEBytes: mimeBytes,
|
||||
Now: now,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) forwardingVerificationURL(token string) string {
|
||||
base := strings.TrimRight(strings.TrimSpace(a.cfg.PublicBaseURL), "/")
|
||||
if base == "" {
|
||||
base = "https://" + strings.Trim(strings.TrimSpace(a.cfg.PublicHostname), "/")
|
||||
}
|
||||
return base + "/api/verify-email?token=" + url.QueryEscape(token)
|
||||
}
|
||||
|
||||
func (a *App) renderForwardingVerificationPage(w http.ResponseWriter, status int, ok bool, email, message string) {
|
||||
title := "邮箱转发验证"
|
||||
heading := "验证失败"
|
||||
color := "#dc2626"
|
||||
if ok {
|
||||
heading = "验证完成"
|
||||
color = "#2563eb"
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
_, _ = fmt.Fprintf(w, `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>%s</title></head><body style="margin:0;background:#f8fafc;color:#0f172a;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Arial,sans-serif"><main style="min-height:100vh;display:grid;place-items:center;padding:24px"><section style="width:min(100%%,520px);background:white;border:1px solid #e2e8f0;border-radius:14px;padding:34px 30px;box-shadow:0 18px 45px rgba(15,23,42,.08)"><h1 style="margin:0 0 14px;font-size:28px">%s</h1><p style="margin:0 0 10px;font-size:17px;color:#475569">%s</p><p style="margin:0 0 26px;font-size:15px;color:#64748b">%s</p><a href="/" style="display:inline-block;border-radius:8px;background:%s;color:white;text-decoration:none;padding:12px 18px;font-weight:700">返回邮箱</a></section></main></body></html>`,
|
||||
title, heading, htmlEscape(message), htmlEscape(email), color)
|
||||
}
|
||||
|
||||
func (a *App) cleanForwardingVerificationEmail(w http.ResponseWriter, r *http.Request, userID, value string) (string, bool) {
|
||||
email := normalizeEmail(value)
|
||||
if email == "" || !strings.Contains(email, "@") {
|
||||
badRequest(w, errors.New("邮箱地址无效"))
|
||||
return "", false
|
||||
}
|
||||
if owns, err := a.userOwnsMailboxAddress(r.Context(), userID, email); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to check mailbox")
|
||||
return "", false
|
||||
} else if owns {
|
||||
badRequest(w, errors.New("不能把当前账号邮箱作为转发验证邮箱"))
|
||||
return "", false
|
||||
}
|
||||
return email, true
|
||||
}
|
||||
|
||||
func (a *App) forwardingVerifiedEmailState(ctx context.Context, userID, email string) (id string, verified bool, err error) {
|
||||
var verifiedInt int
|
||||
err = a.db.QueryRowContext(ctx, `SELECT id,verified FROM forwarding_verified_emails WHERE user_id=? AND email=?`, userID, normalizeEmail(email)).Scan(&id, &verifiedInt)
|
||||
return id, intBool(verifiedInt), err
|
||||
}
|
||||
|
||||
func (a *App) primaryMailboxForUser(ctx context.Context, userID string) (Mailbox, error) {
|
||||
var mb Mailbox
|
||||
var created string
|
||||
err := a.db.QueryRowContext(ctx, `SELECT id,user_id,domain_id,local_part,address,display_name,quota_mb,status,created_at
|
||||
FROM mailboxes WHERE user_id=? AND status='active' ORDER BY created_at,id LIMIT 1`, userID).
|
||||
Scan(&mb.ID, &mb.UserID, &mb.DomainID, &mb.LocalPart, &mb.Address, &mb.DisplayName, &mb.QuotaMB, &mb.Status, &created)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return mb, errors.New("当前账号没有可用于发送验证邮件的邮箱")
|
||||
}
|
||||
mb.CreatedAt = parseTime(created)
|
||||
return mb, err
|
||||
}
|
||||
|
||||
func (a *App) forwardingEmailVerified(ctx context.Context, userID, email string) (bool, error) {
|
||||
var count int
|
||||
err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM forwarding_verified_emails WHERE user_id=? AND email=? AND verified=1`, userID, normalizeEmail(email)).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func forwardingTargetsFromRequest(targetEmail string, targetEmails []string) []string {
|
||||
if len(targetEmails) > 0 {
|
||||
return targetEmails
|
||||
}
|
||||
if strings.TrimSpace(targetEmail) == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{targetEmail}
|
||||
}
|
||||
|
||||
func (a *App) cleanForwardingTargets(ctx context.Context, userID string, values []string) ([]string, error) {
|
||||
targets := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || strings.EqualFold(value, "none") {
|
||||
continue
|
||||
}
|
||||
target := normalizeEmail(value)
|
||||
if target == "" || !strings.Contains(target, "@") {
|
||||
return nil, errors.New("转发邮箱无效")
|
||||
}
|
||||
if seen[target] {
|
||||
continue
|
||||
}
|
||||
ok, err := a.forwardingEmailVerified(ctx, userID, target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, errors.New("请先完成邮箱验证")
|
||||
}
|
||||
seen[target] = true
|
||||
targets = append(targets, target)
|
||||
}
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
func forwardingTargetsFromStored(targetEmail, targetsJSON string) []string {
|
||||
targets := dedupeEmails(jsonDecodeSlice(targetsJSON))
|
||||
if len(targets) > 0 {
|
||||
return targets
|
||||
}
|
||||
target := normalizeEmail(targetEmail)
|
||||
if target == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{target}
|
||||
}
|
||||
|
||||
func firstForwardingTarget(targets []string) string {
|
||||
if len(targets) == 0 {
|
||||
return ""
|
||||
}
|
||||
return targets[0]
|
||||
}
|
||||
|
||||
func removeForwardingTarget(targets []string, email string) []string {
|
||||
email = normalizeEmail(email)
|
||||
next := make([]string, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
if normalizeEmail(target) == email {
|
||||
continue
|
||||
}
|
||||
next = append(next, normalizeEmail(target))
|
||||
}
|
||||
return dedupeEmails(next)
|
||||
}
|
||||
|
||||
func (a *App) removeForwardingTargetFromSettings(ctx context.Context, tx *sql.Tx, userID, email, now string) error {
|
||||
var accountTarget, accountTargetsJSON string
|
||||
if err := tx.QueryRowContext(ctx, `SELECT target_email,target_emails FROM account_forwarding_settings WHERE user_id=?`, userID).Scan(&accountTarget, &accountTargetsJSON); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
} else if err == nil {
|
||||
targets := removeForwardingTarget(forwardingTargetsFromStored(accountTarget, accountTargetsJSON), email)
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE account_forwarding_settings SET target_email=?,target_emails=?,updated_at=? WHERE user_id=?`, firstForwardingTarget(targets), jsonEncode(targets), now, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := tx.QueryContext(ctx, `SELECT mfs.mailbox_id,mfs.target_email,mfs.target_emails
|
||||
FROM mailbox_forwarding_settings mfs
|
||||
JOIN mailboxes mb ON mb.id=mfs.mailbox_id
|
||||
WHERE mb.user_id=?`, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type mailboxRow struct {
|
||||
id string
|
||||
target string
|
||||
targetsJSON string
|
||||
}
|
||||
var items []mailboxRow
|
||||
for rows.Next() {
|
||||
var item mailboxRow
|
||||
if err := rows.Scan(&item.id, &item.target, &item.targetsJSON); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
targets := removeForwardingTarget(forwardingTargetsFromStored(item.target, item.targetsJSON), email)
|
||||
if len(targets) == 0 {
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM mailbox_forwarding_settings WHERE mailbox_id=?`, item.id); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE mailbox_forwarding_settings SET target_email=?,target_emails=?,updated_at=? WHERE mailbox_id=?`, firstForwardingTarget(targets), jsonEncode(targets), now, item.id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) userOwnsMailboxID(ctx context.Context, userID, mailboxID string) (bool, error) {
|
||||
var count int
|
||||
err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM mailboxes WHERE id=? AND user_id=? AND status='active'`, mailboxID, userID).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (a *App) userOwnsMailboxAddress(ctx context.Context, userID, address string) (bool, error) {
|
||||
var count int
|
||||
err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM mailboxes WHERE user_id=? AND address=? AND status='active'`, userID, normalizeEmail(address)).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
@@ -24,6 +24,10 @@ const mailMessagesPageSize = 30
|
||||
|
||||
const customFolderDefaultSortOrderBase = 100000
|
||||
|
||||
func isAllMailboxID(mailboxID string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(mailboxID), "all")
|
||||
}
|
||||
|
||||
type AttachmentInput struct {
|
||||
Filename string `json:"filename"`
|
||||
ContentType string `json:"contentType"`
|
||||
@@ -55,10 +59,14 @@ type storedMessage struct {
|
||||
|
||||
func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT mb.id,mb.user_id,mb.domain_id,mb.local_part,mb.address,mb.display_name,mb.quota_mb,mb.status,mb.created_at
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT mb.id,mb.user_id,mb.domain_id,mb.local_part,mb.address,mb.display_name,mb.quota_mb,mb.status,mb.created_at,
|
||||
COALESCE(SUM(CASE WHEN lower(f.name)='inbox' AND m.is_read=0 THEN 1 ELSE 0 END),0) AS unread_count
|
||||
FROM mailboxes mb
|
||||
JOIN domains d ON d.id=mb.domain_id
|
||||
LEFT JOIN folders f ON f.mailbox_id=mb.id
|
||||
LEFT JOIN messages m ON m.folder_id=f.id
|
||||
WHERE mb.user_id=? AND mb.status='active' AND d.status='active'
|
||||
GROUP BY mb.id,mb.user_id,mb.domain_id,mb.local_part,mb.address,mb.display_name,mb.quota_mb,mb.status,mb.created_at
|
||||
ORDER BY mb.address`, user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load mailboxes")
|
||||
@@ -69,7 +77,7 @@ func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
|
||||
for rows.Next() {
|
||||
var m Mailbox
|
||||
var created string
|
||||
if err := rows.Scan(&m.ID, &m.UserID, &m.DomainID, &m.LocalPart, &m.Address, &m.DisplayName, &m.QuotaMB, &m.Status, &created); err != nil {
|
||||
if err := rows.Scan(&m.ID, &m.UserID, &m.DomainID, &m.LocalPart, &m.Address, &m.DisplayName, &m.QuotaMB, &m.Status, &created, &m.UnreadCount); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan mailboxes")
|
||||
return
|
||||
}
|
||||
@@ -81,6 +89,10 @@ func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
|
||||
a.handleAllMailFolders(w, r)
|
||||
return
|
||||
}
|
||||
mb, err := a.mailboxForCurrentUser(r)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
@@ -118,6 +130,43 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (a *App) handleAllMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT 'all-' || lower(f.name),f.name,f.role,
|
||||
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
|
||||
COUNT(m.id) AS total,
|
||||
MIN(f.sort_order),MAX(f.uid_validity),MAX(f.uid_next),MAX(f.highest_modseq)
|
||||
FROM folders f
|
||||
JOIN mailboxes mb ON mb.id=f.mailbox_id
|
||||
LEFT JOIN messages m ON m.folder_id=f.id
|
||||
WHERE mb.user_id=? AND mb.status='active'
|
||||
GROUP BY f.name,f.role
|
||||
ORDER BY CASE
|
||||
WHEN lower(f.name)='inbox' THEN 1000
|
||||
WHEN lower(f.name)='sent' THEN 5000
|
||||
WHEN lower(f.name)='drafts' THEN 6000
|
||||
WHEN lower(f.name)='archive' THEN 7000
|
||||
WHEN lower(f.name)='spam' THEN 8000
|
||||
WHEN lower(f.name)='trash' THEN 9000
|
||||
ELSE MIN(f.sort_order)
|
||||
END, f.name`, user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load folders")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []MailFolder{}
|
||||
for rows.Next() {
|
||||
var f MailFolder
|
||||
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
||||
return
|
||||
}
|
||||
items = append(items, f)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (a *App) handleReorderMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||
mb, err := a.mailboxForCurrentUser(r)
|
||||
if err != nil {
|
||||
@@ -345,6 +394,29 @@ func (a *App) nextCustomFolderSortOrder(ctx context.Context, mailboxID string) (
|
||||
}
|
||||
|
||||
func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) {
|
||||
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
|
||||
user := currentUser(r)
|
||||
if labelID := strings.TrimSpace(r.URL.Query().Get("labelId")); labelID != "" {
|
||||
if !a.labelBelongsToUser(r.Context(), labelID, user.ID) {
|
||||
respondError(w, http.StatusNotFound, "label not found")
|
||||
return
|
||||
}
|
||||
a.respondMailMessageList(w, r, `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)`, []any{user.ID, labelID})
|
||||
return
|
||||
}
|
||||
folder := r.URL.Query().Get("folder")
|
||||
if folder == "" {
|
||||
folder = "Inbox"
|
||||
}
|
||||
if normalized, err := normalizeFolderNameForUser(folder); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
} else {
|
||||
folder = normalized
|
||||
}
|
||||
a.respondMailMessageList(w, r, `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND f.name=?`, []any{user.ID, folder})
|
||||
return
|
||||
}
|
||||
mb, err := a.mailboxForCurrentUser(r)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
@@ -377,6 +449,11 @@ func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) handleStarredMessages(w http.ResponseWriter, r *http.Request) {
|
||||
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
|
||||
user := currentUser(r)
|
||||
a.respondMailMessageList(w, r, `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND m.is_starred=1`, []any{user.ID})
|
||||
return
|
||||
}
|
||||
mb, err := a.mailboxForCurrentUser(r)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
@@ -394,9 +471,15 @@ func (a *App) respondMailMessageList(w http.ResponseWriter, r *http.Request, whe
|
||||
limit := mailMessagesPageSize
|
||||
|
||||
if q != "" {
|
||||
where += ` AND (m.subject LIKE ? OR m.from_addr LIKE ? OR m.from_name LIKE ? OR m.snippet LIKE ? OR m.body_text LIKE ?)`
|
||||
where += ` AND (m.subject LIKE ? OR m.from_addr LIKE ? OR m.from_name LIKE ? OR m.to_addrs LIKE ? OR m.cc_addrs LIKE ? OR m.recipient_addr LIKE ? OR m.snippet LIKE ? OR m.body_text LIKE ?)`
|
||||
like := "%" + q + "%"
|
||||
args = append(args, like, like, like, like, like)
|
||||
args = append(args, like, like, like, like, like, like, like, like)
|
||||
}
|
||||
var err error
|
||||
where, args, err = appendMailMessageSearchFilters(r, where, args)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
args = append(args, limit+1, offset)
|
||||
query := `SELECT m.id,m.mailbox_id,m.folder_id,COALESCE(f.name,''),m.message_uid,m.imap_uid,m.imap_modseq,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
|
||||
@@ -428,7 +511,131 @@ func (a *App) respondMailMessageList(w http.ResponseWriter, r *http.Request, whe
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
|
||||
}
|
||||
|
||||
func appendMailMessageSearchFilters(r *http.Request, where string, args []any) (string, []any, error) {
|
||||
if from := strings.TrimSpace(r.URL.Query().Get("from")); from != "" {
|
||||
where += ` AND (m.from_addr LIKE ? OR m.from_name LIKE ?)`
|
||||
like := "%" + from + "%"
|
||||
args = append(args, like, like)
|
||||
}
|
||||
if to := strings.TrimSpace(r.URL.Query().Get("to")); to != "" {
|
||||
where += ` AND (m.to_addrs LIKE ? OR m.cc_addrs LIKE ? OR m.bcc_addrs LIKE ? OR m.recipient_addr LIKE ?)`
|
||||
like := "%" + to + "%"
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
if subject := strings.TrimSpace(r.URL.Query().Get("subject")); subject != "" {
|
||||
where += ` AND m.subject LIKE ?`
|
||||
args = append(args, "%"+subject+"%")
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("attachmentMode"))) {
|
||||
case "with":
|
||||
where += ` AND m.has_attachments=1`
|
||||
case "without":
|
||||
where += ` AND m.has_attachments=0`
|
||||
default:
|
||||
if mailSearchFlag(r, "hasAttachments") {
|
||||
where += ` AND m.has_attachments=1`
|
||||
}
|
||||
}
|
||||
if minSize, ok, err := mailSearchSizeBytes(r.URL.Query().Get("minSizeKb"), "minSizeKb"); err != nil {
|
||||
return where, args, err
|
||||
} else if ok {
|
||||
where += ` AND m.size_bytes>=?`
|
||||
args = append(args, minSize)
|
||||
}
|
||||
if maxSize, ok, err := mailSearchSizeBytes(r.URL.Query().Get("maxSizeKb"), "maxSizeKb"); err != nil {
|
||||
return where, args, err
|
||||
} else if ok {
|
||||
where += ` AND m.size_bytes<=?`
|
||||
args = append(args, maxSize)
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("readStatus"))) {
|
||||
case "read":
|
||||
where += ` AND m.is_read=1`
|
||||
case "unread":
|
||||
where += ` AND m.is_read=0`
|
||||
default:
|
||||
if mailSearchFlag(r, "unread") {
|
||||
where += ` AND m.is_read=0`
|
||||
}
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("flagStatus"))) {
|
||||
case "starred":
|
||||
where += ` AND m.is_starred=1`
|
||||
case "unstarred":
|
||||
where += ` AND m.is_starred=0`
|
||||
default:
|
||||
if mailSearchFlag(r, "starred") {
|
||||
where += ` AND m.is_starred=1`
|
||||
}
|
||||
}
|
||||
if start, ok, err := mailSearchDateBoundary(r.URL.Query().Get("startDate"), false); err != nil {
|
||||
return where, args, err
|
||||
} else if ok {
|
||||
where += ` AND m.received_at>=?`
|
||||
args = append(args, start)
|
||||
}
|
||||
if end, ok, err := mailSearchDateBoundary(r.URL.Query().Get("endDate"), true); err != nil {
|
||||
return where, args, err
|
||||
} else if ok {
|
||||
where += ` AND m.received_at<=?`
|
||||
args = append(args, end)
|
||||
}
|
||||
return where, args, nil
|
||||
}
|
||||
|
||||
func mailSearchFlag(r *http.Request, key string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get(key))) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func mailSearchDateBoundary(value string, endOfDay bool) (string, bool, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
if len(value) == len("2006-01-02") {
|
||||
t, err := time.Parse("2006-01-02", value)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("invalid date %q", value)
|
||||
}
|
||||
if endOfDay {
|
||||
t = t.AddDate(0, 0, 1).Add(-time.Nanosecond)
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339Nano), true, nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339Nano, value)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("invalid date %q", value)
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339Nano), true, nil
|
||||
}
|
||||
|
||||
func mailSearchSizeBytes(value string, key string) (int64, bool, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return 0, false, nil
|
||||
}
|
||||
kb, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || kb < 0 {
|
||||
return 0, false, fmt.Errorf("invalid %s %q", key, value)
|
||||
}
|
||||
return kb * 1024, true, nil
|
||||
}
|
||||
|
||||
func (a *App) handleMailLabels(w http.ResponseWriter, r *http.Request) {
|
||||
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
|
||||
labels, err := a.labelsForUser(r.Context(), currentUser(r).ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load labels")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": labels})
|
||||
return
|
||||
}
|
||||
mb, err := a.mailboxForCurrentUser(r)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
@@ -691,6 +898,14 @@ var errSenderNotAuthorized = errors.New("sender address is not authorized")
|
||||
var errMailboxQuotaExceeded = errors.New("mailbox quota exceeded")
|
||||
|
||||
func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mailComposeInput) (*MailMessage, error) {
|
||||
return a.sendMailWithSource(ctx, user, mb, req, sendSourceWebmail)
|
||||
}
|
||||
|
||||
func (a *App) sendMailWithSource(ctx context.Context, user *User, mb *Mailbox, req mailComposeInput, source string) (*MailMessage, error) {
|
||||
source = strings.TrimSpace(source)
|
||||
if source == "" {
|
||||
source = sendSourceWebmail
|
||||
}
|
||||
if err := validateAttachmentLimit(req.Attachments, userLimits(user)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -742,8 +957,8 @@ func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mail
|
||||
a.deleteMessage(ctx, sentID)
|
||||
return nil, fmt.Errorf("failed to store sent message in maildir: %w", err)
|
||||
}
|
||||
a.recordSendAudit(ctx, sendAuditAccepted, sendQueueStatusQueued, sendAuditInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, Source: sendSourceWebmail, MailFrom: fromAddress, HeaderFrom: fromAddress, Recipients: allRecipients})
|
||||
if _, err := a.enqueueSend(ctx, sendQueueInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, MessageID: messageID, Source: sendSourceWebmail, MailFrom: fromAddress, HeaderFrom: fromAddress, Recipients: allRecipients, MIMEBytes: mimeBytes, Now: now}); err != nil {
|
||||
a.recordSendAudit(ctx, sendAuditAccepted, sendQueueStatusQueued, sendAuditInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, Source: source, MailFrom: fromAddress, HeaderFrom: fromAddress, Recipients: allRecipients})
|
||||
if _, err := a.enqueueSend(ctx, sendQueueInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, MessageID: messageID, Source: source, MailFrom: fromAddress, HeaderFrom: fromAddress, Recipients: allRecipients, MIMEBytes: mimeBytes, Now: now}); err != nil {
|
||||
a.deleteMessage(ctx, sentID)
|
||||
return nil, fmt.Errorf("failed to enqueue delivery: %w", err)
|
||||
}
|
||||
@@ -796,6 +1011,7 @@ func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mail
|
||||
if inboxMsgID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
|
||||
_ = a.writeStoredMessageToMaildir(ctx, inboxMsgID, copyMsg, req.Attachments)
|
||||
a.applyInboundControls(ctx, inboxMsgID, rcptMailbox.ID, copyMsg.From, copyMsg.Subject)
|
||||
a.processInboundForwarding(ctx, inboxMsgID, rcptMailbox.ID, mimeBytes)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1113,15 +1329,23 @@ func (a *App) handleDeleteDraft(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) handleScheduledSends(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
mb, err := a.mailboxForCurrentUser(r)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
|
||||
args := []any{user.ID}
|
||||
where := `user_id=?`
|
||||
if !isAllMailboxID(mailboxID) {
|
||||
mb, err := a.mailboxForCurrentUser(r)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
where += ` AND mailbox_id=?`
|
||||
args = append(args, mb.ID)
|
||||
}
|
||||
args = append(args, "pending", "sending", "failed")
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,mailbox_id,draft_id,payload_json,send_at,status,error,created_at,updated_at,sent_at
|
||||
FROM scheduled_sends
|
||||
WHERE user_id=? AND mailbox_id=? AND status IN ('pending','sending','failed')
|
||||
ORDER BY send_at ASC, created_at DESC`, user.ID, mb.ID)
|
||||
WHERE `+where+` AND status IN (?,?,?)
|
||||
ORDER BY send_at ASC, created_at DESC`, args...)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load scheduled sends")
|
||||
return
|
||||
@@ -1158,20 +1382,28 @@ func (a *App) handleScheduledSends(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) handleSendQueue(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
mb, err := a.mailboxForCurrentUser(r)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
|
||||
status := strings.TrimSpace(r.URL.Query().Get("status"))
|
||||
if strings.EqualFold(status, "all") {
|
||||
status = ""
|
||||
}
|
||||
cursorCreatedAt, cursorID, offsetCursor, err := parseSendQueueCursor(r.URL.Query().Get("cursor"))
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
limit := 30
|
||||
args := []any{user.ID, mb.ID}
|
||||
where := `mb.user_id=? AND sq.mailbox_id=?`
|
||||
args := []any{user.ID}
|
||||
where := `mb.user_id=?`
|
||||
if !isAllMailboxID(mailboxID) {
|
||||
mb, err := a.mailboxForCurrentUser(r)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
where += ` AND sq.mailbox_id=?`
|
||||
args = append(args, mb.ID)
|
||||
}
|
||||
if status != "" {
|
||||
if !validSendQueueStatus(status) {
|
||||
badRequest(w, errors.New("invalid send queue status"))
|
||||
@@ -1894,13 +2126,18 @@ func (a *App) handleAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) handleAdminAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
attID := chi.URLParam(r, "id")
|
||||
row := a.db.QueryRowContext(r.Context(), `SELECT filename,content_type,size_bytes,storage_path FROM attachments WHERE id=?`, attID)
|
||||
var filename, contentType, path string
|
||||
row := a.db.QueryRowContext(r.Context(), `SELECT a.filename,a.content_type,a.size_bytes,a.storage_path,COALESCE(m.mailbox_id,'') FROM attachments a JOIN messages m ON m.id=a.message_id WHERE a.id=?`, attID)
|
||||
var filename, contentType, path, mailboxID string
|
||||
var size int64
|
||||
if err := row.Scan(&filename, &contentType, &size, &path); err != nil {
|
||||
if err := row.Scan(&filename, &contentType, &size, &path, &mailboxID); err != nil {
|
||||
respondError(w, http.StatusNotFound, "attachment not found")
|
||||
return
|
||||
}
|
||||
user := currentUser(r)
|
||||
if mailboxID == "" && (user == nil || user.Role != "admin") {
|
||||
respondError(w, http.StatusForbidden, "system admin required")
|
||||
return
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "attachment file missing")
|
||||
@@ -2161,6 +2398,29 @@ func (a *App) labelsForMailbox(ctx context.Context, mailboxID string) ([]MailLab
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) labelsForUser(ctx context.Context, userID string) ([]MailLabel, error) {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT l.id,l.mailbox_id,l.name,l.color,COUNT(ml.message_id)
|
||||
FROM mail_labels l
|
||||
JOIN mailboxes mb ON mb.id=l.mailbox_id
|
||||
LEFT JOIN message_labels ml ON ml.label_id=l.id
|
||||
WHERE mb.user_id=? AND mb.status='active'
|
||||
GROUP BY l.id,l.mailbox_id,l.name,l.color
|
||||
ORDER BY lower(l.name)`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []MailLabel{}
|
||||
for rows.Next() {
|
||||
var item MailLabel
|
||||
if err := rows.Scan(&item.ID, &item.MailboxID, &item.Name, &item.Color, &item.MessageCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) labelsForMessage(ctx context.Context, messageID string) ([]MailLabel, error) {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT l.id,l.mailbox_id,l.name,l.color
|
||||
FROM mail_labels l JOIN message_labels ml ON ml.label_id=l.id
|
||||
@@ -2251,6 +2511,12 @@ func (a *App) labelBelongsToMailbox(ctx context.Context, labelID, mailboxID stri
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func (a *App) labelBelongsToUser(ctx context.Context, labelID, userID string) bool {
|
||||
var count int
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM mail_labels l JOIN mailboxes mb ON mb.id=l.mailbox_id WHERE l.id=? AND mb.user_id=?`, labelID, userID).Scan(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func normalizeLabelName(name string) string {
|
||||
name = strings.Join(strings.Fields(strings.TrimSpace(name)), " ")
|
||||
if len([]rune(name)) > 32 {
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxMailImportBytes int64 = 256 << 20
|
||||
|
||||
var exportFilenameUnsafe = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
|
||||
|
||||
func (a *App) handleExportMail(w http.ResponseWriter, r *http.Request) {
|
||||
ids, err := a.exportMessageIDs(r)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
respondError(w, http.StatusNotFound, "mailbox or label not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errSystemAdminRequired) {
|
||||
respondError(w, http.StatusForbidden, "system admin required")
|
||||
return
|
||||
}
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("mail-export-%s.zip", a.now().UTC().Format("20060102-150405"))
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
zw := zip.NewWriter(w)
|
||||
usedNames := make(map[string]int, len(ids))
|
||||
for index, id := range ids {
|
||||
raw, subject, err := a.rawMessageForExport(r.Context(), id)
|
||||
if err != nil {
|
||||
_ = zw.Close()
|
||||
return
|
||||
}
|
||||
entryName := uniqueExportFilename(exportMessageFilename(subject, id, index), usedNames)
|
||||
entry, err := zw.CreateHeader(&zip.FileHeader{Name: entryName, Method: zip.Deflate})
|
||||
if err != nil {
|
||||
_ = zw.Close()
|
||||
return
|
||||
}
|
||||
if _, err := entry.Write(raw); err != nil {
|
||||
_ = zw.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = zw.Close()
|
||||
}
|
||||
|
||||
var errSystemAdminRequired = errors.New("system admin required")
|
||||
|
||||
func (a *App) exportMessageIDs(r *http.Request) ([]string, error) {
|
||||
user := currentUser(r)
|
||||
if user == nil {
|
||||
return nil, errors.New("no user")
|
||||
}
|
||||
view := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("view")))
|
||||
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
|
||||
where := []string{}
|
||||
args := []any{}
|
||||
|
||||
if view == "unknown" {
|
||||
if user.Role != "admin" {
|
||||
return nil, errSystemAdminRequired
|
||||
}
|
||||
where = append(where, "m.mailbox_id IS NULL")
|
||||
} else {
|
||||
where = append(where, "EXISTS (SELECT 1 FROM mailboxes owner_mb WHERE owner_mb.id=m.mailbox_id AND owner_mb.user_id=? AND owner_mb.status='active')")
|
||||
args = append(args, user.ID)
|
||||
if mailboxID != "" && !isAllMailboxID(mailboxID) {
|
||||
if _, err := a.mailboxForCurrentUserWithID(r, mailboxID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
where = append(where, "m.mailbox_id=?")
|
||||
args = append(args, mailboxID)
|
||||
}
|
||||
switch view {
|
||||
case "", "folder":
|
||||
folder := strings.TrimSpace(r.URL.Query().Get("folder"))
|
||||
if folder == "" {
|
||||
folder = "Inbox"
|
||||
}
|
||||
normalized, err := normalizeFolderNameForUser(folder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
where = append(where, "f.name=?")
|
||||
args = append(args, normalized)
|
||||
case "starred":
|
||||
where = append(where, "m.is_starred=1")
|
||||
case "label":
|
||||
labelID := strings.TrimSpace(r.URL.Query().Get("labelId"))
|
||||
if labelID == "" || !a.labelBelongsToUser(r.Context(), labelID, user.ID) {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
where = append(where, "EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)")
|
||||
args = append(args, labelID)
|
||||
default:
|
||||
return nil, errors.New("unsupported mail view")
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT m.id FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE `+strings.Join(where, " AND ")+` ORDER BY m.received_at DESC,m.id`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
ids := []string{}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) rawMessageForExport(ctx context.Context, id string) ([]byte, string, error) {
|
||||
msg, err := a.storedMessageByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if msg.RawPath != "" {
|
||||
if ok, pathErr := a.pathIsUnderMaildirRoot(msg.RawPath); pathErr == nil && ok {
|
||||
if raw, readErr := os.ReadFile(msg.RawPath); readErr == nil {
|
||||
return raw, msg.Subject, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
attachments, err := a.attachmentInputsForMessage(ctx, id)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
raw, err := BuildMIME(MIMEMessage{
|
||||
From: msg.From, FromName: msg.FromName, To: msg.To, CC: msg.CC, BCC: msg.BCC,
|
||||
Subject: msg.Subject, Text: msg.BodyText, HTML: msg.BodyHTML, MessageID: msg.MessageID,
|
||||
Date: messageDate(msg), Attachments: attachments,
|
||||
})
|
||||
return raw, msg.Subject, err
|
||||
}
|
||||
|
||||
func exportMessageFilename(subject, id string, index int) string {
|
||||
name := exportFilenameUnsafe.ReplaceAllString(strings.TrimSpace(subject), "-")
|
||||
name = strings.Trim(name, ".-_")
|
||||
if name == "" {
|
||||
name = "message"
|
||||
}
|
||||
if len(name) > 80 {
|
||||
name = name[:80]
|
||||
}
|
||||
return fmt.Sprintf("%04d-%s-%s.eml", index+1, name, id)
|
||||
}
|
||||
|
||||
func uniqueExportFilename(name string, used map[string]int) string {
|
||||
used[name]++
|
||||
if used[name] == 1 {
|
||||
return name
|
||||
}
|
||||
base := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
return fmt.Sprintf("%s-%d%s", base, used[name], filepath.Ext(name))
|
||||
}
|
||||
|
||||
func (a *App) handleImportMail(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxMailImportBytes)
|
||||
if err := r.ParseMultipartForm(maxMailImportBytes); err != nil {
|
||||
respondError(w, http.StatusRequestEntityTooLarge, "import is too large")
|
||||
return
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
}
|
||||
mb, err := a.mailboxForCurrentUserWithID(r, r.FormValue("mailboxId"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
folderName := strings.TrimSpace(r.FormValue("folder"))
|
||||
if folderName == "" {
|
||||
folderName = "Inbox"
|
||||
}
|
||||
folderName, err = normalizeFolderNameForUser(folderName)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
folderID, err := a.ensureFolder(r.Context(), mb.ID, folderName)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load folder")
|
||||
return
|
||||
}
|
||||
files := r.MultipartForm.File["files"]
|
||||
if len(files) == 0 {
|
||||
files = r.MultipartForm.File["file"]
|
||||
}
|
||||
if len(files) == 0 {
|
||||
badRequest(w, errors.New("at least one EML or MBOX file is required"))
|
||||
return
|
||||
}
|
||||
|
||||
imported, skipped := 0, 0
|
||||
problems := []string{}
|
||||
maxMessageBytes := int64(a.cfg.SubmissionMaxMessageMB) * 1024 * 1024
|
||||
if maxMessageBytes <= 0 {
|
||||
maxMessageBytes = 35 * 1024 * 1024
|
||||
}
|
||||
for _, header := range files {
|
||||
messages, fileErr := readImportFile(header, maxMessageBytes)
|
||||
if fileErr != nil {
|
||||
skipped++
|
||||
problems = appendImportProblem(problems, fmt.Sprintf("%s: %v", header.Filename, fileErr))
|
||||
continue
|
||||
}
|
||||
for _, raw := range messages {
|
||||
if err := a.importRawMessage(r.Context(), mb, folderID, raw); err != nil {
|
||||
skipped++
|
||||
problems = appendImportProblem(problems, fmt.Sprintf("%s: %v", header.Filename, err))
|
||||
continue
|
||||
}
|
||||
imported++
|
||||
}
|
||||
}
|
||||
if imported == 0 && len(problems) > 0 {
|
||||
badRequest(w, errors.New(problems[0]))
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "imported": imported, "skipped": skipped, "errors": problems})
|
||||
}
|
||||
|
||||
func readImportFile(header *multipart.FileHeader, maxMessageBytes int64) ([][]byte, error) {
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
if ext != ".eml" && ext != ".mbox" {
|
||||
return nil, errors.New("only .eml and .mbox files are supported")
|
||||
}
|
||||
file, err := header.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
if ext == ".eml" {
|
||||
raw, err := io.ReadAll(io.LimitReader(file, maxMessageBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(raw)) > maxMessageBytes {
|
||||
return nil, fmt.Errorf("message exceeds %d MB", maxMessageBytes/(1024*1024))
|
||||
}
|
||||
if len(bytes.TrimSpace(raw)) == 0 {
|
||||
return nil, errors.New("message is empty")
|
||||
}
|
||||
return [][]byte{raw}, nil
|
||||
}
|
||||
return parseMBOX(file, maxMessageBytes)
|
||||
}
|
||||
|
||||
func parseMBOX(reader io.Reader, maxMessageBytes int64) ([][]byte, error) {
|
||||
scanner := bufio.NewScanner(reader)
|
||||
bufferSize := int(maxMessageBytes + 1024)
|
||||
if bufferSize < 64*1024 {
|
||||
bufferSize = 64 * 1024
|
||||
}
|
||||
scanner.Buffer(make([]byte, 64*1024), bufferSize)
|
||||
var current bytes.Buffer
|
||||
messages := [][]byte{}
|
||||
seenSeparator := false
|
||||
flush := func() error {
|
||||
raw := bytes.TrimSpace(current.Bytes())
|
||||
current.Reset()
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
if int64(len(raw)) > maxMessageBytes {
|
||||
return fmt.Errorf("message exceeds %d MB", maxMessageBytes/(1024*1024))
|
||||
}
|
||||
messages = append(messages, append([]byte(nil), raw...))
|
||||
return nil
|
||||
}
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if bytes.HasPrefix(line, []byte("From ")) {
|
||||
if seenSeparator {
|
||||
if err := flush(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
seenSeparator = true
|
||||
continue
|
||||
}
|
||||
if bytes.HasPrefix(line, []byte(">From ")) {
|
||||
line = line[1:]
|
||||
}
|
||||
current.Write(line)
|
||||
current.WriteString("\r\n")
|
||||
if int64(current.Len()) > maxMessageBytes {
|
||||
return nil, fmt.Errorf("message exceeds %d MB", maxMessageBytes/(1024*1024))
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := flush(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return nil, errors.New("MBOX contains no messages")
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func (a *App) importRawMessage(ctx context.Context, mb *Mailbox, folderID string, raw []byte) error {
|
||||
msg, attachments, err := a.parseMaildirMessage(raw, mb.Address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid message: %w", err)
|
||||
}
|
||||
msg.MailboxID = mb.ID
|
||||
msg.FolderID = folderID
|
||||
msg.RecipientAddr = mb.Address
|
||||
msg.RawPath = ""
|
||||
id, err := a.insertMessage(ctx, msg, attachments)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.writeRawMessageToMaildir(ctx, id, raw, false); err != nil {
|
||||
a.deleteMessage(ctx, id)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendImportProblem(items []string, problem string) []string {
|
||||
if len(items) >= 5 {
|
||||
return items
|
||||
}
|
||||
return append(items, problem)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseMBOXMultipleMessages(t *testing.T) {
|
||||
raw := strings.Join([]string{
|
||||
"From sender@example.com Mon Jan 1 00:00:00 2024",
|
||||
"From: sender@example.com",
|
||||
"To: first@example.com",
|
||||
"Subject: first",
|
||||
"",
|
||||
"first body",
|
||||
">From escaped body line",
|
||||
"From sender@example.com Tue Jan 2 00:00:00 2024",
|
||||
"From: sender@example.com",
|
||||
"To: second@example.com",
|
||||
"Subject: second",
|
||||
"",
|
||||
"second body",
|
||||
}, "\n")
|
||||
messages, err := parseMBOX(strings.NewReader(raw), 1<<20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("messages=%d", len(messages))
|
||||
}
|
||||
if !bytes.Contains(messages[0], []byte("Subject: first")) || !bytes.Contains(messages[0], []byte("From escaped body line")) {
|
||||
t.Fatalf("first message=%q", messages[0])
|
||||
}
|
||||
if !bytes.Contains(messages[1], []byte("Subject: second")) {
|
||||
t.Fatalf("second message=%q", messages[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailImportExportAndOwnership(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin login=%d", code)
|
||||
}
|
||||
var domains struct {
|
||||
Items []Domain `json:"items"`
|
||||
}
|
||||
if code := admin.do("GET", "/api/admin/domains", nil, &domains); code != http.StatusOK || len(domains.Items) == 0 {
|
||||
t.Fatalf("domains code=%d items=%d", code, len(domains.Items))
|
||||
}
|
||||
ownerMailbox := createTestMailbox(t, admin, domains.Items[0].ID, "transfer-owner", "Transfer Owner", "Password123!", nil)
|
||||
otherMailbox := createTestMailbox(t, admin, domains.Items[0].ID, "transfer-other", "Transfer Other", "Password123!", nil)
|
||||
owner := &testClient{t: t, server: ts}
|
||||
if code := owner.do("POST", "/api/auth/login", map[string]string{"email": ownerMailbox.Address, "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("owner login=%d", code)
|
||||
}
|
||||
|
||||
eml := []byte("From: sender@example.com\r\nTo: " + ownerMailbox.Address + "\r\nSubject: imported message\r\nMessage-ID: <imported@example.com>\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nhello import")
|
||||
var imported struct {
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
if code := doMailImport(t, owner, ownerMailbox.ID, "Inbox", map[string][]byte{"message.eml": eml}, &imported); code != http.StatusOK || imported.Imported != 1 || imported.Skipped != 0 {
|
||||
t.Fatalf("import code=%d response=%+v", code, imported)
|
||||
}
|
||||
|
||||
var list struct {
|
||||
Items []MailMessage `json:"items"`
|
||||
}
|
||||
if code := owner.do("GET", "/api/mail/messages?folder=Inbox&mailboxId="+ownerMailbox.ID, nil, &list); code != http.StatusOK || len(list.Items) != 1 || list.Items[0].Subject != "imported message" {
|
||||
t.Fatalf("list code=%d items=%+v", code, list.Items)
|
||||
}
|
||||
|
||||
status, archive := getMailExport(t, owner, "/api/mail/export?view=folder&folder=Inbox&mailboxId="+ownerMailbox.ID)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("export status=%d body=%q", status, archive)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(zr.File) != 1 {
|
||||
t.Fatalf("zip entries=%d", len(zr.File))
|
||||
}
|
||||
entry, err := zr.File[0].Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exported, err := io.ReadAll(entry)
|
||||
entry.Close()
|
||||
if err != nil || !bytes.Contains(exported, []byte("Subject: imported message")) {
|
||||
t.Fatalf("exported message err=%v raw=%q", err, exported)
|
||||
}
|
||||
|
||||
var denied map[string]any
|
||||
if code := doMailImport(t, owner, otherMailbox.ID, "Inbox", map[string][]byte{"message.eml": eml}, &denied); code != http.StatusNotFound {
|
||||
t.Fatalf("cross-mailbox import code=%d", code)
|
||||
}
|
||||
status, _ = getMailExport(t, owner, "/api/mail/export?view=unknown")
|
||||
if status != http.StatusForbidden {
|
||||
t.Fatalf("unknown export status=%d", status)
|
||||
}
|
||||
}
|
||||
|
||||
func doMailImport(t *testing.T, client *testClient, mailboxID, folder string, files map[string][]byte, out any) int {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
_ = writer.WriteField("mailboxId", mailboxID)
|
||||
_ = writer.WriteField("folder", folder)
|
||||
for name, content := range files {
|
||||
part, err := writer.CreateFormFile("files", name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := part.Write(content); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, client.server.URL+"/api/mail/import", &body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
if client.cookie != nil {
|
||||
req.AddCookie(client.cookie)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if out != nil {
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
t.Fatalf("decode import response: %v", err)
|
||||
}
|
||||
}
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
func getMailExport(t *testing.T, client *testClient, path string) (int, []byte) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodGet, client.server.URL+path, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if client.cookie != nil {
|
||||
req.AddCookie(client.cookie)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp.StatusCode, body
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
const googleTranslateEndpoint = "https://translate.googleapis.com/translate_a/single"
|
||||
@@ -23,6 +25,7 @@ type translateMailMessageRequest struct {
|
||||
|
||||
type translateMailMessageResponse struct {
|
||||
TranslatedText string `json:"translatedText"`
|
||||
TranslatedHTML string `json:"translatedHtml,omitempty"`
|
||||
SourceLanguage string `json:"sourceLanguage,omitempty"`
|
||||
TargetLanguage string `json:"targetLanguage"`
|
||||
Truncated bool `json:"truncated"`
|
||||
@@ -67,7 +70,11 @@ func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request)
|
||||
respondError(w, http.StatusBadGateway, "translation failed")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
|
||||
translatedHTML := ""
|
||||
if strings.TrimSpace(msg.BodyHTML) != "" {
|
||||
translatedHTML, _ = translateHTMLTextNodes(r.Context(), a.policy, msg.BodyHTML, target, maxChars)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
|
||||
}
|
||||
|
||||
func (a *App) handleTranslateExternalIMAPMessage(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -130,7 +137,76 @@ func (a *App) handleTranslateExternalIMAPMessage(w http.ResponseWriter, r *http.
|
||||
respondError(w, http.StatusBadGateway, "translation failed")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
|
||||
translatedHTML := ""
|
||||
if err == nil && strings.TrimSpace(stored.BodyHTML) != "" {
|
||||
translatedHTML, _ = translateHTMLTextNodes(r.Context(), a.policy, stored.BodyHTML, target, maxChars)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
|
||||
}
|
||||
|
||||
func translateHTMLTextNodes(ctx context.Context, policy *HTMLPolicy, bodyHTML, target string, maxChars int) (string, error) {
|
||||
nodes, err := html.ParseFragment(strings.NewReader(bodyHTML), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
remaining := maxChars
|
||||
var translateNode func(*html.Node) error
|
||||
translateNode = func(n *html.Node) error {
|
||||
if n.Type == html.ElementNode && shouldSkipHTMLTranslationElement(n.Data) {
|
||||
return nil
|
||||
}
|
||||
if n.Type == html.TextNode {
|
||||
text := strings.TrimSpace(n.Data)
|
||||
if text != "" && containsTranslatableLetter(text) && remaining > 0 {
|
||||
limited, _ := truncateRunes(text, remaining)
|
||||
remaining -= utf8.RuneCountInString(limited)
|
||||
translated, _, err := googleFreeTranslate(ctx, limited, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n.Data = strings.Replace(n.Data, text, translated, 1)
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
if err := translateNode(c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, n := range nodes {
|
||||
if err := translateNode(n); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
var b bytes.Buffer
|
||||
for _, n := range nodes {
|
||||
if err := html.Render(&b, n); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if policy != nil {
|
||||
return policy.Sanitize(b.String()), nil
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func shouldSkipHTMLTranslationElement(tag string) bool {
|
||||
switch strings.ToLower(tag) {
|
||||
case "script", "style", "code", "pre", "textarea":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func containsTranslatableLetter(value string) bool {
|
||||
for _, r := range value {
|
||||
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '\u4e00' && r <= '\u9fff') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeTranslateTarget(value string) string {
|
||||
|
||||
@@ -371,6 +371,7 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
|
||||
id, err := a.insertMessage(ctx, msg, attachments)
|
||||
if err == nil && strings.EqualFold(folder.Name, "Inbox") {
|
||||
a.applyInboundControls(ctx, id, mb.ID, msg.From, msg.Subject)
|
||||
a.processInboundForwarding(ctx, id, mb.ID, raw)
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
const deliveryWebhookMaxAge = 5 * time.Minute
|
||||
|
||||
type deliveryWebhookEvent struct {
|
||||
ID string `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
QueueID string `json:"queueId"`
|
||||
MessageID string `json:"messageId"`
|
||||
RFCMessageID string `json:"rfcMessageId"`
|
||||
Recipient string `json:"recipient"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason"`
|
||||
OccurredAt string `json:"occurredAt"`
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIDeliveryWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
secret := strings.TrimSpace(a.cfg.DeliveryWebhookSecret)
|
||||
if secret == "" {
|
||||
respondError(w, http.StatusServiceUnavailable, "delivery webhook is not configured")
|
||||
return
|
||||
}
|
||||
timestamp := strings.TrimSpace(r.Header.Get("X-LanQin-Timestamp"))
|
||||
signature := strings.TrimPrefix(strings.TrimSpace(r.Header.Get("X-LanQin-Signature")), "sha256=")
|
||||
unix, err := strconv.ParseInt(timestamp, 10, 64)
|
||||
if err != nil || signature == "" {
|
||||
respondError(w, http.StatusUnauthorized, "invalid webhook signature")
|
||||
return
|
||||
}
|
||||
signedAt := time.Unix(unix, 0)
|
||||
if delta := a.now().UTC().Sub(signedAt); delta < -deliveryWebhookMaxAge || delta > deliveryWebhookMaxAge {
|
||||
respondError(w, http.StatusUnauthorized, "webhook timestamp is outside the allowed window")
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
if err != nil {
|
||||
badRequest(w, errors.New("invalid webhook body"))
|
||||
return
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(timestamp + "."))
|
||||
_, _ = mac.Write(body)
|
||||
expected, err := hex.DecodeString(signature)
|
||||
if err != nil || !hmac.Equal(mac.Sum(nil), expected) {
|
||||
respondError(w, http.StatusUnauthorized, "invalid webhook signature")
|
||||
return
|
||||
}
|
||||
var payload struct {
|
||||
Events []deliveryWebhookEvent `json:"events"`
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(body)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&payload); err != nil || len(payload.Events) == 0 || len(payload.Events) > 100 {
|
||||
badRequest(w, errors.New("events must contain between 1 and 100 items"))
|
||||
return
|
||||
}
|
||||
accepted := 0
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to start delivery event transaction")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, event := range payload.Events {
|
||||
inserted, err := a.storeDeliveryEvent(r, tx, event)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if inserted {
|
||||
accepted++
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to store delivery events")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "accepted": accepted, "duplicates": len(payload.Events) - accepted})
|
||||
}
|
||||
|
||||
func (a *App) storeDeliveryEvent(r *http.Request, tx *sql.Tx, event deliveryWebhookEvent) (bool, error) {
|
||||
event.ID = strings.TrimSpace(event.ID)
|
||||
event.Provider = strings.ToLower(strings.TrimSpace(event.Provider))
|
||||
event.QueueID = strings.TrimSpace(event.QueueID)
|
||||
event.MessageID = strings.TrimSpace(event.MessageID)
|
||||
event.RFCMessageID = strings.TrimSpace(event.RFCMessageID)
|
||||
event.Recipient = normalizeEmail(event.Recipient)
|
||||
event.Status = strings.ToLower(strings.TrimSpace(event.Status))
|
||||
if event.ID == "" || len(event.ID) > 200 || event.Provider == "" || len(event.Provider) > 80 || event.Recipient == "" || len(event.Recipient) > 320 || len(event.Reason) > 2000 || !validDeliveryEventStatus(event.Status) {
|
||||
return false, errors.New("invalid delivery event")
|
||||
}
|
||||
if event.QueueID == "" && event.MessageID == "" && event.RFCMessageID == "" {
|
||||
return false, errors.New("queueId, messageId, or rfcMessageId is required")
|
||||
}
|
||||
occurredAt, err := time.Parse(time.RFC3339Nano, event.OccurredAt)
|
||||
if err != nil {
|
||||
return false, errors.New("occurredAt must be an RFC3339 timestamp")
|
||||
}
|
||||
var queueID, sentMessageID, rfcMessageID string
|
||||
err = tx.QueryRowContext(r.Context(), `SELECT id,sent_message_id,message_id FROM send_queue
|
||||
WHERE (?<>'' AND id=?) OR (?<>'' AND sent_message_id=?) OR (?<>'' AND message_id=?)
|
||||
ORDER BY created_at DESC LIMIT 1`, event.QueueID, event.QueueID, event.MessageID, event.MessageID, event.RFCMessageID, event.RFCMessageID).Scan(&queueID, &sentMessageID, &rfcMessageID)
|
||||
if err != nil {
|
||||
return false, errors.New("send item not found")
|
||||
}
|
||||
if (event.QueueID != "" && event.QueueID != queueID) || (event.MessageID != "" && event.MessageID != sentMessageID) || (event.RFCMessageID != "" && event.RFCMessageID != rfcMessageID) {
|
||||
return false, errors.New("delivery event identifiers do not refer to the same send item")
|
||||
}
|
||||
var recipientsJSON string
|
||||
if err := tx.QueryRowContext(r.Context(), `SELECT recipients_json FROM send_queue WHERE id=?`, queueID).Scan(&recipientsJSON); err != nil {
|
||||
return false, errors.New("send item not found")
|
||||
}
|
||||
foundRecipient := false
|
||||
for _, recipient := range jsonDecodeSlice(recipientsJSON) {
|
||||
if normalizeEmail(recipient) == event.Recipient {
|
||||
foundRecipient = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundRecipient {
|
||||
return false, errors.New("delivery event recipient does not belong to the send item")
|
||||
}
|
||||
id := newID("dev")
|
||||
createdAt := a.now().UTC()
|
||||
reason := strings.TrimSpace(event.Reason)
|
||||
res, err := tx.ExecContext(r.Context(), `INSERT OR IGNORE INTO delivery_events(id,external_id,provider,queue_id,sent_message_id,rfc_message_id,recipient,status,reason,occurred_at,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?)`, id, event.ID, event.Provider, queueID, sentMessageID, rfcMessageID, event.Recipient, event.Status, reason, occurredAt.UTC().Format(time.RFC3339Nano), createdAt.Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n > 0 {
|
||||
item := DeliveryEvent{ID: id, ExternalID: event.ID, Provider: event.Provider, QueueID: queueID, MessageID: sentMessageID, RFCMessageID: rfcMessageID, Recipient: event.Recipient, Status: event.Status, Reason: reason, OccurredAt: occurredAt.UTC(), CreatedAt: createdAt}
|
||||
var mailboxID string
|
||||
if err := tx.QueryRowContext(r.Context(), `SELECT mailbox_id FROM send_queue WHERE id=?`, queueID).Scan(&mailboxID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := a.enqueueStatusWebhook(r.Context(), tx, "delivery:"+event.Provider+":"+event.ID, "delivery."+event.Status, mailboxID, item); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
func validDeliveryEventStatus(status string) bool {
|
||||
switch status {
|
||||
case "delivered", "bounced", "complained", "rejected", "deferred":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIListSends(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
limit := parseOpenAPILimit(r, 30, 100)
|
||||
where := "mb.user_id=?"
|
||||
args := []any{user.ID}
|
||||
if mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId")); mailboxID != "" {
|
||||
where += " AND sq.mailbox_id=?"
|
||||
args = append(args, mailboxID)
|
||||
}
|
||||
if status := strings.TrimSpace(r.URL.Query().Get("status")); status != "" {
|
||||
if !validSendQueueStatus(status) {
|
||||
badRequest(w, errors.New("invalid send queue status"))
|
||||
return
|
||||
}
|
||||
where += " AND sq.status=?"
|
||||
args = append(args, status)
|
||||
}
|
||||
cursorCreatedAt, cursorID, _, err := parseSendQueueCursor(r.URL.Query().Get("cursor"))
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if cursorCreatedAt != "" {
|
||||
where += " AND (sq.created_at<? OR (sq.created_at=? AND sq.id<?))"
|
||||
args = append(args, cursorCreatedAt, cursorCreatedAt, cursorID)
|
||||
}
|
||||
args = append(args, limit+1)
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT sq.id,sq.mailbox_id,sq.sent_message_id,sq.message_id,COALESCE(m.subject,''),sq.source,sq.mail_from,sq.header_from,sq.recipients_json,sq.status,sq.attempt_count,sq.max_attempts,sq.next_attempt_at,sq.last_error,sq.created_at,sq.updated_at,sq.delivered_at
|
||||
FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id LEFT JOIN messages m ON m.id=sq.sent_message_id
|
||||
WHERE `+where+` ORDER BY sq.created_at DESC,sq.id DESC LIMIT ?`, args...)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list sends")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []openAPISendStatus{}
|
||||
for rows.Next() {
|
||||
item, err := scanSendQueueEntry(rows)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan sends")
|
||||
return
|
||||
}
|
||||
status := openAPISendStatusFromQueue(item, item.MailFrom)
|
||||
a.applyDeliveryStatus(r.Context(), &status)
|
||||
items = append(items, status)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list sends")
|
||||
return
|
||||
}
|
||||
next := ""
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
last := items[len(items)-1]
|
||||
next = encodeSendQueueCursor(last.CreatedAt, last.QueueID)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPISendEvents(w http.ResponseWriter, r *http.Request) {
|
||||
item, err := a.resolveOpenAPISendQueue(r, chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "send item not found")
|
||||
return
|
||||
}
|
||||
audit, err := a.sendAuditEvents(r, item.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load send events")
|
||||
return
|
||||
}
|
||||
delivery, err := a.deliveryEvents(r, item.SentMessageID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load delivery events")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"auditEvents": audit, "deliveryEvents": delivery})
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIRetrySend(w http.ResponseWriter, r *http.Request) {
|
||||
item, err := a.resolveOpenAPISendQueue(r, chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "send item not found")
|
||||
return
|
||||
}
|
||||
if item.Status != sendQueueStatusFailed {
|
||||
badRequest(w, errors.New("send item is not failed"))
|
||||
return
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
res, err := a.db.ExecContext(r.Context(), `UPDATE send_queue SET status=?,attempt_count=0,next_attempt_at=?,last_error='',updated_at=?,delivered_at=NULL WHERE id=? AND status=?`, sendQueueStatusQueued, now, now, item.ID, sendQueueStatusFailed)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to retry send item")
|
||||
return
|
||||
}
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
respondError(w, http.StatusConflict, "send item status changed")
|
||||
return
|
||||
}
|
||||
a.recordSendAudit(r.Context(), sendAuditRetry, sendQueueStatusQueued, sendAuditInputFromEntry(item, currentUser(r).ID, ""))
|
||||
updated, _ := a.loadSendQueueEntryForUser(r.Context(), item.ID, currentUser(r).ID)
|
||||
respondJSON(w, http.StatusOK, openAPISendStatusFromQueue(updated, updated.MailFrom))
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPICancelSend(w http.ResponseWriter, r *http.Request) {
|
||||
item, err := a.resolveOpenAPISendQueue(r, chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "send item not found")
|
||||
return
|
||||
}
|
||||
if item.Status != sendQueueStatusQueued && item.Status != sendQueueStatusFailed {
|
||||
badRequest(w, errors.New("send item cannot be canceled"))
|
||||
return
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
res, err := a.db.ExecContext(r.Context(), `UPDATE send_queue SET status=?,last_error='',updated_at=? WHERE id=? AND status IN (?,?)`, sendQueueStatusCanceled, now, item.ID, sendQueueStatusQueued, sendQueueStatusFailed)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to cancel send item")
|
||||
return
|
||||
}
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
respondError(w, http.StatusConflict, "send item status changed")
|
||||
return
|
||||
}
|
||||
a.recordSendAudit(r.Context(), sendAuditCanceled, sendQueueStatusCanceled, sendAuditInputFromEntry(item, currentUser(r).ID, ""))
|
||||
updated, _ := a.loadSendQueueEntryForUser(r.Context(), item.ID, currentUser(r).ID)
|
||||
respondJSON(w, http.StatusOK, openAPISendStatusFromQueue(updated, updated.MailFrom))
|
||||
}
|
||||
|
||||
func sendAuditInputFromEntry(item SendQueueEntry, userID, errorText string) sendAuditInput {
|
||||
return sendAuditInput{QueueID: item.ID, UserID: userID, MailboxID: item.MailboxID, SentMessageID: item.SentMessageID, Source: item.Source, MailFrom: item.MailFrom, HeaderFrom: item.HeaderFrom, Recipients: item.Recipients, Error: errorText}
|
||||
}
|
||||
|
||||
func (a *App) resolveOpenAPISendQueue(r *http.Request, id string) (SendQueueEntry, error) {
|
||||
user := currentUser(r)
|
||||
if item, err := a.loadSendQueueEntryForUser(r.Context(), strings.TrimSpace(id), user.ID); err == nil {
|
||||
return item, nil
|
||||
}
|
||||
return a.loadLatestSendQueueForMessage(r.Context(), strings.TrimSpace(id), user.ID)
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIMessage(w http.ResponseWriter, r *http.Request) {
|
||||
msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), true)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "message not found")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, msg)
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIListAliases(w http.ResponseWriter, r *http.Request) {
|
||||
limit := parseOpenAPILimit(r, 50, 100)
|
||||
sortValue, cursorID, err := parseOpenAPIListCursor(r.URL.Query().Get("cursor"))
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,domain_id,source,destination,enabled,created_at FROM aliases
|
||||
WHERE (?='' OR source>? OR (source=? AND id>?)) ORDER BY source,id LIMIT ?`, sortValue, sortValue, sortValue, cursorID, limit+1)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list aliases")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Alias{}
|
||||
for rows.Next() {
|
||||
item, err := scanOpenAPIAlias(rows)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan aliases")
|
||||
return
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list aliases")
|
||||
return
|
||||
}
|
||||
next := ""
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
last := items[len(items)-1]
|
||||
next = encodeOpenAPIListCursor(last.Source, last.ID)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIGetAlias(w http.ResponseWriter, r *http.Request) {
|
||||
item, err := scanOpenAPIAlias(a.db.QueryRowContext(r.Context(), `SELECT id,domain_id,source,destination,enabled,created_at FROM aliases WHERE id=?`, chi.URLParam(r, "id")))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "alias not found")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, item)
|
||||
}
|
||||
|
||||
type aliasScanner interface{ Scan(...any) error }
|
||||
|
||||
func scanOpenAPIAlias(row aliasScanner) (Alias, error) {
|
||||
var item Alias
|
||||
var enabled int
|
||||
var created string
|
||||
err := row.Scan(&item.ID, &item.DomainID, &item.Source, &item.Destination, &enabled, &created)
|
||||
item.Enabled = intBool(enabled)
|
||||
item.CreatedAt = parseTime(created)
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (a *App) sendAuditEvents(r *http.Request, queueID string) ([]SendAuditEvent, error) {
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,queue_id,mailbox_id,sent_message_id,source,event,status,mail_from,header_from,recipients_json,error,created_at FROM send_audit_events WHERE queue_id=? ORDER BY created_at,id`, queueID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []SendAuditEvent{}
|
||||
for rows.Next() {
|
||||
var item SendAuditEvent
|
||||
var recipientsJSON, createdAt string
|
||||
if err := rows.Scan(&item.ID, &item.QueueID, &item.MailboxID, &item.SentMessageID, &item.Source, &item.Event, &item.Status, &item.MailFrom, &item.HeaderFrom, &recipientsJSON, &item.Error, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Recipients = jsonDecodeSlice(recipientsJSON)
|
||||
item.CreatedAt = parseTime(createdAt)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) deliveryEvents(r *http.Request, sentMessageID string) ([]DeliveryEvent, error) {
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,external_id,provider,queue_id,sent_message_id,rfc_message_id,recipient,status,reason,occurred_at,created_at FROM delivery_events WHERE sent_message_id=? ORDER BY occurred_at,id`, sentMessageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []DeliveryEvent{}
|
||||
for rows.Next() {
|
||||
var item DeliveryEvent
|
||||
var occurredAt, createdAt string
|
||||
if err := rows.Scan(&item.ID, &item.ExternalID, &item.Provider, &item.QueueID, &item.MessageID, &item.RFCMessageID, &item.Recipient, &item.Status, &item.Reason, &occurredAt, &createdAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.OccurredAt = parseTime(occurredAt)
|
||||
item.CreatedAt = parseTime(createdAt)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,937 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func (a *App) handleOpenAPIListDomains(w http.ResponseWriter, r *http.Request) {
|
||||
limit := parseOpenAPILimit(r, 50, 100)
|
||||
sortValue, cursorID, err := parseOpenAPIListCursor(r.URL.Query().Get("cursor"))
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,status,dkim_selector,dkim_public_key,dns_status,dns_checked_at,created_at FROM domains
|
||||
WHERE (?='' OR name>? OR (name=? AND id>?)) ORDER BY name,id LIMIT ?`, sortValue, sortValue, sortValue, cursorID, limit+1)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list domains")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Domain{}
|
||||
for rows.Next() {
|
||||
item, err := scanDomain(rows)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan domains")
|
||||
return
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list domains")
|
||||
return
|
||||
}
|
||||
next := ""
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
last := items[len(items)-1]
|
||||
next = encodeOpenAPIListCursor(last.Name, last.ID)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPICreateDomain(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
id, err := a.createDomainTx(r.Context(), nil, req.Name)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
domain, err := a.domainByID(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load domain")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, domain)
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIGetDomain(w http.ResponseWriter, r *http.Request) {
|
||||
domain, err := a.domainByID(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "domain not found")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, domain)
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIUpdateDomain(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
status := strings.TrimSpace(req.Status)
|
||||
if status != "active" && status != "disabled" {
|
||||
badRequest(w, errors.New("invalid status"))
|
||||
return
|
||||
}
|
||||
id := chi.URLParam(r, "id")
|
||||
res, err := a.db.ExecContext(r.Context(), `UPDATE domains SET status=?, updated_at=? WHERE id=?`, status, a.now().UTC().Format(time.RFC3339Nano), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update domain")
|
||||
return
|
||||
}
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
respondError(w, http.StatusNotFound, "domain not found")
|
||||
return
|
||||
}
|
||||
domain, err := a.domainByID(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load domain")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, domain)
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var count int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM mailboxes WHERE domain_id=?`, id).Scan(&count); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to check domain")
|
||||
return
|
||||
}
|
||||
if count > 0 {
|
||||
badRequest(w, errors.New("domain still has mailboxes"))
|
||||
return
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `DELETE FROM domains WHERE id=?`, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete domain")
|
||||
return
|
||||
}
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
respondError(w, http.StatusNotFound, "domain not found")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIListMailboxes(w http.ResponseWriter, r *http.Request) {
|
||||
limit := parseOpenAPILimit(r, 50, 100)
|
||||
sortValue, cursorID, err := parseOpenAPIListCursor(r.URL.Query().Get("cursor"))
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT mb.id,mb.user_id,u.email,mb.domain_id,mb.local_part,mb.address,mb.display_name,mb.quota_mb,mb.status,mb.created_at
|
||||
FROM mailboxes mb JOIN users u ON u.id=mb.user_id
|
||||
WHERE (?='' OR mb.address>? OR (mb.address=? AND mb.id>?)) ORDER BY mb.address,mb.id LIMIT ?`, sortValue, sortValue, sortValue, cursorID, limit+1)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list mailboxes")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []Mailbox{}
|
||||
for rows.Next() {
|
||||
item, err := scanMailbox(rows)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan mailboxes")
|
||||
return
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list mailboxes")
|
||||
return
|
||||
}
|
||||
next := ""
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
last := items[len(items)-1]
|
||||
next = encodeOpenAPIListCursor(last.Address, last.ID)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPICreateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
DomainID string `json:"domainId"`
|
||||
LocalPart string `json:"localPart"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Password string `json:"password"`
|
||||
QuotaMB int `json:"quotaMb"`
|
||||
OwnerEmail string `json:"ownerEmail"`
|
||||
UserID string `json:"userId"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if err := requireString("domainId", req.DomainID); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if err := requireString("localPart", req.LocalPart); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
||||
return
|
||||
}
|
||||
domain, err := a.domainByID(r.Context(), req.DomainID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "domain not found")
|
||||
return
|
||||
}
|
||||
localPart := normalizeLocalPart(req.LocalPart)
|
||||
if localPart == "" {
|
||||
badRequest(w, errors.New("localPart is required"))
|
||||
return
|
||||
}
|
||||
address := localPart + "@" + domain.Name
|
||||
displayName := strings.TrimSpace(req.DisplayName)
|
||||
if displayName == "" {
|
||||
displayName = address
|
||||
}
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to hash password")
|
||||
return
|
||||
}
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to start transaction")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
userID, err := a.resolveMailboxOwnerTx(r.Context(), tx, req.UserID, req.OwnerEmail, address, displayName, string(passwordHash))
|
||||
if err != nil {
|
||||
respondMailboxOwnerError(w, err)
|
||||
return
|
||||
}
|
||||
mailboxID, err := a.createMailboxWithPasswordHashTx(r.Context(), tx, userID, req.DomainID, localPart, displayName, string(passwordHash), req.QuotaMB, "active")
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to create mailbox")
|
||||
return
|
||||
}
|
||||
mailbox, err := a.mailboxByID(r.Context(), mailboxID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load mailbox")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, mailbox)
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIGetMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
mailbox, err := a.mailboxByID(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, mailbox)
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIUpdateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
current, err := a.mailboxByID(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
QuotaMB int `json:"quotaMb"`
|
||||
Status string `json:"status"`
|
||||
UserID string `json:"userId"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
displayName := strings.TrimSpace(req.DisplayName)
|
||||
if displayName == "" {
|
||||
displayName = current.DisplayName
|
||||
}
|
||||
quotaMB := req.QuotaMB
|
||||
if quotaMB <= 0 {
|
||||
quotaMB = current.QuotaMB
|
||||
}
|
||||
status := strings.TrimSpace(req.Status)
|
||||
if status == "" {
|
||||
status = current.Status
|
||||
}
|
||||
if status != "active" && status != "disabled" {
|
||||
badRequest(w, errors.New("invalid status"))
|
||||
return
|
||||
}
|
||||
userID := strings.TrimSpace(req.UserID)
|
||||
if userID == "" {
|
||||
userID = current.UserID
|
||||
}
|
||||
if err := a.ensureActiveUserExists(r.Context(), userID); err != nil {
|
||||
respondMailboxOwnerError(w, err)
|
||||
return
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
|
||||
userID, displayName, quotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update mailbox")
|
||||
return
|
||||
}
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
mailbox, err := a.mailboxByID(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load mailbox")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, mailbox)
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var owner string
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT user_id FROM mailboxes WHERE id=?`, id).Scan(&owner); err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
current := currentUser(r)
|
||||
if current != nil && owner == current.ID {
|
||||
var count int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM mailboxes WHERE user_id=?`, owner).Scan(&count); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to check mailbox")
|
||||
return
|
||||
}
|
||||
if count <= 1 {
|
||||
badRequest(w, errors.New("cannot delete your last mailbox"))
|
||||
return
|
||||
}
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load mailbox messages")
|
||||
return
|
||||
}
|
||||
messageIDs := []string{}
|
||||
for rows.Next() {
|
||||
var messageID string
|
||||
if rows.Scan(&messageID) == nil {
|
||||
messageIDs = append(messageIDs, messageID)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
for _, messageID := range messageIDs {
|
||||
a.deleteMessage(r.Context(), messageID)
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete mailbox")
|
||||
return
|
||||
}
|
||||
if affected, _ := res.RowsAffected(); affected == 0 {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIResetMailboxPassword(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
||||
return
|
||||
}
|
||||
var userID string
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT user_id FROM mailboxes WHERE id=?`, chi.URLParam(r, "id")).Scan(&userID); err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to hash password")
|
||||
return
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to start transaction")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET password_hash=?,updated_at=? WHERE id=?`, string(hash), now, userID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to reset password")
|
||||
return
|
||||
}
|
||||
res, err := tx.ExecContext(r.Context(), `UPDATE mailboxes SET password_hash=?,updated_at=? WHERE user_id=?`, string(hash), now, userID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to reset mailbox passwords")
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to save password")
|
||||
return
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "affectedMailboxes": affected})
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPISendMail(w http.ResponseWriter, r *http.Request) {
|
||||
var req mailComposeInput
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
mb, err := a.mailboxForCurrentUserWithID(r, req.MailboxID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
idempotencyKey := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
|
||||
requestJSON, _ := json.Marshal(req)
|
||||
requestSum := sha256.Sum256(requestJSON)
|
||||
requestHash := hex.EncodeToString(requestSum[:])
|
||||
if idempotencyKey != "" {
|
||||
if len(idempotencyKey) > 128 || strings.ContainsAny(idempotencyKey, "\r\n") {
|
||||
badRequest(w, errors.New("invalid Idempotency-Key"))
|
||||
return
|
||||
}
|
||||
status, replayed, err := a.reserveOpenAPISendIdempotency(r.Context(), currentUser(r).ID, idempotencyKey, requestHash)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusConflict, err.Error())
|
||||
return
|
||||
}
|
||||
if replayed {
|
||||
w.Header().Set("Idempotency-Replayed", "true")
|
||||
respondJSON(w, http.StatusOK, status)
|
||||
return
|
||||
}
|
||||
}
|
||||
msg, err := a.sendMailWithSource(r.Context(), currentUser(r), mb, req, sendSourceOpenAPI)
|
||||
if err != nil {
|
||||
if idempotencyKey != "" {
|
||||
_, _ = a.db.ExecContext(r.Context(), `DELETE FROM send_idempotency_keys WHERE user_id=? AND idempotency_key=? AND sent_message_id=''`, currentUser(r).ID, idempotencyKey)
|
||||
}
|
||||
respondSendError(w, err)
|
||||
return
|
||||
}
|
||||
status := openAPISendStatusFromMessage(msg, mb.Address)
|
||||
if msg.SendQueueID != "" {
|
||||
if item, err := a.loadSendQueueEntryForUser(r.Context(), msg.SendQueueID, mb.UserID); err == nil {
|
||||
status = openAPISendStatusFromQueue(item, mb.Address)
|
||||
}
|
||||
} else {
|
||||
item, err := a.loadLatestSendQueueForMailboxMessage(r.Context(), msg.ID, mb.ID)
|
||||
if err == nil {
|
||||
status = openAPISendStatusFromQueue(item, mb.Address)
|
||||
}
|
||||
}
|
||||
a.applyDeliveryStatus(r.Context(), &status)
|
||||
if idempotencyKey != "" {
|
||||
_, _ = a.db.ExecContext(r.Context(), `UPDATE send_idempotency_keys SET sent_message_id=?,queue_id=? WHERE user_id=? AND idempotency_key=?`, status.MessageID, status.QueueID, currentUser(r).ID, idempotencyKey)
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, status)
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPISendStatus(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
id := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
item, err := a.loadSendQueueEntryForUser(r.Context(), id, user.ID)
|
||||
if err != nil {
|
||||
item, err = a.loadSendQueueEntryForSentMessage(r.Context(), id, user.ID)
|
||||
}
|
||||
if err == nil {
|
||||
mailboxAddress := ""
|
||||
if mb, mbErr := a.mailboxByID(r.Context(), item.MailboxID); mbErr == nil {
|
||||
mailboxAddress = mb.Address
|
||||
}
|
||||
status := openAPISendStatusFromQueue(item, mailboxAddress)
|
||||
a.applyDeliveryStatus(r.Context(), &status)
|
||||
respondJSON(w, http.StatusOK, status)
|
||||
return
|
||||
}
|
||||
msg, err := a.loadOpenAPISentMessageForUser(r.Context(), id, user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "send item not found")
|
||||
return
|
||||
}
|
||||
mailboxAddress := ""
|
||||
if mb, mbErr := a.mailboxByID(r.Context(), msg.MailboxID); mbErr == nil {
|
||||
mailboxAddress = mb.Address
|
||||
}
|
||||
respondJSON(w, http.StatusOK, openAPISendStatusFromMessage(msg, mailboxAddress))
|
||||
}
|
||||
|
||||
func (a *App) handleOpenAPIMailboxMessages(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
mailboxID := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
if _, err := a.mailboxForUserByID(r.Context(), user.ID, mailboxID); err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
limit := parseOpenAPILimit(r, 30, 100)
|
||||
cursorReceivedAt, cursorID, offset, err := parseOpenAPIMessageCursor(r.URL.Query().Get("cursor"))
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
folder := strings.TrimSpace(r.URL.Query().Get("folder"))
|
||||
if folder == "" {
|
||||
folder = "Inbox"
|
||||
}
|
||||
where := "m.mailbox_id=?"
|
||||
args := []any{mailboxID}
|
||||
if folder != "" && !strings.EqualFold(folder, "all") {
|
||||
where += " AND lower(f.name)=lower(?)"
|
||||
args = append(args, folder)
|
||||
}
|
||||
if q := strings.TrimSpace(r.URL.Query().Get("q")); q != "" {
|
||||
where += " AND (m.subject LIKE ? OR m.from_addr LIKE ? OR m.from_name LIKE ? OR m.to_addrs LIKE ? OR m.snippet LIKE ? OR m.body_text LIKE ?)"
|
||||
like := "%" + q + "%"
|
||||
args = append(args, like, like, like, like, like, like)
|
||||
}
|
||||
if cursorReceivedAt != "" {
|
||||
where += " AND (m.received_at<? OR (m.received_at=? AND m.id<?))"
|
||||
args = append(args, cursorReceivedAt, cursorReceivedAt, cursorID)
|
||||
}
|
||||
args = append(args, limit+1)
|
||||
query := `SELECT m.id,m.mailbox_id,m.folder_id,f.name,m.message_uid,m.imap_uid,m.imap_modseq,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
|
||||
FROM messages m JOIN folders f ON f.id=m.folder_id
|
||||
WHERE ` + where + `
|
||||
ORDER BY m.received_at DESC,m.id DESC LIMIT ?`
|
||||
if offset > 0 {
|
||||
query += " OFFSET ?"
|
||||
args = append(args, offset)
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), query, args...)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load messages")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []MailMessage{}
|
||||
for rows.Next() {
|
||||
item, err := scanMessageSummary(rows)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan messages")
|
||||
return
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load messages")
|
||||
return
|
||||
}
|
||||
nextCursor := ""
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
last := items[len(items)-1]
|
||||
nextCursor = encodeOpenAPIMessageCursor(last.ReceivedAt, last.ID)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": nextCursor})
|
||||
}
|
||||
|
||||
type domainScanner interface{ Scan(dest ...any) error }
|
||||
|
||||
func scanDomain(row domainScanner) (Domain, error) {
|
||||
var item Domain
|
||||
var checked sql.NullString
|
||||
var created string
|
||||
err := row.Scan(&item.ID, &item.Name, &item.Status, &item.DKIMSelector, &item.DKIMPublicKey, &item.DNSStatus, &checked, &created)
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
item.DNSCheckedAt = nullableTime(checked)
|
||||
item.CreatedAt = parseTime(created)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
type mailboxScanner interface{ Scan(dest ...any) error }
|
||||
|
||||
func scanMailbox(row mailboxScanner) (Mailbox, error) {
|
||||
var item Mailbox
|
||||
var created string
|
||||
err := row.Scan(&item.ID, &item.UserID, &item.UserEmail, &item.DomainID, &item.LocalPart, &item.Address, &item.DisplayName, &item.QuotaMB, &item.Status, &created)
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
item.CreatedAt = parseTime(created)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
type openAPISendStatus struct {
|
||||
ID string `json:"id"`
|
||||
QueueID string `json:"queueId,omitempty"`
|
||||
Status string `json:"status"`
|
||||
QueueStatus string `json:"queueStatus,omitempty"`
|
||||
MessageID string `json:"messageId"`
|
||||
RFCMessageID string `json:"rfcMessageId"`
|
||||
MailboxID string `json:"mailboxId"`
|
||||
MailboxAddress string `json:"mailboxAddress,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Recipients []string `json:"recipients,omitempty"`
|
||||
AttemptCount int `json:"attemptCount,omitempty"`
|
||||
MaxAttempts int `json:"maxAttempts,omitempty"`
|
||||
NextAttemptAt *time.Time `json:"nextAttemptAt,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt *time.Time `json:"updatedAt,omitempty"`
|
||||
DeliveredAt *time.Time `json:"deliveredAt,omitempty"`
|
||||
RecipientStatuses []openAPIRecipientStatus `json:"recipientStatuses,omitempty"`
|
||||
}
|
||||
|
||||
type openAPIRecipientStatus struct {
|
||||
Recipient string `json:"recipient"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
}
|
||||
|
||||
func openAPISendStatusFromQueue(item SendQueueEntry, mailboxAddress string) openAPISendStatus {
|
||||
status := item.Status
|
||||
if status == sendQueueStatusDelivered {
|
||||
status = "relayed"
|
||||
}
|
||||
return openAPISendStatus{
|
||||
ID: firstNonEmpty(item.SentMessageID, item.ID),
|
||||
QueueID: item.ID,
|
||||
Status: status,
|
||||
QueueStatus: item.Status,
|
||||
MessageID: item.SentMessageID,
|
||||
RFCMessageID: item.MessageID,
|
||||
MailboxID: item.MailboxID,
|
||||
MailboxAddress: mailboxAddress,
|
||||
Subject: item.Subject,
|
||||
Recipients: item.Recipients,
|
||||
AttemptCount: item.AttemptCount,
|
||||
MaxAttempts: item.MaxAttempts,
|
||||
NextAttemptAt: timePtr(item.NextAttemptAt),
|
||||
LastError: item.LastError,
|
||||
CreatedAt: item.CreatedAt,
|
||||
UpdatedAt: timePtr(item.UpdatedAt),
|
||||
DeliveredAt: item.DeliveredAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) reserveOpenAPISendIdempotency(ctx context.Context, userID, key, requestHash string) (openAPISendStatus, bool, error) {
|
||||
_, _ = a.db.ExecContext(ctx, `DELETE FROM send_idempotency_keys WHERE created_at<?`, a.now().UTC().Add(-24*time.Hour).Format(time.RFC3339Nano))
|
||||
res, err := a.db.ExecContext(ctx, `INSERT OR IGNORE INTO send_idempotency_keys(user_id,idempotency_key,request_hash,created_at) VALUES(?,?,?,?)`, userID, key, requestHash, a.now().UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return openAPISendStatus{}, false, err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
return openAPISendStatus{}, false, nil
|
||||
}
|
||||
var storedHash, sentMessageID, queueID string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT request_hash,sent_message_id,queue_id FROM send_idempotency_keys WHERE user_id=? AND idempotency_key=?`, userID, key).Scan(&storedHash, &sentMessageID, &queueID); err != nil {
|
||||
return openAPISendStatus{}, false, err
|
||||
}
|
||||
if storedHash != requestHash {
|
||||
return openAPISendStatus{}, false, errors.New("Idempotency-Key was already used with a different request")
|
||||
}
|
||||
if sentMessageID == "" {
|
||||
return openAPISendStatus{}, false, errors.New("a request with this Idempotency-Key is still processing")
|
||||
}
|
||||
item, err := a.loadSendQueueEntryForUser(ctx, queueID, userID)
|
||||
if err != nil {
|
||||
return openAPISendStatus{}, false, err
|
||||
}
|
||||
status := openAPISendStatusFromQueue(item, item.MailFrom)
|
||||
a.applyDeliveryStatus(ctx, &status)
|
||||
return status, true, nil
|
||||
}
|
||||
|
||||
func (a *App) applyDeliveryStatus(ctx context.Context, status *openAPISendStatus) {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT recipient,status,reason,provider,occurred_at FROM delivery_events
|
||||
WHERE sent_message_id=? ORDER BY occurred_at DESC,id DESC`, status.MessageID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
seen := map[string]bool{}
|
||||
counts := map[string]int{}
|
||||
for rows.Next() {
|
||||
var item openAPIRecipientStatus
|
||||
var occurredAt string
|
||||
if rows.Scan(&item.Recipient, &item.Status, &item.Reason, &item.Provider, &occurredAt) != nil || seen[item.Recipient] {
|
||||
continue
|
||||
}
|
||||
seen[item.Recipient] = true
|
||||
item.OccurredAt = parseTime(occurredAt)
|
||||
status.RecipientStatuses = append(status.RecipientStatuses, item)
|
||||
counts[item.Status]++
|
||||
}
|
||||
if len(status.RecipientStatuses) == 0 {
|
||||
return
|
||||
}
|
||||
if len(status.RecipientStatuses) < len(status.Recipients) || len(counts) > 1 {
|
||||
status.Status = "partial"
|
||||
return
|
||||
}
|
||||
for _, value := range []string{"complained", "bounced", "rejected", "deferred", "delivered"} {
|
||||
if counts[value] > 0 {
|
||||
status.Status = value
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type openAPIMessageCursor struct {
|
||||
ReceivedAt string `json:"receivedAt"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type openAPIListCursor struct {
|
||||
Sort string `json:"sort"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func encodeOpenAPIMessageCursor(receivedAt time.Time, id string) string {
|
||||
payload, _ := json.Marshal(openAPIMessageCursor{ReceivedAt: receivedAt.UTC().Format(time.RFC3339Nano), ID: id})
|
||||
return base64.RawURLEncoding.EncodeToString(payload)
|
||||
}
|
||||
|
||||
func parseOpenAPIMessageCursor(raw string) (receivedAt, id string, offset int, err error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", "", 0, nil
|
||||
}
|
||||
if n, convErr := strconv.Atoi(raw); convErr == nil {
|
||||
if n < 0 {
|
||||
return "", "", 0, errors.New("invalid cursor")
|
||||
}
|
||||
return "", "", n, nil
|
||||
}
|
||||
data, err := base64.RawURLEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return "", "", 0, errors.New("invalid cursor")
|
||||
}
|
||||
var cursor openAPIMessageCursor
|
||||
if err := json.Unmarshal(data, &cursor); err != nil || cursor.ReceivedAt == "" || cursor.ID == "" {
|
||||
return "", "", 0, errors.New("invalid cursor")
|
||||
}
|
||||
if _, err := time.Parse(time.RFC3339Nano, cursor.ReceivedAt); err != nil {
|
||||
return "", "", 0, errors.New("invalid cursor")
|
||||
}
|
||||
return cursor.ReceivedAt, cursor.ID, 0, nil
|
||||
}
|
||||
|
||||
func encodeOpenAPIListCursor(sortValue, id string) string {
|
||||
payload, _ := json.Marshal(openAPIListCursor{Sort: sortValue, ID: id})
|
||||
return base64.RawURLEncoding.EncodeToString(payload)
|
||||
}
|
||||
|
||||
func parseOpenAPIListCursor(raw string) (sortValue, id string, err error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
data, err := base64.RawURLEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return "", "", errors.New("invalid cursor")
|
||||
}
|
||||
var cursor openAPIListCursor
|
||||
if err := json.Unmarshal(data, &cursor); err != nil || cursor.Sort == "" || cursor.ID == "" {
|
||||
return "", "", errors.New("invalid cursor")
|
||||
}
|
||||
return cursor.Sort, cursor.ID, nil
|
||||
}
|
||||
|
||||
func openAPISendStatusFromMessage(msg *MailMessage, mailboxAddress string) openAPISendStatus {
|
||||
recipients := append(append([]string{}, msg.To...), msg.CC...)
|
||||
recipients = append(recipients, msg.BCC...)
|
||||
return openAPISendStatus{
|
||||
ID: msg.ID,
|
||||
Status: sendAuditAccepted,
|
||||
MessageID: msg.ID,
|
||||
RFCMessageID: msg.MessageID,
|
||||
MailboxID: msg.MailboxID,
|
||||
MailboxAddress: mailboxAddress,
|
||||
Subject: msg.Subject,
|
||||
Recipients: dedupeEmails(recipients),
|
||||
CreatedAt: msg.ReceivedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func timePtr(t time.Time) *time.Time {
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return &t
|
||||
}
|
||||
|
||||
func (a *App) resolveMailboxOwnerTx(ctx context.Context, tx *sql.Tx, userID, ownerEmail, address, displayName, passwordHash string) (string, error) {
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID != "" {
|
||||
var disabled int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", errNotFound
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if intBool(disabled) {
|
||||
return "", errors.New("owner user is disabled")
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
email := normalizeEmail(ownerEmail)
|
||||
if email == "" {
|
||||
email = address
|
||||
}
|
||||
if !strings.Contains(email, "@") {
|
||||
return "", errors.New("invalid owner email")
|
||||
}
|
||||
var existing string
|
||||
err := tx.QueryRowContext(ctx, `SELECT id FROM users WHERE (login_name=? OR email=?) AND disabled=0`, email, email).Scan(&existing)
|
||||
if err == nil {
|
||||
return existing, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return "", err
|
||||
}
|
||||
userID = newID("usr")
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
if displayName == "" {
|
||||
displayName = email
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO users(id,login_name,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)`, userID, email, email, displayName, "user", passwordHash, 0, now, now)
|
||||
return userID, err
|
||||
}
|
||||
|
||||
func (a *App) ensureActiveUserExists(ctx context.Context, userID string) error {
|
||||
var disabled int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return errNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if intBool(disabled) {
|
||||
return errors.New("owner user is disabled")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func respondMailboxOwnerError(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, errNotFound) {
|
||||
respondError(w, http.StatusNotFound, "owner user not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func respondSendError(w http.ResponseWriter, err error) {
|
||||
switch {
|
||||
case errors.Is(err, errNoRecipients), errors.Is(err, errInvalidMIME), errors.Is(err, errAttachmentTooLarge):
|
||||
badRequest(w, err)
|
||||
case errors.Is(err, errSMTPRateLimited):
|
||||
respondError(w, http.StatusTooManyRequests, err.Error())
|
||||
case errors.Is(err, errSenderNotAuthorized):
|
||||
respondError(w, http.StatusForbidden, err.Error())
|
||||
case errors.Is(err, errMailboxQuotaExceeded):
|
||||
respondError(w, http.StatusInsufficientStorage, err.Error())
|
||||
default:
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) loadLatestSendQueueForMessage(ctx context.Context, sentMessageID, userID string) (SendQueueEntry, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT sq.id,sq.mailbox_id,sq.sent_message_id,sq.message_id,COALESCE(m.subject,''),sq.source,sq.mail_from,sq.header_from,sq.recipients_json,sq.status,sq.attempt_count,sq.max_attempts,sq.next_attempt_at,sq.last_error,sq.created_at,sq.updated_at,sq.delivered_at
|
||||
FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id LEFT JOIN messages m ON m.id=sq.sent_message_id
|
||||
WHERE sq.sent_message_id=? AND mb.user_id=? ORDER BY sq.created_at DESC, sq.id DESC LIMIT 1`, sentMessageID, userID)
|
||||
return scanSendQueueEntry(row)
|
||||
}
|
||||
|
||||
func (a *App) loadLatestSendQueueForMailboxMessage(ctx context.Context, sentMessageID, mailboxID string) (SendQueueEntry, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT sq.id,sq.mailbox_id,sq.sent_message_id,sq.message_id,COALESCE(m.subject,''),sq.source,sq.mail_from,sq.header_from,sq.recipients_json,sq.status,sq.attempt_count,sq.max_attempts,sq.next_attempt_at,sq.last_error,sq.created_at,sq.updated_at,sq.delivered_at
|
||||
FROM send_queue sq LEFT JOIN messages m ON m.id=sq.sent_message_id
|
||||
WHERE sq.sent_message_id=? AND sq.mailbox_id=? ORDER BY sq.created_at DESC, sq.id DESC LIMIT 1`, sentMessageID, mailboxID)
|
||||
return scanSendQueueEntry(row)
|
||||
}
|
||||
|
||||
func (a *App) loadSendQueueEntryForSentMessage(ctx context.Context, sentMessageID, userID string) (SendQueueEntry, error) {
|
||||
return a.loadLatestSendQueueForMessage(ctx, sentMessageID, userID)
|
||||
}
|
||||
|
||||
func (a *App) loadOpenAPISentMessageForUser(ctx context.Context, id, userID string) (*MailMessage, error) {
|
||||
var messageID string
|
||||
err := a.db.QueryRowContext(ctx, `SELECT m.id
|
||||
FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id JOIN folders f ON f.id=m.folder_id
|
||||
WHERE (m.id=? OR m.message_id=?) AND mb.user_id=? AND lower(f.name)='sent'
|
||||
ORDER BY m.received_at DESC LIMIT 1`, id, id, userID).Scan(&messageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.messageByID(ctx, messageID, false)
|
||||
}
|
||||
|
||||
func parseOpenAPILimit(r *http.Request, defaultLimit, maxLimit int) int {
|
||||
limit, err := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if err != nil || limit <= 0 {
|
||||
return defaultLimit
|
||||
}
|
||||
if limit > maxLimit {
|
||||
return maxLimit
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func parseOpenAPIOffset(r *http.Request) int {
|
||||
cursor := strings.TrimSpace(r.URL.Query().Get("cursor"))
|
||||
if cursor == "" {
|
||||
return 0
|
||||
}
|
||||
offset, err := strconv.Atoi(cursor)
|
||||
if err != nil || offset < 0 {
|
||||
return 0
|
||||
}
|
||||
return offset
|
||||
}
|
||||
@@ -137,16 +137,18 @@ type PermissionGroup struct {
|
||||
}
|
||||
|
||||
type PermissionLimits struct {
|
||||
MaxAttachmentMB int `json:"maxAttachmentMb"`
|
||||
SMTPDailyLimit int `json:"smtpDailyLimit"`
|
||||
SMTPMinuteLimit int `json:"smtpMinuteLimit"`
|
||||
IMAPMinuteLimit int `json:"imapMinuteLimit"`
|
||||
POP3MinuteLimit int `json:"pop3MinuteLimit"`
|
||||
MaxAttachmentMB int `json:"maxAttachmentMb"`
|
||||
MaxMailboxCount int `json:"maxMailboxCount"`
|
||||
SMTPDailyLimit int `json:"smtpDailyLimit"`
|
||||
SMTPMinuteLimit int `json:"smtpMinuteLimit"`
|
||||
IMAPMinuteLimit int `json:"imapMinuteLimit"`
|
||||
POP3MinuteLimit int `json:"pop3MinuteLimit"`
|
||||
}
|
||||
|
||||
func defaultPermissionLimits() PermissionLimits {
|
||||
return PermissionLimits{
|
||||
MaxAttachmentMB: 25,
|
||||
MaxMailboxCount: 9,
|
||||
SMTPDailyLimit: 200,
|
||||
SMTPMinuteLimit: 20,
|
||||
IMAPMinuteLimit: 200,
|
||||
@@ -158,6 +160,9 @@ func normalizePermissionLimits(limits PermissionLimits) (PermissionLimits, error
|
||||
if limits.MaxAttachmentMB < 0 {
|
||||
return PermissionLimits{}, errors.New("maxAttachmentMb cannot be negative")
|
||||
}
|
||||
if limits.MaxMailboxCount < 0 {
|
||||
return PermissionLimits{}, errors.New("maxMailboxCount cannot be negative")
|
||||
}
|
||||
if limits.SMTPDailyLimit < 0 {
|
||||
return PermissionLimits{}, errors.New("smtpDailyLimit cannot be negative")
|
||||
}
|
||||
@@ -173,6 +178,17 @@ func normalizePermissionLimits(limits PermissionLimits) (PermissionLimits, error
|
||||
return limits, nil
|
||||
}
|
||||
|
||||
func normalizeMailboxLimitOverride(value *int) (*int, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if *value < 0 {
|
||||
return nil, errors.New("mailboxLimitOverride cannot be negative")
|
||||
}
|
||||
normalized := *value
|
||||
return &normalized, nil
|
||||
}
|
||||
|
||||
func decodeStoredLimits(value string) PermissionLimits {
|
||||
limits := defaultPermissionLimits()
|
||||
if strings.TrimSpace(value) == "" {
|
||||
@@ -197,11 +213,12 @@ func encodePermissionLimits(limits PermissionLimits) string {
|
||||
|
||||
func mergePermissionLimits(left, right PermissionLimits) PermissionLimits {
|
||||
return PermissionLimits{
|
||||
MaxAttachmentMB: mergeLimitValue(left.MaxAttachmentMB, right.MaxAttachmentMB),
|
||||
SMTPDailyLimit: mergeLimitValue(left.SMTPDailyLimit, right.SMTPDailyLimit),
|
||||
SMTPMinuteLimit: mergeLimitValue(left.SMTPMinuteLimit, right.SMTPMinuteLimit),
|
||||
IMAPMinuteLimit: mergeLimitValue(left.IMAPMinuteLimit, right.IMAPMinuteLimit),
|
||||
POP3MinuteLimit: mergeLimitValue(left.POP3MinuteLimit, right.POP3MinuteLimit),
|
||||
MaxAttachmentMB: mergeLimitValue(left.MaxAttachmentMB, right.MaxAttachmentMB),
|
||||
MaxMailboxCount: mergeLimitValue(left.MaxMailboxCount, right.MaxMailboxCount),
|
||||
SMTPDailyLimit: mergeLimitValue(left.SMTPDailyLimit, right.SMTPDailyLimit),
|
||||
SMTPMinuteLimit: mergeLimitValue(left.SMTPMinuteLimit, right.SMTPMinuteLimit),
|
||||
IMAPMinuteLimit: mergeLimitValue(left.IMAPMinuteLimit, right.IMAPMinuteLimit),
|
||||
POP3MinuteLimit: mergeLimitValue(left.POP3MinuteLimit, right.POP3MinuteLimit),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,11 +233,12 @@ func mergeLimitValue(left, right int) int {
|
||||
}
|
||||
|
||||
func minimalLimits() PermissionLimits {
|
||||
// minimalLimits sets every field to 1 so that mergePermissionLimits
|
||||
// (which takes the max of each field) produces correct aggregation
|
||||
// minimalLimits sets every field to 1 so that mergePermissionLimits
|
||||
// (which takes the max of each field) produces correct aggregation
|
||||
// when no group has a limit set for a given field.
|
||||
return PermissionLimits{
|
||||
MaxAttachmentMB: 1,
|
||||
MaxMailboxCount: 1,
|
||||
SMTPDailyLimit: 1,
|
||||
SMTPMinuteLimit: 1,
|
||||
IMAPMinuteLimit: 1,
|
||||
@@ -236,6 +254,7 @@ func actorCanGrantLimits(actor *User, limits PermissionLimits) bool {
|
||||
return true
|
||||
}
|
||||
return canGrantLimitValue(actor.Limits.MaxAttachmentMB, limits.MaxAttachmentMB) &&
|
||||
canGrantLimitValue(actor.Limits.MaxMailboxCount, limits.MaxMailboxCount) &&
|
||||
canGrantLimitValue(actor.Limits.SMTPDailyLimit, limits.SMTPDailyLimit) &&
|
||||
canGrantLimitValue(actor.Limits.SMTPMinuteLimit, limits.SMTPMinuteLimit) &&
|
||||
canGrantLimitValue(actor.Limits.IMAPMinuteLimit, limits.IMAPMinuteLimit) &&
|
||||
@@ -292,20 +311,20 @@ var permissionCatalogItems = []PermissionInfo{
|
||||
{Key: PermissionMailRules, Label: "管理收件规则", Description: "查看、新增和删除本人的收件规则。", Category: "个人中心"},
|
||||
{Key: PermissionMailBlocked, Label: "管理拦截名单", Description: "查看、新增和删除本人的发件人拦截规则。", Category: "个人中心"},
|
||||
{Key: PermissionMailStats, Label: "查看邮箱统计", Description: "查看本人邮箱统计和清理概览。", Category: "个人中心"},
|
||||
{Key: PermissionMailboxApply, Label: "自助申请邮箱", Description: "在开放申请时为本人申请邮箱账号。", Category: "个人中心"},
|
||||
{Key: PermissionMailboxApply, Label: "自助申请邮箱", Description: "在开放申请时为本人申请邮箱。", Category: "个人中心"},
|
||||
|
||||
{Key: PermissionAdminOverview, Label: "查看概览", Description: "查看后台统计和首次配置检查。", Category: "概览"},
|
||||
|
||||
{Key: PermissionUsersView, Label: "查看用户", Description: "查看用户列表、状态和绑定邮箱。", Category: "用户"},
|
||||
{Key: PermissionUsersCreate, Label: "创建用户", Description: "创建普通用户并分配权限组。", Category: "用户"},
|
||||
{Key: PermissionUsersUpdate, Label: "编辑用户", Description: "修改用户显示名称、状态和权限组。", Category: "用户"},
|
||||
{Key: PermissionUsersDelete, Label: "删除用户", Description: "删除非受保护用户。", Category: "用户"},
|
||||
{Key: PermissionUsersResetPassword, Label: "重置用户密码", Description: "为用户重置登录密码。", Category: "用户"},
|
||||
{Key: PermissionUsersView, Label: "查看账号", Description: "查看账号列表、状态、邮箱数量上限和绑定邮箱。", Category: "账号管理"},
|
||||
{Key: PermissionUsersCreate, Label: "创建账号", Description: "创建普通账号并分配权限配额。", Category: "账号管理"},
|
||||
{Key: PermissionUsersUpdate, Label: "编辑账号", Description: "修改账号显示名称、状态、邮箱数量上限和权限配额。", Category: "账号管理"},
|
||||
{Key: PermissionUsersDelete, Label: "删除账号", Description: "删除非受保护账号。", Category: "账号管理"},
|
||||
{Key: PermissionUsersResetPassword, Label: "重置账号密码", Description: "为账号重置登录密码。", Category: "账号管理"},
|
||||
|
||||
{Key: PermissionGroupsView, Label: "查看权限组", Description: "查看权限组、权限目录和使用人数。", Category: "权限组"},
|
||||
{Key: PermissionGroupsCreate, Label: "创建权限组", Description: "创建自定义权限组。", Category: "权限组"},
|
||||
{Key: PermissionGroupsUpdate, Label: "编辑权限组", Description: "修改自定义权限组名称、说明和权限。", Category: "权限组"},
|
||||
{Key: PermissionGroupsDelete, Label: "删除权限组", Description: "删除未被用户使用的自定义权限组。", Category: "权限组"},
|
||||
{Key: PermissionGroupsView, Label: "查看权限配额", Description: "查看权限配额、权限目录和使用人数。", Category: "权限配额"},
|
||||
{Key: PermissionGroupsCreate, Label: "创建权限配额", Description: "创建自定义权限配额。", Category: "权限配额"},
|
||||
{Key: PermissionGroupsUpdate, Label: "编辑权限配额", Description: "修改自定义权限配额名称、说明、功能权限和额度。", Category: "权限配额"},
|
||||
{Key: PermissionGroupsDelete, Label: "删除权限配额", Description: "删除未被账号使用的自定义权限配额。", Category: "权限配额"},
|
||||
|
||||
{Key: PermissionDomainsView, Label: "查看域名", Description: "查看邮件域名和 DKIM 配置。", Category: "域名"},
|
||||
{Key: PermissionDomainsCreate, Label: "添加域名", Description: "添加新的邮件域名。", Category: "域名"},
|
||||
@@ -315,15 +334,15 @@ var permissionCatalogItems = []PermissionInfo{
|
||||
{Key: PermissionDNSView, Label: "查看 DNS", Description: "查看域名需要配置的 DNS 记录。", Category: "DNS"},
|
||||
{Key: PermissionDNSCheck, Label: "执行 DNS 检测", Description: "触发 MX、SPF、DKIM、DMARC 检测。", Category: "DNS"},
|
||||
|
||||
{Key: PermissionMailboxesView, Label: "查看邮箱账号", Description: "查看邮箱账号列表和归属用户。", Category: "邮箱账号"},
|
||||
{Key: PermissionMailboxesCreate, Label: "创建邮箱账号", Description: "创建邮箱账号并准备归属用户。", Category: "邮箱账号"},
|
||||
{Key: PermissionMailboxesUpdate, Label: "编辑邮箱账号", Description: "修改邮箱归属、显示名、配额和状态。", Category: "邮箱账号"},
|
||||
{Key: PermissionMailboxesDelete, Label: "删除邮箱账号", Description: "删除邮箱账号及关联邮件文件。", Category: "邮箱账号"},
|
||||
{Key: PermissionMailboxesView, Label: "查看邮箱", Description: "查看邮箱列表和归属账号。", Category: "邮箱管理"},
|
||||
{Key: PermissionMailboxesCreate, Label: "创建邮箱", Description: "创建邮箱并准备归属账号。", Category: "邮箱管理"},
|
||||
{Key: PermissionMailboxesUpdate, Label: "编辑邮箱", Description: "修改邮箱归属、显示名、配额和状态。", Category: "邮箱管理"},
|
||||
{Key: PermissionMailboxesDelete, Label: "删除邮箱", Description: "删除邮箱及关联邮件文件。", Category: "邮箱管理"},
|
||||
|
||||
{Key: PermissionAliasesView, Label: "查看别名转发", Description: "查看别名转发规则。", Category: "别名转发"},
|
||||
{Key: PermissionAliasesCreate, Label: "创建别名转发", Description: "创建新的别名转发。", Category: "别名转发"},
|
||||
{Key: PermissionAliasesUpdate, Label: "编辑别名转发", Description: "修改别名转发来源、目标和启用状态。", Category: "别名转发"},
|
||||
{Key: PermissionAliasesDelete, Label: "删除别名转发", Description: "删除别名转发规则。", Category: "别名转发"},
|
||||
{Key: PermissionAliasesView, Label: "查看邮件转发", Description: "查看邮件转发规则。", Category: "邮件转发"},
|
||||
{Key: PermissionAliasesCreate, Label: "创建邮件转发", Description: "创建新的邮件转发规则。", Category: "邮件转发"},
|
||||
{Key: PermissionAliasesUpdate, Label: "编辑邮件转发", Description: "修改邮件转发来源、目标和启用状态。", Category: "邮件转发"},
|
||||
{Key: PermissionAliasesDelete, Label: "删除邮件转发", Description: "删除邮件转发规则。", Category: "邮件转发"},
|
||||
|
||||
{Key: PermissionMessagesView, Label: "查看邮件列表", Description: "查看全局邮件列表和搜索结果。", Category: "邮件审计"},
|
||||
{Key: PermissionMessagesRead, Label: "查看邮件正文", Description: "查看任意邮箱及未注册收件人的邮件正文。", Category: "邮件审计"},
|
||||
@@ -435,8 +454,8 @@ func defaultPermissionGroups() []PermissionGroup {
|
||||
return []PermissionGroup{
|
||||
{
|
||||
ID: PermissionGroupSuperAdmin,
|
||||
Name: "超级管理员",
|
||||
Description: "拥有全部后台权限,由用户身份决定,不通过权限组分配。",
|
||||
Name: "管理员",
|
||||
Description: "拥有全部后台权限,由账号身份决定,不通过权限配额分配。",
|
||||
Permissions: allPermissionKeys(),
|
||||
Limits: PermissionLimits{},
|
||||
System: true,
|
||||
@@ -597,6 +616,9 @@ func (a *App) attachUserAuthorization(ctx context.Context, u *User) error {
|
||||
if u == nil {
|
||||
return nil
|
||||
}
|
||||
if u.LoginName == "" {
|
||||
u.LoginName = u.Email
|
||||
}
|
||||
permissions, err := a.permissionsForUser(ctx, u.ID, u.Role)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -611,6 +633,9 @@ func (a *App) attachUserAuthorization(ctx context.Context, u *User) error {
|
||||
}
|
||||
u.Permissions = permissions
|
||||
u.Limits = limits
|
||||
if u.Role != "admin" && u.MailboxLimitOverride != nil {
|
||||
u.Limits.MaxMailboxCount = *u.MailboxLimitOverride
|
||||
}
|
||||
u.PermissionGroupIDs = groupIDs
|
||||
u.PermissionGroups = groups
|
||||
u.Protected = a.isDefaultAdminUser(u)
|
||||
@@ -769,7 +794,7 @@ func (a *App) effectiveLimitsForUserGroups(ctx context.Context, tx *sql.Tx, grou
|
||||
|
||||
func (a *App) permissionGroupsForUser(ctx context.Context, userID, role string) ([]string, []PermissionGroupSummary, error) {
|
||||
if role == "admin" {
|
||||
group := PermissionGroupSummary{ID: PermissionGroupSuperAdmin, Name: "超级管理员"}
|
||||
group := PermissionGroupSummary{ID: PermissionGroupSuperAdmin, Name: "管理员"}
|
||||
return []string{group.ID}, []PermissionGroupSummary{group}, nil
|
||||
}
|
||||
ids := []string{PermissionGroupRegular}
|
||||
@@ -1032,6 +1057,9 @@ func (a *App) isDefaultAdminUser(u *User) bool {
|
||||
if u == nil {
|
||||
return false
|
||||
}
|
||||
if adminUsername := normalizeLoginName(a.cfg.AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") {
|
||||
return strings.EqualFold(normalizeLoginName(u.LoginName), adminUsername)
|
||||
}
|
||||
adminEmail := normalizeEmail(a.cfg.AdminEmail)
|
||||
return adminEmail != "" && strings.EqualFold(normalizeEmail(u.Email), adminEmail)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -88,6 +89,17 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusConflict, "该邮箱地址已被占用")
|
||||
return
|
||||
}
|
||||
if user.Role != "admin" && user.Limits.MaxMailboxCount > 0 {
|
||||
var ownedCount int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM mailboxes WHERE user_id=? AND status='active'`, user.ID).Scan(&ownedCount); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to check mailbox quota")
|
||||
return
|
||||
}
|
||||
if ownedCount >= user.Limits.MaxMailboxCount {
|
||||
respondError(w, http.StatusForbidden, "邮箱数量已达上限")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var passwordHash string
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=? AND disabled=0`, user.ID).Scan(&passwordHash); err != nil {
|
||||
@@ -483,6 +495,15 @@ func (a *App) handleCreateRule(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("rule action is required"))
|
||||
return
|
||||
}
|
||||
actions, err := a.cleanRuleActions(r.Context(), user.ID, actions)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if len(actions) == 0 {
|
||||
badRequest(w, errors.New("rule action is required"))
|
||||
return
|
||||
}
|
||||
conditionsJSON, err := json.Marshal(conditions)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
@@ -544,6 +565,201 @@ func (a *App) handleDeleteRule(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) handleUpdateRule(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
id := chi.URLParam(r, "id")
|
||||
item, err := a.ruleByID(r.Context(), user.ID, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
respondError(w, http.StatusNotFound, "rule not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load rule")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
MailboxID *string `json:"mailboxId"`
|
||||
Name *string `json:"name"`
|
||||
MatchMode *string `json:"matchMode"`
|
||||
Conditions *[]MailRuleCondition `json:"conditions"`
|
||||
Actions *[]MailRuleAction `json:"actions"`
|
||||
ApplyExisting *bool `json:"applyToExisting"`
|
||||
StopProcessing *bool `json:"stopProcessing"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if req.MailboxID != nil {
|
||||
mailboxID, ok := a.optionalMailboxIDForUser(r, *req.MailboxID)
|
||||
if !ok {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
item.MailboxID = mailboxID
|
||||
}
|
||||
if req.Name != nil {
|
||||
item.Name = strings.TrimSpace(*req.Name)
|
||||
if item.Name == "" {
|
||||
item.Name = "收件规则"
|
||||
}
|
||||
}
|
||||
if req.MatchMode != nil {
|
||||
raw := strings.ToLower(strings.TrimSpace(*req.MatchMode))
|
||||
if raw != "all" && raw != "and" && raw != "any" && raw != "or" {
|
||||
badRequest(w, errors.New("invalid match mode"))
|
||||
return
|
||||
}
|
||||
item.MatchMode = normalizeRuleMatchMode(raw)
|
||||
}
|
||||
if req.Conditions != nil {
|
||||
item.Conditions = normalizeRuleConditions(*req.Conditions, "", "")
|
||||
if len(item.Conditions) == 0 {
|
||||
badRequest(w, errors.New("rule condition is required"))
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Actions != nil {
|
||||
item.Actions = normalizeRuleActions(*req.Actions, "")
|
||||
item.Actions, err = a.cleanRuleActions(r.Context(), user.ID, item.Actions)
|
||||
if err != nil || len(item.Actions) == 0 {
|
||||
if err == nil {
|
||||
err = errors.New("rule action is required")
|
||||
}
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.ApplyExisting != nil {
|
||||
item.ApplyToExisting = *req.ApplyExisting
|
||||
}
|
||||
if req.StopProcessing != nil {
|
||||
item.StopProcessing = *req.StopProcessing
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
item.Enabled = *req.Enabled
|
||||
}
|
||||
conditionsJSON, err := json.Marshal(item.Conditions)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
actionsJSON, err := json.Marshal(item.Actions)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
item.FromContains = legacyConditionValue(item.Conditions, "from")
|
||||
item.SubjectContains = legacyConditionValue(item.Conditions, "subject")
|
||||
item.Action = item.Actions[0].Type
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
_, err = a.db.ExecContext(r.Context(), `UPDATE mail_rules SET mailbox_id=?,name=?,match_mode=?,conditions_json=?,actions_json=?,from_contains=?,subject_contains=?,action=?,apply_to_existing=?,stop_processing=?,enabled=?,updated_at=? WHERE id=? AND user_id=?`, item.MailboxID, item.Name, item.MatchMode, string(conditionsJSON), string(actionsJSON), item.FromContains, item.SubjectContains, item.Action, boolInt(item.ApplyToExisting), boolInt(item.StopProcessing), boolInt(item.Enabled), now, id, user.ID)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
updated, err := a.ruleByID(r.Context(), user.ID, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load rule")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
func (a *App) handleMoveRule(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
var req struct {
|
||||
Direction string `json:"direction"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if req.Direction != "up" && req.Direction != "down" {
|
||||
badRequest(w, errors.New("invalid direction"))
|
||||
return
|
||||
}
|
||||
type orderedRule struct{ id, createdAt string }
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,created_at FROM mail_rules WHERE user_id=? ORDER BY created_at DESC`, user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load rules")
|
||||
return
|
||||
}
|
||||
items := []orderedRule{}
|
||||
for rows.Next() {
|
||||
var item orderedRule
|
||||
if err := rows.Scan(&item.id, &item.createdAt); err != nil {
|
||||
rows.Close()
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan rules")
|
||||
return
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
rows.Close()
|
||||
index := -1
|
||||
for i := range items {
|
||||
if items[i].id == chi.URLParam(r, "id") {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if index < 0 {
|
||||
respondError(w, http.StatusNotFound, "rule not found")
|
||||
return
|
||||
}
|
||||
target := index - 1
|
||||
if req.Direction == "down" {
|
||||
target = index + 1
|
||||
}
|
||||
if target < 0 || target >= len(items) {
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to move rule")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE mail_rules SET created_at=? WHERE id=? AND user_id=?`, items[target].createdAt, items[index].id, user.ID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to move rule")
|
||||
return
|
||||
}
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE mail_rules SET created_at=? WHERE id=? AND user_id=?`, items[index].createdAt, items[target].id, user.ID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to move rule")
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to move rule")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) handleApplyRule(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
item, err := a.ruleByID(r.Context(), user.ID, chi.URLParam(r, "id"))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
respondError(w, http.StatusNotFound, "rule not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load rule")
|
||||
return
|
||||
}
|
||||
affected, err := a.applyRuleToExistingMessages(r.Context(), user.ID, item.MailboxID, item)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to apply rule")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "affected": affected})
|
||||
}
|
||||
|
||||
func (a *App) ruleByID(ctx context.Context, userID, id string) (MailRule, error) {
|
||||
return scanRule(a.db.QueryRowContext(ctx, `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE id=? AND user_id=?`, id, userID))
|
||||
}
|
||||
|
||||
func (a *App) handleListBlockedSenders(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,user_id,mailbox_id,email,reason,created_at FROM blocked_senders WHERE user_id=? ORDER BY created_at DESC`, user.ID)
|
||||
@@ -621,9 +837,10 @@ func (a *App) handleDeleteBlockedSender(w http.ResponseWriter, r *http.Request)
|
||||
func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
|
||||
rangeDays := mailStatsRangeDays(r.URL.Query().Get("days"))
|
||||
args := []any{user.ID}
|
||||
where := `mb.user_id=?`
|
||||
if mailboxID != "" {
|
||||
if mailboxID != "" && !isAllMailboxID(mailboxID) {
|
||||
if _, err := a.mailboxForCurrentUserWithID(r, mailboxID); err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
@@ -631,18 +848,55 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
|
||||
where += ` AND mb.id=?`
|
||||
args = append(args, mailboxID)
|
||||
}
|
||||
stats := MailStats{ByFolder: []MailStatsFolderCount{}}
|
||||
row := a.db.QueryRowContext(r.Context(), `SELECT COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(CASE WHEN m.is_starred=1 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0)
|
||||
FROM mailboxes mb LEFT JOIN messages m ON m.mailbox_id=mb.id WHERE `+where, args...)
|
||||
if err := row.Scan(&stats.TotalMessages, &stats.UnreadMessages, &stats.StarredMessages, &stats.StorageBytes); err != nil {
|
||||
now := a.now().UTC()
|
||||
stats := MailStats{
|
||||
ByFolder: []MailStatsFolderCount{},
|
||||
Trend: emptyMailStatsTrend(now, rangeDays),
|
||||
Distribution: []MailStatsDistributionItem{},
|
||||
TopContacts: []MailStatsContact{},
|
||||
}
|
||||
row := a.db.QueryRowContext(r.Context(), `SELECT COUNT(m.id),
|
||||
COALESCE(SUM(CASE WHEN f.role NOT IN ('sent','drafts') THEN 1 ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN f.role='sent' THEN 1 ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN f.role='drafts' THEN 1 ELSE 0 END),0),
|
||||
COALESCE(SUM(CASE WHEN m.is_starred=1 THEN 1 ELSE 0 END),0),
|
||||
COALESCE(SUM(m.size_bytes),0)
|
||||
FROM mailboxes mb
|
||||
LEFT JOIN messages m ON m.mailbox_id=mb.id
|
||||
LEFT JOIN folders f ON f.id=m.folder_id
|
||||
WHERE `+where, args...)
|
||||
if err := row.Scan(&stats.TotalMessages, &stats.TotalIncoming, &stats.TotalOutgoing, &stats.UnreadMessages, &stats.DraftMessages, &stats.StarredMessages, &stats.StorageBytes); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load stats")
|
||||
return
|
||||
}
|
||||
if stats.TotalMessages > 0 {
|
||||
stats.AverageMessageBytes = stats.StorageBytes / stats.TotalMessages
|
||||
}
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(a.id),COALESCE(SUM(a.size_bytes),0) FROM attachments a JOIN messages m ON m.id=a.message_id JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...).Scan(&stats.AttachmentCount, &stats.AttachmentBytes); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load attachment stats")
|
||||
return
|
||||
}
|
||||
if mailboxID != "" {
|
||||
var attachmentMessageCount int64
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(m.id) FROM mailboxes mb JOIN messages m ON m.mailbox_id=mb.id WHERE `+where+` AND m.has_attachments=1`, args...).Scan(&attachmentMessageCount); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load attachment message stats")
|
||||
return
|
||||
}
|
||||
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)
|
||||
todayArgs := append(append([]any{}, args...), todayStart)
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(m.id)
|
||||
FROM mailboxes mb JOIN messages m ON m.mailbox_id=mb.id JOIN folders f ON f.id=m.folder_id
|
||||
WHERE `+where+` AND f.role='sent' AND m.sent_at>=?`, todayArgs...).Scan(&stats.TodayOutgoing); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load today stats")
|
||||
return
|
||||
}
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(sq.id)
|
||||
FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id
|
||||
WHERE `+where+` AND sq.status='failed'`, args...).Scan(&stats.FailedSends); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load send queue stats")
|
||||
return
|
||||
}
|
||||
if mailboxID != "" && !isAllMailboxID(mailboxID) {
|
||||
var quotaMB int64
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT quota_mb FROM mailboxes WHERE id=? AND user_id=?`, mailboxID, user.ID).Scan("aMB); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load quota")
|
||||
@@ -652,6 +906,16 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
|
||||
if stats.QuotaBytes > 0 {
|
||||
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
|
||||
}
|
||||
} else {
|
||||
var quotaMB int64
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(mb.quota_mb),0) FROM mailboxes mb WHERE `+where, args...).Scan("aMB); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load quota")
|
||||
return
|
||||
}
|
||||
stats.QuotaBytes = quotaMB * 1024 * 1024
|
||||
if stats.QuotaBytes > 0 {
|
||||
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
|
||||
}
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT f.name,f.role,COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0)
|
||||
FROM mailboxes mb JOIN folders f ON f.mailbox_id=mb.id LEFT JOIN messages m ON m.folder_id=f.id
|
||||
@@ -669,9 +933,165 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
stats.ByFolder = append(stats.ByFolder, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan folder stats")
|
||||
return
|
||||
}
|
||||
stats.Distribution = mailStatsDistribution(stats.ByFolder, attachmentMessageCount, stats.StarredMessages)
|
||||
if err := a.loadMailStatsTrend(r.Context(), where, args, rangeDays, stats.Trend); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load trend stats")
|
||||
return
|
||||
}
|
||||
topContacts, err := a.mailStatsTopContacts(r.Context(), where, args)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load contact stats")
|
||||
return
|
||||
}
|
||||
stats.TopContacts = topContacts
|
||||
respondJSON(w, http.StatusOK, stats)
|
||||
}
|
||||
|
||||
func mailStatsRangeDays(raw string) int {
|
||||
days, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil || days <= 0 {
|
||||
return 30
|
||||
}
|
||||
switch days {
|
||||
case 7, 30, 90, 365:
|
||||
return days
|
||||
default:
|
||||
if days < 7 {
|
||||
return 7
|
||||
}
|
||||
if days > 365 {
|
||||
return 365
|
||||
}
|
||||
return days
|
||||
}
|
||||
}
|
||||
|
||||
func emptyMailStatsTrend(now time.Time, days int) []MailStatsTrendPoint {
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
points := make([]MailStatsTrendPoint, 0, days)
|
||||
for i := days - 1; i >= 0; i-- {
|
||||
points = append(points, MailStatsTrendPoint{Date: today.AddDate(0, 0, -i).Format("2006-01-02")})
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
func mailStatsDistribution(rows []MailStatsFolderCount, attachmentMessages, starred int64) []MailStatsDistributionItem {
|
||||
roles := map[string]int64{}
|
||||
for _, row := range rows {
|
||||
roles[strings.ToLower(row.Role)] += row.Count
|
||||
}
|
||||
return []MailStatsDistributionItem{
|
||||
{Key: "inbox", Label: "收件箱", Count: roles["inbox"]},
|
||||
{Key: "archive", Label: "已归档", Count: roles["archive"]},
|
||||
{Key: "spam", Label: "垃圾邮件", Count: roles["spam"]},
|
||||
{Key: "trash", Label: "已删除", Count: roles["trash"]},
|
||||
{Key: "attachments", Label: "有附件", Count: attachmentMessages},
|
||||
{Key: "starred", Label: "已加旗标", Count: starred},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) loadMailStatsTrend(ctx context.Context, where string, args []any, days int, trend []MailStatsTrendPoint) error {
|
||||
start := ""
|
||||
if len(trend) > 0 {
|
||||
start = trend[0].Date + "T00:00:00Z"
|
||||
}
|
||||
queryArgs := append(append([]any{}, args...), start)
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT substr(CASE WHEN f.role='sent' THEN m.sent_at ELSE m.received_at END, 1, 10),
|
||||
COALESCE(SUM(CASE WHEN f.role='sent' THEN 0 ELSE 1 END),0),
|
||||
COALESCE(SUM(CASE WHEN f.role='sent' THEN 1 ELSE 0 END),0)
|
||||
FROM mailboxes mb JOIN messages m ON m.mailbox_id=mb.id JOIN folders f ON f.id=m.folder_id
|
||||
WHERE `+where+` AND f.role<>'drafts' AND (CASE WHEN f.role='sent' THEN m.sent_at ELSE m.received_at END)>=?
|
||||
GROUP BY 1`, queryArgs...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
byDate := map[string]*MailStatsTrendPoint{}
|
||||
for i := range trend {
|
||||
byDate[trend[i].Date] = &trend[i]
|
||||
}
|
||||
for rows.Next() {
|
||||
var date string
|
||||
var incoming, outgoing int64
|
||||
if err := rows.Scan(&date, &incoming, &outgoing); err != nil {
|
||||
return err
|
||||
}
|
||||
if point := byDate[date]; point != nil {
|
||||
point.Incoming = incoming
|
||||
point.Outgoing = outgoing
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) mailStatsTopContacts(ctx context.Context, where string, args []any) ([]MailStatsContact, error) {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT f.role,m.from_addr,m.to_addrs,m.cc_addrs,m.bcc_addrs
|
||||
FROM mailboxes mb JOIN messages m ON m.mailbox_id=mb.id JOIN folders f ON f.id=m.folder_id
|
||||
WHERE `+where+`
|
||||
ORDER BY m.received_at DESC LIMIT 2000`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
counts := map[string]int64{}
|
||||
for rows.Next() {
|
||||
var role, from, toJSON, ccJSON, bccJSON string
|
||||
if err := rows.Scan(&role, &from, &toJSON, &ccJSON, &bccJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.EqualFold(role, "sent") {
|
||||
for _, email := range append(append(mailStatsEmailList(toJSON), mailStatsEmailList(ccJSON)...), mailStatsEmailList(bccJSON)...) {
|
||||
if email != "" {
|
||||
counts[email]++
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if email := normalizeEmail(from); email != "" && strings.Contains(email, "@") {
|
||||
counts[email]++
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]MailStatsContact, 0, len(counts))
|
||||
for email, count := range counts {
|
||||
items = append(items, MailStatsContact{Email: email, Count: count})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].Count == items[j].Count {
|
||||
return items[i].Email < items[j].Email
|
||||
}
|
||||
return items[i].Count > items[j].Count
|
||||
})
|
||||
if len(items) > 10 {
|
||||
items = items[:10]
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func mailStatsEmailList(raw string) []string {
|
||||
var values []string
|
||||
if err := json.Unmarshal([]byte(raw), &values); err != nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
email := normalizeEmail(value)
|
||||
if email == "" || !strings.Contains(email, "@") || seen[email] {
|
||||
continue
|
||||
}
|
||||
seen[email] = true
|
||||
out = append(out, email)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) handleMailCleanup(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
MailboxID string `json:"mailboxId"`
|
||||
@@ -889,7 +1309,7 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr
|
||||
a.moveBlockedMessageToSpam(ctx, messageID, mailboxID)
|
||||
return
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND enabled=1 ORDER BY created_at`, userID, mailboxID)
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND enabled=1 ORDER BY created_at DESC`, userID, mailboxID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -1059,10 +1479,10 @@ func normalizeRuleActions(items []MailRuleAction, legacyAction string) []MailRul
|
||||
}
|
||||
out := []MailRuleAction{}
|
||||
for _, item := range items {
|
||||
typ := strings.TrimSpace(item.Type)
|
||||
typ := strings.ToLower(strings.TrimSpace(item.Type))
|
||||
value := strings.TrimSpace(item.Value)
|
||||
labelID := strings.TrimSpace(item.LabelID)
|
||||
if typ != "archive" && typ != "trash" && typ != "star" && typ != "mark-read" && typ != "label" && typ != "move" {
|
||||
if typ != "archive" && typ != "trash" && typ != "star" && typ != "mark-read" && typ != "label" && typ != "move" && typ != "forward" {
|
||||
continue
|
||||
}
|
||||
if typ == "label" && value == "" && labelID == "" {
|
||||
@@ -1071,11 +1491,32 @@ func normalizeRuleActions(items []MailRuleAction, legacyAction string) []MailRul
|
||||
if typ == "move" && value == "" {
|
||||
continue
|
||||
}
|
||||
if typ == "forward" && value == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, MailRuleAction{Type: typ, Value: value, LabelID: labelID})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) cleanRuleActions(ctx context.Context, userID string, actions []MailRuleAction) ([]MailRuleAction, error) {
|
||||
out := make([]MailRuleAction, 0, len(actions))
|
||||
for _, action := range actions {
|
||||
if action.Type == "forward" {
|
||||
targets, err := a.cleanForwardingTargets(ctx, userID, splitRuleForwardTargets(action.Value))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
continue
|
||||
}
|
||||
action.Value = strings.Join(targets, ", ")
|
||||
}
|
||||
out = append(out, action)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func legacyConditionValue(items []MailRuleCondition, field string) string {
|
||||
for _, item := range items {
|
||||
if item.Field == field && item.Operator == "contains" {
|
||||
@@ -1315,6 +1756,10 @@ func (a *App) applyRuleActions(ctx context.Context, mailboxID, messageID string,
|
||||
if err := a.applyRuleLabel(ctx, mailboxID, messageID, action); err != nil {
|
||||
return err
|
||||
}
|
||||
case "forward":
|
||||
if err := a.processRuleForwarding(ctx, messageID, mailboxID, action); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -1370,13 +1815,23 @@ func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
messages := []ruleMessage{}
|
||||
messageIDs := []string{}
|
||||
var count int64
|
||||
for rows.Next() {
|
||||
var messageID string
|
||||
if err := rows.Scan(&messageID); err != nil {
|
||||
rows.Close()
|
||||
return count, err
|
||||
}
|
||||
messageIDs = append(messageIDs, messageID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return count, err
|
||||
}
|
||||
rows.Close()
|
||||
messages := []ruleMessage{}
|
||||
for _, messageID := range messageIDs {
|
||||
msg, ok := a.ruleMessageByID(ctx, messageID)
|
||||
if !ok {
|
||||
continue
|
||||
@@ -1386,10 +1841,6 @@ func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID
|
||||
}
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return count, err
|
||||
}
|
||||
rows.Close()
|
||||
for _, msg := range messages {
|
||||
if err := a.applyRuleActions(ctx, msg.MailboxID, msg.ID, rule.Actions); err != nil {
|
||||
return count, err
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
type contextKey string
|
||||
|
||||
const userContextKey contextKey = "user"
|
||||
const apiTokenScopesContextKey contextKey = "api_token_scopes"
|
||||
|
||||
func (a *App) Router() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
@@ -31,14 +32,25 @@ func (a *App) Router() http.Handler {
|
||||
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Get("/public/settings", a.handlePublicSettings)
|
||||
r.Get("/verify-email", a.handleVerifyForwardingEmail)
|
||||
r.Post("/auth/register", a.handleRegister)
|
||||
r.Post("/auth/login", a.handleLogin)
|
||||
r.Post("/auth/logout", a.handleLogout)
|
||||
r.With(a.requireAuth).Get("/me", a.handleMe)
|
||||
r.With(a.requireAuth).Post("/me/profile", a.handleUpdateProfile)
|
||||
r.With(a.requireAuth).Post("/me/password", a.handleChangePassword)
|
||||
r.With(a.requireAuth).Get("/me/api-tokens", a.handleListAPITokens)
|
||||
r.With(a.requireAuth).Post("/me/api-tokens", a.handleCreateAPIToken)
|
||||
r.With(a.requireAuth).Post("/me/api-tokens/{id}", a.handleUpdateAPIToken)
|
||||
r.With(a.requireAuth).Delete("/me/api-tokens/{id}", a.handleDeleteAPIToken)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailboxApply)).Get("/me/mailbox-apply-options", a.handleMailboxApplyOptions)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailboxApply)).Post("/me/mailboxes/apply", a.handleApplyMailbox)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Get("/me/forwarding", a.handleForwardingSettings)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/forwarding/verified-emails", a.handleAddForwardingVerifiedEmail)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/forwarding/verified-emails/{id}/resend", a.handleResendForwardingVerifiedEmail)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Delete("/me/forwarding/verified-emails/{id}", a.handleDeleteForwardingVerifiedEmail)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/forwarding/account", a.handleUpdateAccountForwarding)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess)).Post("/me/mailboxes/{id}/forwarding", a.handleUpdateMailboxForwarding)
|
||||
r.With(a.requireAuth).Post("/me/2fa/setup", a.handleTwoFactorSetup)
|
||||
r.With(a.requireAuth).Post("/me/2fa/enable", a.handleTwoFactorEnable)
|
||||
r.With(a.requireAuth).Post("/me/2fa/disable", a.handleTwoFactorDisable)
|
||||
@@ -53,6 +65,9 @@ func (a *App) Router() http.Handler {
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Get("/me/signatures/default", a.handleDefaultSignature)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Get("/me/rules", a.handleListRules)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Post("/me/rules", a.handleCreateRule)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Post("/me/rules/{id}", a.handleUpdateRule)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Post("/me/rules/{id}/move", a.handleMoveRule)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Post("/me/rules/{id}/apply", a.handleApplyRule)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Delete("/me/rules/{id}", a.handleDeleteRule)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailBlocked)).Get("/me/blocked-senders", a.handleListBlockedSenders)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailBlocked)).Post("/me/blocked-senders", a.handleCreateBlockedSender)
|
||||
@@ -71,6 +86,10 @@ func (a *App) Router() http.Handler {
|
||||
r.With(a.requireExternalIMAPEnabled).Get("/external-imap-oauth/{provider}/callback", a.handleExternalIMAPOAuthCallback)
|
||||
r.With(a.requireAuth).Get("/events", a.handleEvents)
|
||||
|
||||
r.Post("/open/v1/delivery-events", a.handleOpenAPIDeliveryWebhook)
|
||||
r.Route("/open", func(r chi.Router) { a.registerOpenAPIRoutes(r) })
|
||||
r.Route("/open/v1", func(r chi.Router) { a.registerOpenAPIRoutes(r) })
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.requireAuth)
|
||||
r.With(a.requirePermission(PermissionMailAccess)).Get("/mail/mailboxes", a.handleMyMailboxes)
|
||||
@@ -83,6 +102,8 @@ func (a *App) Router() http.Handler {
|
||||
r.With(a.requirePermission(PermissionMailLabels)).Delete("/mail/labels/{id}", a.handleDeleteMailLabel)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages", a.handleMailMessages)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/starred", a.handleStarredMessages)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/export", a.handleExportMail)
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/import", a.handleImportMail)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages/{id}", a.handleMailMessage)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Post("/mail/messages/{id}/translate", a.handleTranslateMailMessage)
|
||||
r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Get("/mail/external-accounts", a.handleMailExternalAccounts)
|
||||
@@ -115,6 +136,8 @@ func (a *App) Router() http.Handler {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.requireAuth)
|
||||
r.Use(a.requireAdminAccess)
|
||||
r.Get("/admin/system/version", a.handleSystemVersion)
|
||||
r.Post("/admin/system/update", a.handleSystemUpdate)
|
||||
r.With(a.requirePermission(PermissionAdminOverview)).Get("/admin/overview", a.handleAdminOverview)
|
||||
r.With(a.requireAnyPermission(PermissionUsersView, PermissionMailboxesView)).Get("/admin/users", a.handleListUsers)
|
||||
r.With(a.requirePermission(PermissionUsersCreate)).Post("/admin/users", a.handleCreateUser)
|
||||
@@ -158,6 +181,37 @@ func (a *App) Router() http.Handler {
|
||||
return r
|
||||
}
|
||||
|
||||
func (a *App) registerOpenAPIRoutes(r chi.Router) {
|
||||
r.Use(a.requireAPIToken)
|
||||
r.With(a.requireAPITokenScope("domains:read"), a.requireAdminAccess, a.requireAnyPermission(PermissionDomainsView, PermissionDNSView, PermissionMailboxesView, PermissionAliasesView, PermissionSettingsView, PermissionTemplatesView)).Get("/domains", a.handleOpenAPIListDomains)
|
||||
r.With(a.requireAPITokenScope("domains:write"), a.requireAdminAccess, a.requirePermission(PermissionDomainsCreate)).Post("/domains", a.handleOpenAPICreateDomain)
|
||||
r.With(a.requireAPITokenScope("domains:read"), a.requireAdminAccess, a.requireAnyPermission(PermissionDomainsView, PermissionDNSView, PermissionMailboxesView, PermissionAliasesView, PermissionSettingsView, PermissionTemplatesView)).Get("/domains/{id}", a.handleOpenAPIGetDomain)
|
||||
r.With(a.requireAPITokenScope("domains:write"), a.requireAdminAccess, a.requirePermission(PermissionDomainsUpdate)).Post("/domains/{id}", a.handleOpenAPIUpdateDomain)
|
||||
r.With(a.requireAPITokenScope("domains:write"), a.requireAdminAccess, a.requirePermission(PermissionDomainsDelete)).Delete("/domains/{id}", a.handleOpenAPIDeleteDomain)
|
||||
r.With(a.requireAPITokenScope("dns:read"), a.requireAdminAccess, a.requirePermission(PermissionDNSView)).Get("/domains/{id}/dns-records", a.handleDNSRecords)
|
||||
r.With(a.requireAPITokenScope("dns:check"), a.requireAdminAccess, a.requirePermission(PermissionDNSCheck)).Post("/domains/{id}/dns-check", a.handleDNSCheck)
|
||||
r.With(a.requireAPITokenScope("mailboxes:read"), a.requireAdminAccess, a.requireAnyPermission(PermissionMailboxesView, PermissionMessagesView)).Get("/mailboxes", a.handleOpenAPIListMailboxes)
|
||||
r.With(a.requireAPITokenScope("mailboxes:write"), a.requireAdminAccess, a.requirePermission(PermissionMailboxesCreate)).Post("/mailboxes", a.handleOpenAPICreateMailbox)
|
||||
r.With(a.requireAPITokenScope("mailboxes:read"), a.requireAdminAccess, a.requireAnyPermission(PermissionMailboxesView, PermissionMessagesView)).Get("/mailboxes/{id}", a.handleOpenAPIGetMailbox)
|
||||
r.With(a.requireAPITokenScope("mailboxes:write"), a.requireAdminAccess, a.requirePermission(PermissionMailboxesUpdate)).Post("/mailboxes/{id}", a.handleOpenAPIUpdateMailbox)
|
||||
r.With(a.requireAPITokenScope("mailboxes:write"), a.requireAdminAccess, a.requirePermission(PermissionUsersResetPassword)).Post("/mailboxes/{id}/password", a.handleOpenAPIResetMailboxPassword)
|
||||
r.With(a.requireAPITokenScope("mailboxes:write"), a.requireAdminAccess, a.requirePermission(PermissionMailboxesDelete)).Delete("/mailboxes/{id}", a.handleOpenAPIDeleteMailbox)
|
||||
r.With(a.requireAPITokenScope("messages:send"), a.requirePermission(PermissionMailSend)).Post("/send", a.handleOpenAPISendMail)
|
||||
r.With(a.requireAPITokenScope("messages:read"), a.requirePermission(PermissionMailRead)).Get("/send", a.handleOpenAPIListSends)
|
||||
r.With(a.requireAPITokenScope("messages:read"), a.requirePermission(PermissionMailRead)).Get("/send/{id}", a.handleOpenAPISendStatus)
|
||||
r.With(a.requireAPITokenScope("messages:read"), a.requirePermission(PermissionMailRead)).Get("/send/{id}/events", a.handleOpenAPISendEvents)
|
||||
r.With(a.requireAPITokenScope("messages:manage"), a.requirePermission(PermissionMailSend)).Post("/send/{id}/retry", a.handleOpenAPIRetrySend)
|
||||
r.With(a.requireAPITokenScope("messages:manage"), a.requirePermission(PermissionMailSend)).Post("/send/{id}/cancel", a.handleOpenAPICancelSend)
|
||||
r.With(a.requireAPITokenScope("messages:read"), a.requirePermission(PermissionMailRead)).Get("/mailboxes/{id}/messages", a.handleOpenAPIMailboxMessages)
|
||||
r.With(a.requireAPITokenScope("messages:read"), a.requirePermission(PermissionMailRead)).Get("/messages/{id}", a.handleOpenAPIMessage)
|
||||
r.With(a.requireAPITokenScope("messages:read"), a.requirePermission(PermissionMailAttachments)).Get("/attachments/{id}", a.handleAttachment)
|
||||
r.With(a.requireAPITokenScope("aliases:read"), a.requireAdminAccess, a.requirePermission(PermissionAliasesView)).Get("/aliases", a.handleOpenAPIListAliases)
|
||||
r.With(a.requireAPITokenScope("aliases:write"), a.requireAdminAccess, a.requirePermission(PermissionAliasesCreate)).Post("/aliases", a.handleCreateAlias)
|
||||
r.With(a.requireAPITokenScope("aliases:read"), a.requireAdminAccess, a.requirePermission(PermissionAliasesView)).Get("/aliases/{id}", a.handleOpenAPIGetAlias)
|
||||
r.With(a.requireAPITokenScope("aliases:write"), a.requireAdminAccess, a.requirePermission(PermissionAliasesUpdate)).Post("/aliases/{id}", a.handleUpdateAlias)
|
||||
r.With(a.requireAPITokenScope("aliases:write"), a.requireAdminAccess, a.requirePermission(PermissionAliasesDelete)).Delete("/aliases/{id}", a.handleDeleteAlias)
|
||||
}
|
||||
|
||||
func (a *App) corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
@@ -165,7 +219,7 @@ func (a *App) corsMiddleware(next http.Handler) http.Handler {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, Idempotency-Key")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,DELETE,OPTIONS")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
@@ -187,6 +241,32 @@ func (a *App) requireAuth(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) requireAPIToken(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, scopes, err := a.authenticateAPIToken(r)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusUnauthorized, "api token required")
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), userContextKey, user)
|
||||
ctx = context.WithValue(ctx, apiTokenScopesContextKey, scopes)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) requireAPITokenScope(scope string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
scopes, _ := r.Context().Value(apiTokenScopesContextKey).(map[string]bool)
|
||||
if !scopes["*"] && !scopes[scope] {
|
||||
respondError(w, http.StatusForbidden, "api token scope required: "+scope)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func currentUser(r *http.Request) *User {
|
||||
user, _ := r.Context().Value(userContextKey).(*User)
|
||||
return user
|
||||
@@ -197,17 +277,19 @@ func (a *App) authenticateRequest(r *http.Request) (*User, error) {
|
||||
if err != nil || cookie.Value == "" {
|
||||
return nil, errors.New("no session")
|
||||
}
|
||||
row := a.db.QueryRowContext(r.Context(), `SELECT u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at
|
||||
row := a.db.QueryRowContext(r.Context(), `SELECT u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at
|
||||
FROM sessions s JOIN users u ON u.id=s.user_id
|
||||
WHERE s.token_hash=? AND s.expires_at > ?`, hashToken(cookie.Value), a.now().UTC().Format(time.RFC3339Nano))
|
||||
var u User
|
||||
var disabled, twoFactorEnabled int
|
||||
var mailboxLimitOverride sql.NullInt64
|
||||
var created string
|
||||
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &created); err != nil {
|
||||
if err := row.Scan(&u.ID, &u.LoginName, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.Disabled = intBool(disabled)
|
||||
u.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
u.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
|
||||
u.CreatedAt = parseTime(created)
|
||||
if u.Disabled {
|
||||
return nil, errors.New("disabled")
|
||||
@@ -218,13 +300,60 @@ func (a *App) authenticateRequest(r *http.Request) (*User, error) {
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (a *App) authenticateAPIToken(r *http.Request) (*User, map[string]bool, error) {
|
||||
token := bearerToken(r)
|
||||
if token == "" {
|
||||
return nil, nil, errors.New("no api token")
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
row := a.db.QueryRowContext(r.Context(), `SELECT at.id,at.scopes_json,u.id,u.login_name,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.mailbox_limit_override,u.created_at
|
||||
FROM api_tokens at JOIN users u ON u.id=at.user_id
|
||||
WHERE at.token_hash=? AND at.disabled=0 AND at.expires_at > ?`, hashToken(token), now)
|
||||
var tokenID, scopesJSON string
|
||||
var u User
|
||||
var disabled, twoFactorEnabled int
|
||||
var mailboxLimitOverride sql.NullInt64
|
||||
var created string
|
||||
if err := row.Scan(&tokenID, &scopesJSON, &u.ID, &u.LoginName, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
u.Disabled = intBool(disabled)
|
||||
u.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
u.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
|
||||
u.CreatedAt = parseTime(created)
|
||||
if u.Disabled {
|
||||
return nil, nil, errors.New("disabled")
|
||||
}
|
||||
if err := a.attachUserAuthorization(r.Context(), &u); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
_, _ = a.db.ExecContext(r.Context(), `UPDATE api_tokens SET last_used_at=? WHERE id=?`, now, tokenID)
|
||||
scopes := map[string]bool{}
|
||||
for _, scope := range jsonDecodeSlice(scopesJSON) {
|
||||
scopes[scope] = true
|
||||
}
|
||||
return &u, scopes, nil
|
||||
}
|
||||
|
||||
func bearerToken(r *http.Request) string {
|
||||
fields := strings.Fields(strings.TrimSpace(r.Header.Get("Authorization")))
|
||||
if len(fields) != 2 || !strings.EqualFold(fields[0], "Bearer") {
|
||||
return ""
|
||||
}
|
||||
return fields[1]
|
||||
}
|
||||
|
||||
func (a *App) userByEmail(ctx context.Context, email string) (*User, string, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,password_hash,disabled,two_factor_enabled,created_at FROM users WHERE email=?`, email)
|
||||
loginName := normalizeLoginName(email)
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,display_name,role,password_hash,disabled,two_factor_enabled,mailbox_limit_override,created_at
|
||||
FROM users WHERE login_name=? OR email=?
|
||||
ORDER BY CASE WHEN login_name=? THEN 0 ELSE 1 END LIMIT 1`, loginName, loginName, loginName)
|
||||
var u User
|
||||
var passwordHash string
|
||||
var disabled, twoFactorEnabled int
|
||||
var mailboxLimitOverride sql.NullInt64
|
||||
var created string
|
||||
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &passwordHash, &disabled, &twoFactorEnabled, &created); err != nil {
|
||||
if err := row.Scan(&u.ID, &u.LoginName, &u.Email, &u.DisplayName, &u.Role, &passwordHash, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, "", errNotFound
|
||||
}
|
||||
@@ -232,6 +361,7 @@ func (a *App) userByEmail(ctx context.Context, email string) (*User, string, err
|
||||
}
|
||||
u.Disabled = intBool(disabled)
|
||||
u.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
u.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
|
||||
u.CreatedAt = parseTime(created)
|
||||
if err := a.attachUserAuthorization(ctx, &u); err != nil {
|
||||
return nil, "", err
|
||||
@@ -240,11 +370,12 @@ func (a *App) userByEmail(ctx context.Context, email string) (*User, string, err
|
||||
}
|
||||
|
||||
func (a *App) userByID(ctx context.Context, id string) (*User, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,disabled,two_factor_enabled,created_at FROM users WHERE id=?`, id)
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,display_name,role,disabled,two_factor_enabled,mailbox_limit_override,created_at FROM users WHERE id=?`, id)
|
||||
var u User
|
||||
var disabled, twoFactorEnabled int
|
||||
var mailboxLimitOverride sql.NullInt64
|
||||
var created string
|
||||
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &created); err != nil {
|
||||
if err := row.Scan(&u.ID, &u.LoginName, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &mailboxLimitOverride, &created); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, errNotFound
|
||||
}
|
||||
@@ -252,6 +383,7 @@ func (a *App) userByID(ctx context.Context, id string) (*User, error) {
|
||||
}
|
||||
u.Disabled = intBool(disabled)
|
||||
u.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
u.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
|
||||
u.CreatedAt = parseTime(created)
|
||||
if err := a.attachUserAuthorization(ctx, &u); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -25,8 +25,12 @@ const (
|
||||
sendAuditRetry = "retry"
|
||||
sendAuditCanceled = "canceled"
|
||||
|
||||
sendSourceWebmail = "webmail"
|
||||
sendSourceSubmission = "submission"
|
||||
sendSourceWebmail = "webmail"
|
||||
sendSourceSubmission = "submission"
|
||||
sendSourceOpenAPI = "open_api"
|
||||
sendSourceForwarding = "forwarding"
|
||||
sendSourceRuleForwarding = "rule_forwarding"
|
||||
sendSourceForwardingVerification = "forwarding_verification"
|
||||
|
||||
sendQueueStaleAfter = 15 * time.Minute
|
||||
sendQueueConcurrency = 4
|
||||
@@ -364,10 +368,26 @@ func (a *App) recordSendAudit(ctx context.Context, event, status string, in send
|
||||
if source == "" {
|
||||
source = "unknown"
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx, `INSERT INTO send_audit_events(id,queue_id,user_id,mailbox_id,sent_message_id,source,event,status,mail_from,header_from,recipients_json,error,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, newID("audit"), in.QueueID, in.UserID, in.MailboxID, in.SentMessageID, source, event, status, normalizeEmail(in.MailFrom), normalizeEmail(in.HeaderFrom), jsonEncode(dedupeEmails(in.Recipients)), in.Error, a.now().UTC().Format(time.RFC3339Nano))
|
||||
id := newID("audit")
|
||||
createdAt := a.now().UTC()
|
||||
item := SendAuditEvent{ID: id, QueueID: in.QueueID, MailboxID: in.MailboxID, SentMessageID: in.SentMessageID, Source: source, Event: event, Status: status, MailFrom: normalizeEmail(in.MailFrom), HeaderFrom: normalizeEmail(in.HeaderFrom), Recipients: dedupeEmails(in.Recipients), Error: in.Error, CreatedAt: createdAt}
|
||||
tx, err := a.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
a.log.Warn("failed to start send audit transaction", "event", event, "error", err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO send_audit_events(id,queue_id,user_id,mailbox_id,sent_message_id,source,event,status,mail_from,header_from,recipients_json,error,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, in.QueueID, in.UserID, in.MailboxID, in.SentMessageID, source, event, status, item.MailFrom, item.HeaderFrom, jsonEncode(item.Recipients), in.Error, createdAt.Format(time.RFC3339Nano)); err != nil {
|
||||
a.log.Warn("failed to record send audit", "event", event, "error", err)
|
||||
return
|
||||
}
|
||||
if err := a.enqueueStatusWebhook(ctx, tx, "audit:"+id, "send."+event, in.MailboxID, item); err != nil {
|
||||
a.log.Warn("failed to enqueue send status webhook", "event", event, "error", err)
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
a.log.Warn("failed to commit send audit", "event", event, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -251,7 +251,7 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
|
||||
domain = "lanqin.local"
|
||||
}
|
||||
now := a.now().UTC()
|
||||
subject := "LanQin Email SMTP 测试"
|
||||
subject := "NewSzxcn 邮箱 SMTP 测试"
|
||||
bodyText := "这是一封 SMTP 测试邮件。"
|
||||
bodyHTML := "<p>这是一封 SMTP 测试邮件。</p>"
|
||||
if tpl, err := a.mailTemplate(r.Context(), smtpTestTemplateKey); err == nil {
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const statusWebhookMaxAttempts = 10
|
||||
|
||||
type statusWebhookEnvelope struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
|
||||
func (a *App) enqueueStatusWebhook(ctx context.Context, db dbExecutor, eventKey, eventType, mailboxID string, data any) error {
|
||||
if strings.TrimSpace(a.cfg.StatusWebhookURL) == "" {
|
||||
return nil
|
||||
}
|
||||
now := a.now().UTC()
|
||||
id := newID("whk")
|
||||
payload := jsonEncode(statusWebhookEnvelope{ID: id, Type: eventType, CreatedAt: now.Format(time.RFC3339Nano), Data: data})
|
||||
_, err := db.ExecContext(ctx, `INSERT OR IGNORE INTO status_webhook_outbox(id,event_key,event_type,mailbox_id,payload_json,next_attempt_at,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`, id, eventKey, eventType, mailboxID, payload, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) statusWebhookWorker(ctx context.Context) {
|
||||
if strings.TrimSpace(a.cfg.StatusWebhookURL) == "" {
|
||||
return
|
||||
}
|
||||
a.log.Info("status webhook worker started")
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if err := a.processDueStatusWebhooks(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
a.log.Warn("status webhook worker failed", "error", err)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
a.log.Info("status webhook worker stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) processDueStatusWebhooks(ctx context.Context) error {
|
||||
if strings.TrimSpace(a.cfg.StatusWebhookURL) == "" {
|
||||
return nil
|
||||
}
|
||||
_, _ = a.db.ExecContext(ctx, `DELETE FROM status_webhook_outbox
|
||||
WHERE updated_at<? AND (delivered_at IS NOT NULL OR attempt_count>=?)`, a.now().UTC().Add(-30*24*time.Hour).Format(time.RFC3339Nano), statusWebhookMaxAttempts)
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id,payload_json,attempt_count FROM status_webhook_outbox
|
||||
WHERE delivered_at IS NULL AND attempt_count<? AND next_attempt_at<=? ORDER BY next_attempt_at,created_at LIMIT 20`, statusWebhookMaxAttempts, a.now().UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type item struct {
|
||||
id, payload string
|
||||
attempt int
|
||||
}
|
||||
items := []item{}
|
||||
for rows.Next() {
|
||||
var value item
|
||||
if err := rows.Scan(&value.id, &value.payload, &value.attempt); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
items = append(items, value)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, value := range items {
|
||||
if err := a.deliverStatusWebhook(ctx, value.id, []byte(value.payload)); err != nil {
|
||||
now := a.now().UTC()
|
||||
next := now.Add(sendRetryDelay(value.attempt + 1))
|
||||
_, _ = a.db.ExecContext(ctx, `UPDATE status_webhook_outbox SET attempt_count=attempt_count+1,next_attempt_at=?,last_error=?,updated_at=? WHERE id=? AND delivered_at IS NULL`, next.Format(time.RFC3339Nano), truncateWebhookError(err.Error()), now.Format(time.RFC3339Nano), value.id)
|
||||
continue
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
_, _ = a.db.ExecContext(ctx, `UPDATE status_webhook_outbox SET attempt_count=attempt_count+1,last_error='',updated_at=?,delivered_at=? WHERE id=? AND delivered_at IS NULL`, now, now, value.id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) deliverStatusWebhook(ctx context.Context, eventID string, payload []byte) error {
|
||||
target, err := a.validatedStatusWebhookURL(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
timestamp := strconv.FormatInt(a.now().UTC().Unix(), 10)
|
||||
mac := hmac.New(sha256.New, []byte(a.cfg.StatusWebhookSecret))
|
||||
_, _ = mac.Write([]byte(timestamp + "."))
|
||||
_, _ = mac.Write(payload)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "NewSzxcn-Email-Webhook/1.0")
|
||||
req.Header.Set("X-LanQin-Webhook-Id", eventID)
|
||||
req.Header.Set("X-LanQin-Timestamp", timestamp)
|
||||
req.Header.Set("X-LanQin-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||
Transport: &http.Transport{DialContext: a.statusWebhookDialContext, DisableKeepAlives: true, TLSHandshakeTimeout: 5 * time.Second, ResponseHeaderTimeout: 5 * time.Second},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("status webhook returned %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) validatedStatusWebhookURL(ctx context.Context) (*url.URL, error) {
|
||||
if strings.TrimSpace(a.cfg.StatusWebhookSecret) == "" {
|
||||
return nil, errors.New("LANQIN_STATUS_WEBHOOK_SECRET is required")
|
||||
}
|
||||
target, err := url.Parse(strings.TrimSpace(a.cfg.StatusWebhookURL))
|
||||
if err != nil || target.Hostname() == "" || target.User != nil || target.Fragment != "" {
|
||||
return nil, errors.New("invalid status webhook URL")
|
||||
}
|
||||
if target.Scheme != "https" && !(a.cfg.StatusWebhookAllowPrivateHosts && target.Scheme == "http") {
|
||||
return nil, errors.New("status webhook URL must use HTTPS")
|
||||
}
|
||||
if !a.cfg.StatusWebhookAllowPrivateHosts {
|
||||
if err := validatePublicWebhookHost(ctx, target.Hostname()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (a *App) statusWebhookDialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.cfg.StatusWebhookAllowPrivateHosts {
|
||||
return (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, network, address)
|
||||
}
|
||||
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if !isPublicStatusWebhookIP(ip) {
|
||||
return nil, errors.New("private or local status webhook hosts are not allowed")
|
||||
}
|
||||
}
|
||||
dialer := &net.Dialer{Timeout: 5 * time.Second}
|
||||
var lastErr error
|
||||
for _, ip := range ips {
|
||||
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("status webhook host resolved without usable addresses")
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
|
||||
func validatePublicWebhookHost(ctx context.Context, host string) error {
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
return errors.New("localhost status webhook hosts are not allowed")
|
||||
}
|
||||
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve status webhook host: %w", err)
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if !isPublicStatusWebhookIP(ip) {
|
||||
return errors.New("private or local status webhook hosts are not allowed")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isPublicStatusWebhookIP(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
return ip.IsGlobalUnicast() && !ip.IsLoopback() && !ip.IsPrivate() && !ip.IsLinkLocalUnicast() && !ip.IsLinkLocalMulticast() && !ip.IsMulticast() && !ip.IsUnspecified()
|
||||
}
|
||||
|
||||
func truncateWebhookError(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) > 1000 {
|
||||
return value[:1000]
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
BuildVersion = "dev"
|
||||
BuildCommit = ""
|
||||
BuildDate = ""
|
||||
)
|
||||
|
||||
type systemVersionInfo struct {
|
||||
CurrentVersion string `json:"currentVersion"`
|
||||
CurrentCommit string `json:"currentCommit,omitempty"`
|
||||
BuildDate string `json:"buildDate,omitempty"`
|
||||
LatestVersion string `json:"latestVersion,omitempty"`
|
||||
LatestName string `json:"latestName,omitempty"`
|
||||
ReleaseURL string `json:"releaseUrl,omitempty"`
|
||||
ReleaseNotes string `json:"releaseNotes,omitempty"`
|
||||
PublishedAt *time.Time `json:"publishedAt,omitempty"`
|
||||
UpdateAvailable bool `json:"updateAvailable"`
|
||||
UpdateEnabled bool `json:"updateEnabled"`
|
||||
CheckError string `json:"checkError,omitempty"`
|
||||
}
|
||||
|
||||
type githubRelease struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Body string `json:"body"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
}
|
||||
|
||||
func (a *App) handleSystemVersion(w http.ResponseWriter, r *http.Request) {
|
||||
info, err := a.systemVersion(r.Context())
|
||||
if err != nil {
|
||||
info.CheckError = "暂时无法连接版本服务"
|
||||
a.log.Warn("check system version", "error", err)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, info)
|
||||
}
|
||||
|
||||
func (a *App) handleSystemUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
if user == nil || user.Role != "admin" {
|
||||
respondError(w, http.StatusForbidden, "system administrator required")
|
||||
return
|
||||
}
|
||||
if !a.updateEnabled() {
|
||||
respondError(w, http.StatusServiceUnavailable, "online update is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
info, err := a.systemVersion(r.Context())
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadGateway, "failed to check latest release")
|
||||
return
|
||||
}
|
||||
if !info.UpdateAvailable {
|
||||
respondError(w, http.StatusConflict, "already on the latest version")
|
||||
return
|
||||
}
|
||||
|
||||
backupPath, err := a.backupDatabaseBeforeUpdate(r.Context())
|
||||
if err != nil {
|
||||
a.log.Error("backup database before update", "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "failed to back up database")
|
||||
return
|
||||
}
|
||||
if err := a.triggerUpdateService(r.Context()); err != nil {
|
||||
a.log.Error("trigger system update", "error", err)
|
||||
respondError(w, http.StatusBadGateway, "failed to start update")
|
||||
return
|
||||
}
|
||||
|
||||
a.log.Info("system update requested", "user", user.ID, "from", info.CurrentVersion, "to", info.LatestVersion, "backup", backupPath)
|
||||
respondJSON(w, http.StatusAccepted, map[string]any{
|
||||
"ok": true,
|
||||
"currentVersion": info.CurrentVersion,
|
||||
"targetVersion": info.LatestVersion,
|
||||
"message": "更新已启动,服务会在完成后自动恢复",
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) systemVersion(ctx context.Context) (systemVersionInfo, error) {
|
||||
current := strings.TrimSpace(a.cfg.AppVersion)
|
||||
if current == "" {
|
||||
current = BuildVersion
|
||||
}
|
||||
info := systemVersionInfo{
|
||||
CurrentVersion: current,
|
||||
CurrentCommit: strings.TrimSpace(BuildCommit),
|
||||
BuildDate: strings.TrimSpace(BuildDate),
|
||||
UpdateEnabled: a.updateEnabled(),
|
||||
}
|
||||
|
||||
release, err := a.fetchLatestRelease(ctx)
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
info.LatestVersion = strings.TrimSpace(release.TagName)
|
||||
info.LatestName = strings.TrimSpace(release.Name)
|
||||
info.ReleaseURL = strings.TrimSpace(release.HTMLURL)
|
||||
info.ReleaseNotes = strings.TrimSpace(release.Body)
|
||||
if !release.PublishedAt.IsZero() {
|
||||
info.PublishedAt = &release.PublishedAt
|
||||
}
|
||||
info.UpdateAvailable = versionIsNewer(info.LatestVersion, info.CurrentVersion)
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (a *App) fetchLatestRelease(ctx context.Context) (githubRelease, error) {
|
||||
endpoint := strings.TrimSpace(a.cfg.ReleaseAPIURL)
|
||||
parsed, err := url.Parse(endpoint)
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return githubRelease{}, errors.New("invalid release API URL")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
|
||||
if err != nil {
|
||||
return githubRelease{}, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", "NewSzxcn-Email/"+strings.TrimPrefix(a.cfg.AppVersion, "v"))
|
||||
client := &http.Client{
|
||||
Timeout: 8 * time.Second,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return githubRelease{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||
return githubRelease{}, fmt.Errorf("release API returned %s", resp.Status)
|
||||
}
|
||||
var release githubRelease
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 2<<20)).Decode(&release); err != nil {
|
||||
return githubRelease{}, err
|
||||
}
|
||||
if strings.TrimSpace(release.TagName) == "" {
|
||||
return githubRelease{}, errors.New("release API returned an empty tag")
|
||||
}
|
||||
return release, nil
|
||||
}
|
||||
|
||||
func (a *App) updateEnabled() bool {
|
||||
return strings.TrimSpace(a.cfg.UpdateServiceURL) != "" && strings.TrimSpace(a.cfg.UpdateServiceToken) != ""
|
||||
}
|
||||
|
||||
func (a *App) triggerUpdateService(ctx context.Context) error {
|
||||
parsed, err := url.Parse(strings.TrimSpace(a.cfg.UpdateServiceURL))
|
||||
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return errors.New("invalid update service URL")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, parsed.String(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(a.cfg.UpdateServiceToken))
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("update service returned %s", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) backupDatabaseBeforeUpdate(ctx context.Context) (string, error) {
|
||||
backupDir := filepath.Join(a.cfg.DataDir, "backups")
|
||||
if err := os.MkdirAll(backupDir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
backupPath := filepath.Join(backupDir, "pre-update-"+a.now().UTC().Format("20060102T150405.000000000Z")+".db")
|
||||
quotedPath := strings.ReplaceAll(backupPath, "'", "''")
|
||||
if _, err := a.db.ExecContext(ctx, "VACUUM INTO '"+quotedPath+"'"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := pruneUpdateBackups(backupDir, 5); err != nil {
|
||||
a.log.Warn("prune update backups", "error", err)
|
||||
}
|
||||
return backupPath, nil
|
||||
}
|
||||
|
||||
func pruneUpdateBackups(dir string, keep int) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type backupFile struct {
|
||||
path string
|
||||
modTime time.Time
|
||||
}
|
||||
backups := make([]backupFile, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasPrefix(entry.Name(), "pre-update-") || !strings.HasSuffix(entry.Name(), ".db") {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
backups = append(backups, backupFile{path: filepath.Join(dir, entry.Name()), modTime: info.ModTime()})
|
||||
}
|
||||
sort.Slice(backups, func(i, j int) bool { return backups[i].modTime.After(backups[j].modTime) })
|
||||
if keep < 0 {
|
||||
keep = 0
|
||||
}
|
||||
if len(backups) <= keep {
|
||||
return nil
|
||||
}
|
||||
for _, backup := range backups[keep:] {
|
||||
if err := os.Remove(backup.path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var versionPattern = regexp.MustCompile(`^[vV]?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$`)
|
||||
|
||||
func versionIsNewer(latest, current string) bool {
|
||||
latestParts, latestPrerelease, latestOK := parseVersion(latest)
|
||||
currentParts, currentPrerelease, currentOK := parseVersion(current)
|
||||
if !latestOK {
|
||||
return false
|
||||
}
|
||||
if !currentOK {
|
||||
return true
|
||||
}
|
||||
for i := 0; i < len(latestParts); i++ {
|
||||
if latestParts[i] != currentParts[i] {
|
||||
return latestParts[i] > currentParts[i]
|
||||
}
|
||||
}
|
||||
return currentPrerelease != "" && latestPrerelease == ""
|
||||
}
|
||||
|
||||
func parseVersion(value string) ([3]int, string, bool) {
|
||||
match := versionPattern.FindStringSubmatch(strings.TrimSpace(value))
|
||||
if match == nil {
|
||||
return [3]int{}, "", false
|
||||
}
|
||||
var parts [3]int
|
||||
for i := 0; i < 3; i++ {
|
||||
if match[i+1] == "" {
|
||||
continue
|
||||
}
|
||||
part, err := strconv.Atoi(match[i+1])
|
||||
if err != nil {
|
||||
return [3]int{}, "", false
|
||||
}
|
||||
parts[i] = part
|
||||
}
|
||||
return parts, match[4], true
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSystemVersionAndUpdate(t *testing.T) {
|
||||
releaseServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `{"tag_name":"v0.2.0","name":"Version 0.2.0","html_url":"https://example.test/releases/v0.2.0","body":"Release notes","published_at":"2026-08-03T00:00:00Z"}`)
|
||||
}))
|
||||
defer releaseServer.Close()
|
||||
|
||||
var updateRequests atomic.Int32
|
||||
updateServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("update method = %s", r.Method)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer update-secret" {
|
||||
t.Errorf("authorization = %q", got)
|
||||
}
|
||||
updateRequests.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer updateServer.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
a := newTestAppWithConfig(t, Config{
|
||||
Addr: ":0",
|
||||
AppVersion: "v0.1.0",
|
||||
DBPath: filepath.Join(dir, "lanqin.db"),
|
||||
DataDir: dir,
|
||||
CookieName: "lanqin_test",
|
||||
SessionTTLHours: 24,
|
||||
AdminEmail: "admin@lanqin.local",
|
||||
AdminPassword: "ChangeMe123!",
|
||||
PublicHostname: "mail.example.test",
|
||||
PublicBaseURL: "http://localhost:5173",
|
||||
AllowInsecureHTTP: true,
|
||||
ReleaseAPIURL: releaseServer.URL,
|
||||
UpdateServiceURL: updateServer.URL,
|
||||
UpdateServiceToken: "update-secret",
|
||||
})
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, nil); code != http.StatusOK {
|
||||
t.Fatalf("login code=%d", code)
|
||||
}
|
||||
|
||||
var version systemVersionInfo
|
||||
if code := admin.do("GET", "/api/admin/system/version", nil, &version); code != http.StatusOK {
|
||||
t.Fatalf("version code=%d", code)
|
||||
}
|
||||
if version.CurrentVersion != "v0.1.0" || version.LatestVersion != "v0.2.0" || !version.UpdateAvailable || !version.UpdateEnabled {
|
||||
t.Fatalf("unexpected version response: %+v", version)
|
||||
}
|
||||
|
||||
var update map[string]any
|
||||
if code := admin.do("POST", "/api/admin/system/update", nil, &update); code != http.StatusAccepted {
|
||||
t.Fatalf("update code=%d response=%v", code, update)
|
||||
}
|
||||
if updateRequests.Load() != 1 {
|
||||
t.Fatalf("update requests=%d", updateRequests.Load())
|
||||
}
|
||||
backups, err := filepath.Glob(filepath.Join(dir, "backups", "pre-update-*.db"))
|
||||
if err != nil || len(backups) != 1 {
|
||||
t.Fatalf("backups=%v err=%v", backups, err)
|
||||
}
|
||||
if info, err := os.Stat(backups[0]); err != nil || info.Size() == 0 {
|
||||
t.Fatalf("backup stat=%v err=%v", info, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemUpdateRequiresSystemAdministrator(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/admin/system/update", nil)
|
||||
req = req.WithContext(context.WithValue(req.Context(), userContextKey, &User{ID: "operator", Role: "user"}))
|
||||
recorder := httptest.NewRecorder()
|
||||
a.handleSystemUpdate(recorder, req)
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("code=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemVersionHandlesReleaseFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a, err := New(Config{
|
||||
Addr: ":0",
|
||||
AppVersion: "v0.1.0",
|
||||
DBPath: filepath.Join(dir, "lanqin.db"),
|
||||
DataDir: dir,
|
||||
CookieName: "lanqin_test",
|
||||
SessionTTLHours: 24,
|
||||
AdminEmail: "admin@lanqin.local",
|
||||
AdminPassword: "ChangeMe123!",
|
||||
PublicHostname: "mail.example.test",
|
||||
PublicBaseURL: "http://localhost:5173",
|
||||
ReleaseAPIURL: "http://127.0.0.1:1/releases/latest",
|
||||
AllowInsecureHTTP: true,
|
||||
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer a.Close()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/system/version", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
a.handleSystemVersion(recorder, req)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("code=%d", recorder.Code)
|
||||
}
|
||||
var info systemVersionInfo
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &info); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(info.CheckError, "版本服务") || info.CurrentVersion != "v0.1.0" {
|
||||
t.Fatalf("unexpected response: %+v", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionIsNewer(t *testing.T) {
|
||||
tests := []struct {
|
||||
latest string
|
||||
current string
|
||||
want bool
|
||||
}{
|
||||
{"v0.2.0", "v0.1.9", true},
|
||||
{"v1.0.0", "v0.99.99", true},
|
||||
{"v1.0.0", "v1.0.0", false},
|
||||
{"v1.0.0-beta.1", "v1.0.0", false},
|
||||
{"v1.0.0", "v1.0.0-beta.1", true},
|
||||
{"v1.0.0+build.2", "v1.0.0+build.1", false},
|
||||
{"v1.0.0", "dev", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := versionIsNewer(tt.latest, tt.current); got != tt.want {
|
||||
t.Errorf("versionIsNewer(%q, %q)=%v want %v", tt.latest, tt.current, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneUpdateBackupsWithFewerFilesThanLimit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "pre-update-one.db")
|
||||
if err := os.WriteFile(path, []byte("backup"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pruneUpdateBackups(dir, 5); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("backup should be retained: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func defaultMailTemplates() []MailTemplate {
|
||||
{
|
||||
Key: "welcome",
|
||||
Name: "欢迎邮件",
|
||||
Subject: "欢迎使用 LanQin Email",
|
||||
Subject: "欢迎使用 NewSzxcn 邮箱",
|
||||
BodyText: "你的自建邮箱 Webmail 已经初始化完成。\n\n请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。",
|
||||
BodyHTML: "<p>你的自建邮箱 Webmail 已经初始化完成。</p><p>请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。</p>",
|
||||
UpdatedAt: now,
|
||||
@@ -51,7 +51,7 @@ func defaultMailTemplates() []MailTemplate {
|
||||
{
|
||||
Key: smtpTestTemplateKey,
|
||||
Name: "SMTP 测试",
|
||||
Subject: "LanQin Email SMTP 测试",
|
||||
Subject: "NewSzxcn 邮箱 SMTP 测试",
|
||||
BodyText: "这是一封 SMTP 测试邮件。\n\n发件人:{{from}}\n收件人:{{to}}\n时间:{{time}}\n主机:{{publicHostname}}",
|
||||
BodyHTML: "<p>这是一封 SMTP 测试邮件。</p><p>发件人:{{from}}<br>收件人:{{to}}<br>时间:{{time}}<br>主机:{{publicHostname}}</p>",
|
||||
UpdatedAt: now,
|
||||
|
||||
@@ -122,11 +122,12 @@ func (a *App) deleteLoginChallenge(ctx context.Context, id string) {
|
||||
}
|
||||
|
||||
func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,disabled,two_factor_enabled,two_factor_secret,created_at FROM users WHERE id=?`, id)
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,login_name,email,display_name,role,disabled,two_factor_enabled,two_factor_secret,mailbox_limit_override,created_at FROM users WHERE id=?`, id)
|
||||
var u User
|
||||
var disabled, twoFactorEnabled int
|
||||
var mailboxLimitOverride sql.NullInt64
|
||||
var secret, created string
|
||||
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &secret, &created); err != nil {
|
||||
if err := row.Scan(&u.ID, &u.LoginName, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &secret, &mailboxLimitOverride, &created); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, "", errNotFound
|
||||
}
|
||||
@@ -134,6 +135,7 @@ func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, e
|
||||
}
|
||||
u.Disabled = intBool(disabled)
|
||||
u.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
u.MailboxLimitOverride = intPtrFromNull(mailboxLimitOverride)
|
||||
u.CreatedAt = parseTime(created)
|
||||
if err := a.attachUserAuthorization(ctx, &u); err != nil {
|
||||
return nil, "", err
|
||||
@@ -172,7 +174,7 @@ func (a *App) handleTwoFactorSetup(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
"secret": secret,
|
||||
"otpauthUrl": totpProvisioningURI("LanQin Email", current.Email, secret),
|
||||
"otpauthUrl": totpProvisioningURI("NewSzxcn 邮箱", current.Email, secret),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -3,18 +3,20 @@ package app
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Protected bool `json:"protected"`
|
||||
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Limits PermissionLimits `json:"limits"`
|
||||
PermissionGroupIDs []string `json:"permissionGroupIds"`
|
||||
PermissionGroups []PermissionGroupSummary `json:"permissionGroups"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
LoginName string `json:"loginName"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Protected bool `json:"protected"`
|
||||
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
||||
MailboxLimitOverride *int `json:"mailboxLimitOverride,omitempty"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Limits PermissionLimits `json:"limits"`
|
||||
PermissionGroupIDs []string `json:"permissionGroupIds"`
|
||||
PermissionGroups []PermissionGroupSummary `json:"permissionGroups"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type AdminUser struct {
|
||||
@@ -23,6 +25,31 @@ type AdminUser struct {
|
||||
Mailboxes []string `json:"mailboxes"`
|
||||
}
|
||||
|
||||
type APIToken struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Scopes []string `json:"scopes"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type DeliveryEvent struct {
|
||||
ID string `json:"id"`
|
||||
ExternalID string `json:"externalId"`
|
||||
Provider string `json:"provider"`
|
||||
QueueID string `json:"queueId"`
|
||||
MessageID string `json:"messageId"`
|
||||
RFCMessageID string `json:"rfcMessageId"`
|
||||
Recipient string `json:"recipient"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type Domain struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -44,6 +71,7 @@ type Mailbox struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
QuotaMB int `json:"quotaMb"`
|
||||
Status string `json:"status"`
|
||||
UnreadCount int `json:"unreadCount"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
@@ -209,15 +237,24 @@ type BlockedSender struct {
|
||||
}
|
||||
|
||||
type MailStats struct {
|
||||
TotalMessages int64 `json:"totalMessages"`
|
||||
UnreadMessages int64 `json:"unreadMessages"`
|
||||
StarredMessages int64 `json:"starredMessages"`
|
||||
AttachmentCount int64 `json:"attachmentCount"`
|
||||
AttachmentBytes int64 `json:"attachmentBytes"`
|
||||
StorageBytes int64 `json:"storageBytes"`
|
||||
QuotaBytes int64 `json:"quotaBytes"`
|
||||
QuotaUsedPct float64 `json:"quotaUsedPct"`
|
||||
ByFolder []MailStatsFolderCount `json:"byFolder"`
|
||||
TotalMessages int64 `json:"totalMessages"`
|
||||
TotalIncoming int64 `json:"totalIncoming"`
|
||||
TotalOutgoing int64 `json:"totalOutgoing"`
|
||||
UnreadMessages int64 `json:"unreadMessages"`
|
||||
TodayOutgoing int64 `json:"todayOutgoing"`
|
||||
DraftMessages int64 `json:"draftMessages"`
|
||||
FailedSends int64 `json:"failedSends"`
|
||||
StarredMessages int64 `json:"starredMessages"`
|
||||
AttachmentCount int64 `json:"attachmentCount"`
|
||||
AttachmentBytes int64 `json:"attachmentBytes"`
|
||||
StorageBytes int64 `json:"storageBytes"`
|
||||
QuotaBytes int64 `json:"quotaBytes"`
|
||||
QuotaUsedPct float64 `json:"quotaUsedPct"`
|
||||
AverageMessageBytes int64 `json:"averageMessageBytes"`
|
||||
ByFolder []MailStatsFolderCount `json:"byFolder"`
|
||||
Trend []MailStatsTrendPoint `json:"trend"`
|
||||
Distribution []MailStatsDistributionItem `json:"distribution"`
|
||||
TopContacts []MailStatsContact `json:"topContacts"`
|
||||
}
|
||||
|
||||
type MailStatsFolderCount struct {
|
||||
@@ -228,6 +265,23 @@ type MailStatsFolderCount struct {
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
type MailStatsTrendPoint struct {
|
||||
Date string `json:"date"`
|
||||
Incoming int64 `json:"incoming"`
|
||||
Outgoing int64 `json:"outgoing"`
|
||||
}
|
||||
|
||||
type MailStatsDistributionItem struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type MailStatsContact struct {
|
||||
Email string `json:"email"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type ExternalIMAPAccount struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
|
||||
@@ -149,6 +149,55 @@ func normalizeEmail(s string) string {
|
||||
return normalizeLocalPart(parts[0]) + "@" + normalizeDomain(parts[1])
|
||||
}
|
||||
|
||||
func normalizeLoginName(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(s, "@") {
|
||||
return normalizeEmail(s)
|
||||
}
|
||||
return normalizeLocalPart(s)
|
||||
}
|
||||
|
||||
func cleanLoginName(value string, fallbacks ...string) (string, error) {
|
||||
loginName := normalizeLoginName(value)
|
||||
for _, fallback := range fallbacks {
|
||||
if loginName != "" {
|
||||
break
|
||||
}
|
||||
loginName = normalizeLoginName(fallback)
|
||||
}
|
||||
if loginName == "" {
|
||||
return "", errors.New("登录名不能为空")
|
||||
}
|
||||
if len([]rune(loginName)) > 80 {
|
||||
return "", errors.New("登录名不能超过 80 个字符")
|
||||
}
|
||||
if strings.Contains(loginName, "@") {
|
||||
parts := strings.SplitN(loginName, "@", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return "", errors.New("登录名格式无效")
|
||||
}
|
||||
return loginName, nil
|
||||
}
|
||||
if len([]rune(loginName)) < 2 {
|
||||
return "", errors.New("登录名至少需要 2 个字符")
|
||||
}
|
||||
return loginName, nil
|
||||
}
|
||||
|
||||
func cleanUsername(value string) (string, error) {
|
||||
username, err := cleanLoginName(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.Contains(username, "@") {
|
||||
return "", errors.New("登录名不能使用邮箱地址")
|
||||
}
|
||||
return username, nil
|
||||
}
|
||||
|
||||
func dedupeEmails(items []string) []string {
|
||||
seen := map[string]bool{}
|
||||
out := make([]string, 0, len(items))
|
||||
@@ -215,6 +264,21 @@ func nullableString(v string) any {
|
||||
return v
|
||||
}
|
||||
|
||||
func nullableInt(v *int) any {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
func intPtrFromNull(v sql.NullInt64) *int {
|
||||
if !v.Valid {
|
||||
return nil
|
||||
}
|
||||
value := int(v.Int64)
|
||||
return &value
|
||||
}
|
||||
|
||||
func parseTime(v string) time.Time {
|
||||
t, _ := time.Parse(time.RFC3339Nano, v)
|
||||
return t
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>LanQin Email</title>
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>NewSzxcn 邮箱</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "lanqin-email-web",
|
||||
"name": "newszxcn-email-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#0f172a"/>
|
||||
<path d="M14 22.5A6.5 6.5 0 0 1 20.5 16h23A6.5 6.5 0 0 1 50 22.5v19A6.5 6.5 0 0 1 43.5 48h-23A6.5 6.5 0 0 1 14 41.5v-19Z" fill="#f8fafc"/>
|
||||
<path d="M18 23.5 32 34l14-10.5" fill="none" stroke="#0f172a" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M24 42V24h4.8l10.4 11.3V24H44v18h-4.8L28.8 30.7V42H24Z" fill="#2563eb"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 479 B |
@@ -1,12 +1,13 @@
|
||||
import * as React from "react"
|
||||
import { Outlet, Link, useLocation } from "react-router-dom"
|
||||
import { BarChart3, Copy, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, ShieldCheck, Users } from "lucide-react"
|
||||
import { BarChart3, ClipboardList, Forward, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, ShieldCheck, UserCog } from "lucide-react"
|
||||
import { useMe } from "@/hooks/use-me"
|
||||
import { useLogout } from "@/hooks/use-logout"
|
||||
import { AuthGuard } from "@/components/auth-guard"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { SystemVersionDialog } from "@/components/system-version-dialog"
|
||||
import { hasAnyPermission } from "@/lib/permissions"
|
||||
import type { PermissionKey } from "@/lib/api-types"
|
||||
import {
|
||||
@@ -27,13 +28,14 @@ import {
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
const adminSections: { key: string; label: string; icon: React.ReactNode; permissions: PermissionKey[] }[] = [
|
||||
{ key: "overview", label: "概览", icon: <BarChart3 />, permissions: ["admin.overview.view"] },
|
||||
{ key: "users", label: "用户", icon: <Users />, permissions: ["admin.users.view"] },
|
||||
{ key: "permissionGroups", label: "权限组", icon: <ShieldCheck />, permissions: ["admin.permission_groups.view"] },
|
||||
{ key: "domains", label: "域名", icon: <Globe2 />, permissions: ["admin.domains.view", "admin.dns.view"] },
|
||||
{ key: "mailboxes", label: "邮箱账号", icon: <Mailbox />, permissions: ["admin.mailboxes.view"] },
|
||||
{ key: "aliases", label: "别名转发", icon: <Copy />, permissions: ["admin.aliases.view"] },
|
||||
{ key: "overview", label: "数据总览", icon: <BarChart3 />, permissions: ["admin.overview.view"] },
|
||||
{ key: "users", label: "账号管理", icon: <UserCog />, permissions: ["admin.users.view"] },
|
||||
{ key: "permissionGroups", label: "权限配额", icon: <ShieldCheck />, permissions: ["admin.permission_groups.view"] },
|
||||
{ key: "domains", label: "域名管理", icon: <Globe2 />, permissions: ["admin.domains.view", "admin.dns.view"] },
|
||||
{ key: "mailboxes", label: "邮箱管理", icon: <Mailbox />, permissions: ["admin.mailboxes.view"] },
|
||||
{ key: "aliases", label: "邮件转发", icon: <Forward />, permissions: ["admin.aliases.view"] },
|
||||
{ key: "messages", label: "全部邮件", icon: <Inbox />, permissions: ["admin.messages.view"] },
|
||||
{ key: "sendAudit", label: "发送队列", icon: <ClipboardList />, permissions: ["admin.messages.view"] },
|
||||
{ key: "settings", label: "系统设置", icon: <Settings />, permissions: ["admin.settings.view", "admin.templates.view"] },
|
||||
]
|
||||
|
||||
@@ -64,21 +66,24 @@ function ProtectedContent() {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<Sidebar collapsible="icon">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<Link to="/">
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<Mail className="size-4" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">LanQin Email</span>
|
||||
</div>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
<SidebarHeader className="border-b">
|
||||
<div className="space-y-1 group-data-[collapsible=icon]:space-y-0">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<Link to="/">
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<Mail className="size-4" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">NewSzxcn 邮箱</span>
|
||||
</div>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
{isAdminRoute && <SystemVersionDialog className="ml-10" />}
|
||||
</div>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{isAdminRoute && visibleAdminSections.length > 0 && (
|
||||
@@ -103,10 +108,10 @@ function ProtectedContent() {
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{user.displayName}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{user.loginName || user.email}</span>
|
||||
</div>
|
||||
<Badge variant={user.role === "admin" ? "default" : "secondary"} className="ml-auto text-[10px]">
|
||||
{user.role === "admin" ? "超级管理员" : "普通用户"}
|
||||
{user.role === "admin" ? "管理员" : "普通用户"}
|
||||
</Badge>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
@@ -125,7 +130,7 @@ function ProtectedContent() {
|
||||
<div className="flex h-12 items-center gap-3 border-b bg-background px-3 md:hidden">
|
||||
<SidebarTrigger aria-label="打开导航" />
|
||||
<div className="min-w-0 flex-1 truncate text-sm font-semibold">
|
||||
{isAdminRoute ? visibleAdminSections.find((item) => item.key === adminSection)?.label || "系统管理" : "LanQin Email"}
|
||||
{isAdminRoute ? visibleAdminSections.find((item) => item.key === adminSection)?.label || "系统管理" : "NewSzxcn 邮箱"}
|
||||
</div>
|
||||
</div>
|
||||
<Outlet />
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import * as React from "react"
|
||||
import { useMutation, useQuery } from "@tanstack/react-query"
|
||||
import { CheckCircle2, Download, ExternalLink, Loader2, RefreshCcw, TriangleAlert } from "lucide-react"
|
||||
import { api } from "@/lib/api"
|
||||
import { cn, formatDate } from "@/lib/utils"
|
||||
import { useMe } from "@/hooks/use-me"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
|
||||
const frontendVersion = import.meta.env.VITE_APP_VERSION || "dev"
|
||||
|
||||
export function SystemVersionDialog({ mode = "sidebar", className }: { mode?: "sidebar" | "inline"; className?: string }) {
|
||||
const me = useMe()
|
||||
const { toast } = useToast()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [updatePhase, setUpdatePhase] = React.useState<"idle" | "starting" | "restarting">("idle")
|
||||
const version = useQuery({
|
||||
queryKey: ["admin", "system-version"],
|
||||
queryFn: api.systemVersion,
|
||||
staleTime: 5 * 60_000,
|
||||
retry: 1,
|
||||
})
|
||||
const currentVersion = version.data?.currentVersion || frontendVersion
|
||||
const isSystemAdmin = me.data?.user.role === "admin"
|
||||
const update = useMutation({
|
||||
mutationFn: async () => {
|
||||
setUpdatePhase("starting")
|
||||
const result = await api.updateSystem()
|
||||
setUpdatePhase("restarting")
|
||||
await waitForUpdatedService(result.targetVersion)
|
||||
return result
|
||||
},
|
||||
onError: (error) => {
|
||||
setUpdatePhase("idle")
|
||||
toast({ title: "更新失败", description: error.message })
|
||||
},
|
||||
})
|
||||
|
||||
const trigger = mode === "inline" ? (
|
||||
<Button type="button" variant="outline" className={cn("h-11 justify-start gap-2 px-4 text-base font-normal", className)}>
|
||||
<RefreshCcw className="h-5 w-5 text-primary" />
|
||||
{currentVersion}
|
||||
{version.data?.updateAvailable && <Badge className="ml-1">可更新</Badge>}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant={version.data?.updateAvailable ? "secondary" : "ghost"}
|
||||
className={cn("h-8 w-fit max-w-full justify-start gap-2 rounded-md px-2 text-xs font-medium group-data-[collapsible=icon]:hidden", className)}
|
||||
aria-label={`系统版本 ${currentVersion}`}
|
||||
>
|
||||
<span className="truncate">{currentVersion}</span>
|
||||
<span className={cn("h-2 w-2 shrink-0 rounded-full", version.data?.updateAvailable ? "bg-amber-500" : "bg-emerald-500")} aria-hidden="true" />
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent className="max-h-[88svh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between gap-3 pr-7">
|
||||
<DialogTitle>系统版本</DialogTitle>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-8 w-8" onClick={() => version.refetch()} disabled={version.isFetching || update.isPending} aria-label="重新检查更新" title="重新检查更新">
|
||||
<RefreshCcw className={cn("h-4 w-4", version.isFetching && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="border-b pb-4 text-center">
|
||||
<div className="text-sm text-muted-foreground">当前版本</div>
|
||||
<div className="mt-2 text-4xl font-semibold tabular-nums">{currentVersion}</div>
|
||||
{version.data?.latestVersion && <div className="mt-2 text-sm text-muted-foreground">最新版本:{version.data.latestVersion}</div>}
|
||||
</div>
|
||||
|
||||
{version.isLoading && <VersionState icon={<Loader2 className="animate-spin" />} title="正在检查更新" description="正在连接 GitHub Release。" />}
|
||||
{version.data?.checkError && <VersionState icon={<TriangleAlert />} title="暂时无法检查更新" description={version.data.checkError} tone="warning" />}
|
||||
{version.data && !version.data.checkError && !version.data.updateAvailable && <VersionState icon={<CheckCircle2 />} title="已是最新版本" description="当前无需更新。" tone="success" />}
|
||||
{version.data?.updateAvailable && (
|
||||
<VersionState
|
||||
icon={<Download />}
|
||||
title="发现新版本"
|
||||
description={`${version.data.latestVersion} 已发布${version.data.publishedAt ? ` · ${formatDate(version.data.publishedAt)}` : ""}`}
|
||||
tone="warning"
|
||||
/>
|
||||
)}
|
||||
|
||||
{version.data?.releaseNotes && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">更新日志</div>
|
||||
<div className="max-h-40 overflow-y-auto whitespace-pre-wrap rounded-md border bg-muted/30 p-3 text-sm leading-6 text-muted-foreground">
|
||||
{version.data.releaseNotes}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{update.isPending && (
|
||||
<div className="rounded-md border bg-muted/30 p-4">
|
||||
<div className="flex items-center gap-3 font-medium">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
{updatePhase === "starting" ? "正在准备更新" : "正在重启服务"}
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">请保持页面打开,服务恢复后会自动刷新。</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{version.data?.updateAvailable && !version.data.updateEnabled && (
|
||||
<div className="rounded-md border p-3 text-sm text-muted-foreground">
|
||||
当前部署未启用页面更新,请在服务器执行 <code className="rounded bg-muted px-1.5 py-0.5 text-foreground">sudo newszxcn-email update</code>。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:justify-between">
|
||||
<div>
|
||||
{version.data?.releaseUrl && (
|
||||
<Button type="button" variant="ghost" asChild>
|
||||
<a href={version.data.releaseUrl} target="_blank" rel="noreferrer">
|
||||
更新详情<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{version.data?.updateAvailable && version.data.updateEnabled && (
|
||||
<Button type="button" disabled={!isSystemAdmin || update.isPending} onClick={() => update.mutate()}>
|
||||
{update.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
|
||||
{isSystemAdmin ? "立即更新" : "仅超级管理员可更新"}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function VersionState({ icon, title, description, tone = "neutral" }: { icon: React.ReactNode; title: string; description: string; tone?: "neutral" | "success" | "warning" }) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex items-start gap-3 rounded-md border p-4",
|
||||
tone === "success" && "border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-100",
|
||||
tone === "warning" && "border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-100",
|
||||
)}>
|
||||
<span className="mt-0.5 [&>svg]:h-5 [&>svg]:w-5">{icon}</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-medium">{title}</span>
|
||||
<span className="mt-1 block text-sm opacity-75">{description}</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForUpdatedService(targetVersion: string) {
|
||||
const deadline = Date.now() + 8 * 60_000
|
||||
while (Date.now() < deadline) {
|
||||
await delay(3000)
|
||||
try {
|
||||
const health = await fetch(`/healthz?update=${Date.now()}`, { cache: "no-store" })
|
||||
if (!health.ok) {
|
||||
continue
|
||||
}
|
||||
const response = await fetch(`/api/admin/system/version?update=${Date.now()}`, { credentials: "include", cache: "no-store" })
|
||||
if (!response.ok) continue
|
||||
const body = await response.json() as { currentVersion?: string }
|
||||
if (body.currentVersion === targetVersion) {
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
throw new Error("更新等待超时,请稍后手动刷新页面检查服务状态")
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -5,39 +5,50 @@
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 224 44% 12%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 84% 4.9%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 84% 4.9%;
|
||||
--destructive: 358 88% 61%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 5.9% 10%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 216 22% 42%;
|
||||
--radius: 0.5rem;
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-background: 0 0% 100%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 224 44% 12%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
--sidebar-accent: 210 40% 96.1%;
|
||||
--sidebar-accent-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-border: 214.3 31.8% 91.4%;
|
||||
--sidebar-ring: 215.4 16.3% 46.9%;
|
||||
}
|
||||
|
||||
* { @apply border-border; }
|
||||
body { @apply bg-background text-foreground antialiased; }
|
||||
html, body, #root { min-height: 100%; }
|
||||
html { color-scheme: light; }
|
||||
html {
|
||||
color-scheme: light;
|
||||
font-size: 16px;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
}
|
||||
html, body, #root {
|
||||
min-height: 100%;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
html.dark { color-scheme: dark; }
|
||||
|
||||
.dark {
|
||||
@@ -71,6 +82,72 @@
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.mail-shell-grid {
|
||||
display: grid;
|
||||
grid-template-columns: clamp(236px, 17vw, 344px) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.mail-content-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 384px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.mail-list-pane,
|
||||
.mail-detail-pane {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mail-list-pane {
|
||||
border-right: 1px solid hsl(var(--border));
|
||||
width: 384px;
|
||||
max-width: 384px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.mail-content-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.mail-list-pane {
|
||||
border-right: 0;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-sidebar="content"] {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
[data-sidebar="group"] {
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
[data-sidebar="menu"] {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
[data-sidebar="menu-button"] {
|
||||
color: hsl(var(--sidebar-foreground));
|
||||
}
|
||||
|
||||
[data-sidebar="menu-button"] svg {
|
||||
color: hsl(var(--muted-foreground));
|
||||
stroke-width: 1.8;
|
||||
}
|
||||
|
||||
[data-sidebar="menu-button"][data-active="true"] {
|
||||
background: hsl(var(--sidebar-accent));
|
||||
color: hsl(var(--sidebar-accent-foreground));
|
||||
}
|
||||
|
||||
[data-sidebar="menu-button"][data-active="true"] svg {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
}
|
||||
|
||||
html.theme-transition body {
|
||||
transition: background-color 180ms ease, color 180ms ease;
|
||||
}
|
||||
|
||||
@@ -47,14 +47,15 @@ export type PermissionKey =
|
||||
| "admin.templates.update"
|
||||
| "admin.templates.reset"
|
||||
export type PermissionInfo = { key: PermissionKey; label: string; description: string; category: string }
|
||||
export type PermissionLimits = { maxAttachmentMb: number; smtpDailyLimit: number; smtpMinuteLimit: number; imapMinuteLimit: number; pop3MinuteLimit: number }
|
||||
export type PermissionLimits = { maxAttachmentMb: number; maxMailboxCount: number; smtpDailyLimit: number; smtpMinuteLimit: number; imapMinuteLimit: number; pop3MinuteLimit: number }
|
||||
export type PermissionGroupSummary = { id: string; name: string }
|
||||
export type PermissionGroup = { id: string; name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits; system: boolean; userCount: number; createdAt: string; updatedAt: string }
|
||||
export type User = { id: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; permissions: PermissionKey[]; limits: PermissionLimits; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string }
|
||||
export type User = { id: string; loginName?: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; mailboxLimitOverride?: number | null; permissions: PermissionKey[]; limits: PermissionLimits; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string }
|
||||
export type APIToken = { id: string; name: string; lastUsedAt?: string; expiresAt?: string; disabled: boolean; scopes: string[]; createdAt: string; updatedAt: string }
|
||||
export type AdminUser = User & { mailboxCount: number; mailboxes?: string[] }
|
||||
export type AdminOverview = { users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number; storageBytes: number }
|
||||
export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string }
|
||||
export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; createdAt: string }
|
||||
export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; unreadCount?: number; createdAt: string }
|
||||
export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string }
|
||||
export type MailFolder = { id: string; name: string; role: string; sortOrder: number; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number }
|
||||
export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string }
|
||||
@@ -69,7 +70,7 @@ export type MailMessage = {
|
||||
export type DNSRecord = { type: string; name: string; value: string; ttl: number }
|
||||
export type DNSCheckResult = { domain: string; status: string; checks: Record<string, { ok: boolean; message: string; found?: string[] }> }
|
||||
export type ListResponse<T> = { items: T[]; nextCursor?: string }
|
||||
export type MailTranslation = { translatedText: string; sourceLanguage?: string; targetLanguage: string; truncated: boolean }
|
||||
export type MailTranslation = { translatedText: string; translatedHtml?: string; sourceLanguage?: string; targetLanguage: string; truncated: boolean }
|
||||
export type SendPayload = { mailboxId?: string; to: string[]; cc: string[]; bcc: string[]; subject: string; text: string; html: string; attachments: { filename: string; contentType: string; contentBase64: string }[] }
|
||||
export type DraftPayload = Omit<SendPayload, "attachments"> & { attachments?: SendPayload["attachments"] }
|
||||
export type ScheduleSendPayload = SendPayload & { draftId?: string; sendAt: string }
|
||||
@@ -120,10 +121,42 @@ export type MailSignature = { id: string; mailboxId: string; name: string; conte
|
||||
export type MailRuleConditionField = "from" | "to" | "cc" | "subject" | "body" | "attachment" | "size" | "date"
|
||||
export type MailRuleConditionOperator = "contains" | "not-contains" | "equals" | "not-equals" | "starts-with" | "ends-with" | "gt" | "gte" | "lt" | "lte" | "before" | "after" | "on"
|
||||
export type MailRuleCondition = { field?: MailRuleConditionField; operator?: MailRuleConditionOperator; value?: string; matchMode?: "all" | "any"; conditions?: MailRuleCondition[] }
|
||||
export type MailRuleAction = { type: "archive" | "trash" | "star" | "mark-read" | "label" | "move"; value?: string; labelId?: string }
|
||||
export type MailRule = { id: string; mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; fromContains: string; subjectContains: string; action: "archive" | "trash" | "star" | "mark-read" | "label" | "move"; enabled: boolean; createdAt: string; appliedExistingCount?: number }
|
||||
export type MailRuleAction = { type: "archive" | "trash" | "star" | "mark-read" | "label" | "move" | "forward"; value?: string; labelId?: string }
|
||||
export type MailRule = { id: string; mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; fromContains: string; subjectContains: string; action: "archive" | "trash" | "star" | "mark-read" | "label" | "move" | "forward"; enabled: boolean; createdAt: string; appliedExistingCount?: number }
|
||||
export type BlockedSender = { id: string; mailboxId: string; email: string; reason: string; createdAt: string }
|
||||
export type MailStats = { totalMessages: number; unreadMessages: number; starredMessages: number; attachmentCount: number; attachmentBytes: number; storageBytes: number; quotaBytes: number; quotaUsedPct: number; byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] }
|
||||
export type MailStats = {
|
||||
totalMessages: number
|
||||
totalIncoming: number
|
||||
totalOutgoing: number
|
||||
unreadMessages: number
|
||||
todayOutgoing: number
|
||||
draftMessages: number
|
||||
failedSends: number
|
||||
starredMessages: number
|
||||
attachmentCount: number
|
||||
attachmentBytes: number
|
||||
storageBytes: number
|
||||
quotaBytes: number
|
||||
quotaUsedPct: number
|
||||
averageMessageBytes: number
|
||||
byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[]
|
||||
trend: { date: string; incoming: number; outgoing: number }[]
|
||||
distribution: { key: string; label: string; count: number }[]
|
||||
topContacts: { email: string; count: number }[]
|
||||
}
|
||||
export type ForwardingVerifiedEmail = {
|
||||
id: string
|
||||
email: string
|
||||
verified: boolean
|
||||
createdAt: string
|
||||
verifiedAt?: string
|
||||
verificationSentAt?: string
|
||||
verificationExpiresAt?: string
|
||||
deliveryStatus?: SendQueueStatus | "verified"
|
||||
deliveryError?: string
|
||||
}
|
||||
export type MailboxForwardingRule = { mailboxId: string; targetEmail: string; targetEmails?: string[] }
|
||||
export type ForwardingSettings = { verifiedEmails: ForwardingVerifiedEmail[]; accountTargetEmail: string; accountTargetEmails?: string[]; mailboxRules: MailboxForwardingRule[] }
|
||||
export type ExternalImapStorageMode = "local" | "remote"
|
||||
export type ExternalImapTlsMode = "tls" | "starttls" | "plain"
|
||||
export type ExternalImapAuthMode = "password" | "oauth2"
|
||||
@@ -150,6 +183,25 @@ export type MaildirSyncHealth = {
|
||||
recentErrors: string[]
|
||||
summary: MaildirSyncCounts
|
||||
}
|
||||
export type SystemVersion = {
|
||||
currentVersion: string
|
||||
currentCommit?: string
|
||||
buildDate?: string
|
||||
latestVersion?: string
|
||||
latestName?: string
|
||||
releaseUrl?: string
|
||||
releaseNotes?: string
|
||||
publishedAt?: string
|
||||
updateAvailable: boolean
|
||||
updateEnabled: boolean
|
||||
checkError?: string
|
||||
}
|
||||
export type SystemUpdateResult = {
|
||||
ok: boolean
|
||||
currentVersion: string
|
||||
targetVersion: string
|
||||
message: string
|
||||
}
|
||||
export type SystemSettings = {
|
||||
publicHostname: string
|
||||
publicBaseUrl: string
|
||||
@@ -185,6 +237,6 @@ export type SystemSettings = {
|
||||
export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet" | "externalImapSecretSet" | "externalImapGmailClientSecretSet" | "externalImapOutlookClientSecretSet"> & { smtpPassword: string; turnstileSecretKey: string; externalImapSecretKey: string; externalImapGmailClientSecret: string; externalImapOutlookClientSecret: string }
|
||||
export type PublicDomain = { id: string; name: string }
|
||||
export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; publicHostname: string; mailAutoRefresh: boolean; mailRefreshMs: number; externalImapEnabled: boolean; mailboxDomains?: PublicDomain[] }
|
||||
export type LoginPayload = { email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string }
|
||||
export type LoginPayload = { loginName?: string; email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string }
|
||||
export type LoginResponse = { user?: User; twoFactorRequired?: boolean; challengeToken?: string }
|
||||
export type RegisterPayload = { email: string; displayName: string; password: string; turnstileToken?: string; domainId?: string; localPart?: string }
|
||||
|
||||
@@ -1,9 +1,47 @@
|
||||
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits } from "./api-types"
|
||||
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, MailTranslation, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ForwardingSettings, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, SystemVersion, SystemUpdateResult, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits, APIToken } from "./api-types"
|
||||
export * from "./api-types"
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15_000
|
||||
const MAIL_DELIVERY_TIMEOUT_MS = 60_000
|
||||
|
||||
export type MailSearchParams = {
|
||||
q?: string
|
||||
from?: string
|
||||
to?: string
|
||||
subject?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
attachmentMode?: "all" | "with" | "without"
|
||||
minSizeKb?: string
|
||||
maxSizeKb?: string
|
||||
readStatus?: "all" | "read" | "unread"
|
||||
flagStatus?: "all" | "starred" | "unstarred"
|
||||
hasAttachments?: boolean
|
||||
unread?: boolean
|
||||
starred?: boolean
|
||||
}
|
||||
|
||||
function appendMailSearchParams(params: URLSearchParams, search: MailSearchParams | string) {
|
||||
if (typeof search === "string") {
|
||||
if (search) params.set("q", search)
|
||||
return
|
||||
}
|
||||
if (search.q) params.set("q", search.q)
|
||||
if (search.from) params.set("from", search.from)
|
||||
if (search.to) params.set("to", search.to)
|
||||
if (search.subject) params.set("subject", search.subject)
|
||||
if (search.startDate) params.set("startDate", search.startDate)
|
||||
if (search.endDate) params.set("endDate", search.endDate)
|
||||
if (search.attachmentMode && search.attachmentMode !== "all") params.set("attachmentMode", search.attachmentMode)
|
||||
else if (search.hasAttachments) params.set("hasAttachments", "1")
|
||||
if (search.minSizeKb) params.set("minSizeKb", search.minSizeKb)
|
||||
if (search.maxSizeKb) params.set("maxSizeKb", search.maxSizeKb)
|
||||
if (search.readStatus && search.readStatus !== "all") params.set("readStatus", search.readStatus)
|
||||
else if (search.unread) params.set("unread", "1")
|
||||
if (search.flagStatus && search.flagStatus !== "all") params.set("flagStatus", search.flagStatus)
|
||||
else if (search.starred) params.set("starred", "1")
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init: RequestInit & { timeoutMs?: number } = {}): Promise<T> {
|
||||
const { timeoutMs, ...requestInit } = init
|
||||
const controller = new AbortController()
|
||||
@@ -31,6 +69,35 @@ async function request<T>(path: string, init: RequestInit & { timeoutMs?: number
|
||||
}
|
||||
}
|
||||
|
||||
async function requestFile(path: string): Promise<Blob> {
|
||||
const res = await fetch(path, { credentials: "include" })
|
||||
if (!res.ok) {
|
||||
let message = `${res.status} ${res.statusText}`
|
||||
try { const body = await res.json(); message = body.error || message } catch {}
|
||||
throw new Error(message)
|
||||
}
|
||||
return res.blob()
|
||||
}
|
||||
|
||||
async function uploadForm<T>(path: string, form: FormData): Promise<T> {
|
||||
const controller = new AbortController()
|
||||
const timeout = window.setTimeout(() => controller.abort(), 5 * 60_000)
|
||||
try {
|
||||
const res = await fetch(path, { method: "POST", credentials: "include", body: form, signal: controller.signal })
|
||||
if (!res.ok) {
|
||||
let message = `${res.status} ${res.statusText}`
|
||||
try { const body = await res.json(); message = body.error || message } catch {}
|
||||
throw new Error(message)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw new Error("导入超时,请缩小文件后重试")
|
||||
throw error instanceof Error ? error : new Error("网络请求失败")
|
||||
} finally {
|
||||
window.clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
export const api = {
|
||||
publicSettings: () => request<PublicSettings>("/api/public/settings"),
|
||||
register: (payload: RegisterPayload) => request<{ user: User }>("/api/auth/register", { method: "POST", body: JSON.stringify(payload) }),
|
||||
@@ -39,6 +106,10 @@ export const api = {
|
||||
me: () => request<{ user: User }>("/api/me"),
|
||||
updateProfile: (payload: { displayName: string }) => request<{ user: User }>("/api/me/profile", { method: "POST", body: JSON.stringify(payload) }),
|
||||
changePassword: (payload: { currentPassword: string; newPassword: string }) => request<{ ok: boolean }>("/api/me/password", { method: "POST", body: JSON.stringify(payload) }),
|
||||
apiTokens: () => request<ListResponse<APIToken>>("/api/me/api-tokens"),
|
||||
createApiToken: (payload: { name: string; expiresAt?: string; scopes: string[] }) => request<{ token: string; item: APIToken }>("/api/me/api-tokens", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updateApiToken: (id: string, payload: { name?: string; expiresAt?: string; disabled?: boolean; scopes?: string[] }) => request<APIToken>(`/api/me/api-tokens/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
deleteApiToken: (id: string) => request<{ ok: boolean }>(`/api/me/api-tokens/${id}`, { method: "DELETE" }),
|
||||
setupTwoFactor: () => request<{ secret: string; otpauthUrl: string }>("/api/me/2fa/setup", { method: "POST" }),
|
||||
enableTwoFactor: (code: string) => request<{ user: User }>("/api/me/2fa/enable", { method: "POST", body: JSON.stringify({ code }) }),
|
||||
disableTwoFactor: (code: string) => request<{ user: User }>("/api/me/2fa/disable", { method: "POST", body: JSON.stringify({ code }) }),
|
||||
@@ -53,14 +124,29 @@ export const api = {
|
||||
defaultSignature: (mailboxId?: string) => request<{ signature: MailSignature | null }>(`/api/me/signatures/default${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
|
||||
rules: () => request<ListResponse<MailRule>>("/api/me/rules"),
|
||||
createRule: (payload: { mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; enabled: boolean }) => request<MailRule>("/api/me/rules", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updateRule: (id: string, payload: Partial<{ mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; enabled: boolean }>) => request<MailRule>(`/api/me/rules/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
moveRule: (id: string, direction: "up" | "down") => request<{ ok: boolean }>(`/api/me/rules/${id}/move`, { method: "POST", body: JSON.stringify({ direction }) }),
|
||||
applyRule: (id: string) => request<{ ok: boolean; affected: number }>(`/api/me/rules/${id}/apply`, { method: "POST" }),
|
||||
deleteRule: (id: string) => request<{ ok: boolean }>(`/api/me/rules/${id}`, { method: "DELETE" }),
|
||||
blockedSenders: () => request<ListResponse<BlockedSender>>("/api/me/blocked-senders"),
|
||||
createBlockedSender: (payload: { mailboxId: string; email: string; reason: string }) => request<BlockedSender>("/api/me/blocked-senders", { method: "POST", body: JSON.stringify(payload) }),
|
||||
deleteBlockedSender: (id: string) => request<{ ok: boolean }>(`/api/me/blocked-senders/${id}`, { method: "DELETE" }),
|
||||
mailStats: (mailboxId?: string) => request<MailStats>(`/api/me/stats${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
|
||||
mailStats: (mailboxId?: string, days?: number) => {
|
||||
const query = new URLSearchParams()
|
||||
if (mailboxId) query.set("mailboxId", mailboxId)
|
||||
if (days) query.set("days", String(days))
|
||||
const suffix = query.toString()
|
||||
return request<MailStats>(`/api/me/stats${suffix ? `?${suffix}` : ""}`)
|
||||
},
|
||||
cleanupMail: (payload: { mailboxId: string; target: "empty-trash" | "empty-spam" | "archive-read-inbox" }) => request<{ ok: boolean; affected: number }>("/api/me/cleanup", { method: "POST", body: JSON.stringify(payload) }),
|
||||
mailboxApplyOptions: () => request<MailboxApplyOptions>("/api/me/mailbox-apply-options"),
|
||||
applyMailbox: (payload: { domainId: string; localPart: string; displayName: string }) => request<Mailbox>("/api/me/mailboxes/apply", { method: "POST", body: JSON.stringify(payload) }),
|
||||
forwardingSettings: () => request<ForwardingSettings>("/api/me/forwarding"),
|
||||
addForwardingVerifiedEmail: (email: string) => request<ForwardingSettings>("/api/me/forwarding/verified-emails", { method: "POST", body: JSON.stringify({ email }) }),
|
||||
resendForwardingVerifiedEmail: (id: string) => request<ForwardingSettings>(`/api/me/forwarding/verified-emails/${id}/resend`, { method: "POST" }),
|
||||
deleteForwardingVerifiedEmail: (id: string) => request<ForwardingSettings>(`/api/me/forwarding/verified-emails/${id}`, { method: "DELETE" }),
|
||||
updateAccountForwarding: (targetEmails: string[] | string) => request<ForwardingSettings>("/api/me/forwarding/account", { method: "POST", body: JSON.stringify(Array.isArray(targetEmails) ? { targetEmails } : { targetEmail: targetEmails }) }),
|
||||
updateMailboxForwarding: (mailboxId: string, targetEmails: string[] | string) => request<ForwardingSettings>(`/api/me/mailboxes/${mailboxId}/forwarding`, { method: "POST", body: JSON.stringify(Array.isArray(targetEmails) ? { targetEmails } : { targetEmail: targetEmails }) }),
|
||||
externalImapAccounts: (mailboxId?: string) => request<ListResponse<ExternalImapAccount>>(`/api/me/external-imap-accounts${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
|
||||
createExternalImapAccount: (payload: ExternalImapAccountPayload) => request<ExternalImapAccount>("/api/me/external-imap-accounts", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updateExternalImapAccount: (id: string, payload: ExternalImapAccountPayload) => request<ExternalImapAccount>(`/api/me/external-imap-accounts/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
@@ -77,8 +163,8 @@ export const api = {
|
||||
updatePermissionGroup: (id: string, payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request<PermissionGroup>(`/api/admin/permission-groups/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
defaultPermissionLimits: () => request<PermissionLimits>("/api/admin/permission-limits/defaults"),
|
||||
deletePermissionGroup: (id: string) => request<{ ok: boolean }>(`/api/admin/permission-groups/${id}`, { method: "DELETE" }),
|
||||
createUser: (payload: { email: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean; permissionGroupIds?: string[] }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updateUser: (id: string, payload: { displayName: string; role: "admin" | "user"; disabled: boolean; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
createUser: (payload: { loginName: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updateUser: (id: string, payload: { loginName?: string; displayName: string; role: "admin" | "user"; disabled: boolean; mailboxLimitOverride?: number; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
resetUserPassword: (id: string, password: string) => request<{ ok: boolean }>(`/api/admin/users/${id}/password`, { method: "POST", body: JSON.stringify({ password }) }),
|
||||
deleteUser: (id: string) => request<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }),
|
||||
domains: () => request<ListResponse<Domain>>("/api/admin/domains"),
|
||||
@@ -86,7 +172,7 @@ export const api = {
|
||||
updateDomain: (id: string, payload: { status: string }) => request<Domain>(`/api/admin/domains/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
deleteDomain: (id: string) => request<{ ok: boolean }>(`/api/admin/domains/${id}`, { method: "DELETE" }),
|
||||
mailboxes: () => request<ListResponse<Mailbox>>("/api/admin/mailboxes"),
|
||||
createMailbox: (payload: { domainId: string; localPart: string; displayName: string; password: string; quotaMb: number; role: "admin" | "user"; ownerEmail?: string; userId?: string }) => request<Mailbox>("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }),
|
||||
createMailbox: (payload: { domainId: string; localPart: string; displayName: string; password: string; quotaMb: number; role: "admin" | "user"; ownerLoginName?: string; ownerEmail?: string; userId?: string }) => request<Mailbox>("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updateMailbox: (id: string, payload: { userId: string; displayName: string; quotaMb: number; status: string }) => request<Mailbox>(`/api/admin/mailboxes/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
deleteMailbox: (id: string) => request<{ ok: boolean }>(`/api/admin/mailboxes/${id}`, { method: "DELETE" }),
|
||||
aliases: () => request<ListResponse<Alias>>("/api/admin/aliases"),
|
||||
@@ -114,6 +200,8 @@ export const api = {
|
||||
const suffix = query.toString()
|
||||
return request<ListResponse<SendQueueAuditEvent>>(`/api/admin/send-audit${suffix ? `?${suffix}` : ""}`)
|
||||
},
|
||||
systemVersion: () => request<SystemVersion>("/api/admin/system/version"),
|
||||
updateSystem: () => request<SystemUpdateResult>("/api/admin/system/update", { method: "POST", timeoutMs: 45_000 }),
|
||||
systemSettings: () => request<SystemSettings>("/api/admin/settings"),
|
||||
maildirSyncHealth: () => request<MaildirSyncHealth>("/api/admin/maildir-sync/health"),
|
||||
updateSystemSettings: (payload: SystemSettingsPayload) => request<SystemSettings>("/api/admin/settings", { method: "POST", body: JSON.stringify(payload) }),
|
||||
@@ -148,21 +236,38 @@ export const api = {
|
||||
return request<MailLabel>(`/api/mail/labels${query}`, { method: "POST", body: JSON.stringify({ name: payload.name, color: payload.color || "" }) })
|
||||
},
|
||||
deleteLabel: (id: string, mailboxId?: string) => request<{ labels: MailLabel[] }>(`/api/mail/labels/${id}${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`, { method: "DELETE" }),
|
||||
messages: (folder: string, q = "", cursor = "", mailboxId?: string) => {
|
||||
const params = new URLSearchParams({ folder, q, cursor })
|
||||
messages: (folder: string, search: MailSearchParams | string = "", cursor = "", mailboxId?: string) => {
|
||||
const params = new URLSearchParams({ folder, cursor })
|
||||
appendMailSearchParams(params, search)
|
||||
if (mailboxId) params.set("mailboxId", mailboxId)
|
||||
return request<ListResponse<MailMessage>>(`/api/mail/messages?${params.toString()}`)
|
||||
},
|
||||
labelMessages: (labelId: string, q = "", cursor = "", mailboxId?: string) => {
|
||||
const params = new URLSearchParams({ labelId, q, cursor })
|
||||
labelMessages: (labelId: string, search: MailSearchParams | string = "", cursor = "", mailboxId?: string) => {
|
||||
const params = new URLSearchParams({ labelId, cursor })
|
||||
appendMailSearchParams(params, search)
|
||||
if (mailboxId) params.set("mailboxId", mailboxId)
|
||||
return request<ListResponse<MailMessage>>(`/api/mail/messages?${params.toString()}`)
|
||||
},
|
||||
starredMessages: (q = "", cursor = "", mailboxId?: string) => {
|
||||
const params = new URLSearchParams({ q, cursor })
|
||||
starredMessages: (search: MailSearchParams | string = "", cursor = "", mailboxId?: string) => {
|
||||
const params = new URLSearchParams({ cursor })
|
||||
appendMailSearchParams(params, search)
|
||||
if (mailboxId) params.set("mailboxId", mailboxId)
|
||||
return request<ListResponse<MailMessage>>(`/api/mail/starred?${params.toString()}`)
|
||||
},
|
||||
exportMail: (params: { view: "folder" | "starred" | "label" | "unknown"; mailboxId?: string; folder?: string; labelId?: string }) => {
|
||||
const query = new URLSearchParams({ view: params.view })
|
||||
if (params.mailboxId) query.set("mailboxId", params.mailboxId)
|
||||
if (params.folder) query.set("folder", params.folder)
|
||||
if (params.labelId) query.set("labelId", params.labelId)
|
||||
return requestFile(`/api/mail/export?${query.toString()}`)
|
||||
},
|
||||
importMail: (files: File[], payload: { mailboxId: string; folder: string }) => {
|
||||
const form = new FormData()
|
||||
form.set("mailboxId", payload.mailboxId)
|
||||
form.set("folder", payload.folder)
|
||||
files.forEach((file) => form.append("files", file))
|
||||
return uploadForm<{ ok: boolean; imported: number; skipped: number; errors: string[] }>("/api/mail/import", form)
|
||||
},
|
||||
message: (id: string, options: { markRead?: boolean } = {}) => request<MailMessage>(`/api/mail/messages/${id}${options.markRead === false ? "?markRead=0" : ""}`),
|
||||
translateMessage: (id: string, targetLanguage: string) => request<MailTranslation>(`/api/mail/messages/${id}/translate`, { method: "POST", body: JSON.stringify({ targetLanguage }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||
translateExternalMessage: (id: string, remoteId: string, targetLanguage: string) => request<MailTranslation>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}/translate`, { method: "POST", body: JSON.stringify({ targetLanguage }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||
@@ -194,6 +299,3 @@ export const api = {
|
||||
move: (id: string, folder: string) => request<{ ok: boolean }>(`/api/mail/messages/${id}/move`, { method: "POST", body: JSON.stringify({ folder }) }),
|
||||
delete: (id: string) => request<{ ok: boolean }>(`/api/mail/messages/${id}`, { method: "DELETE" }),
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from "react"
|
||||
import DOMPurify from "dompurify"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { ArrowRight, BookOpen, CheckCircle2, Circle, ClipboardList, Copy, ExternalLink, GitBranch, Github, Globe2, Mailbox, MoreHorizontal, Plus, RefreshCcw, Scale, Search, ShieldCheck, Star, Trash2, Users } from "lucide-react"
|
||||
import { ArrowRight, BookOpen, CheckCircle2, ChevronDown, Circle, ClipboardList, Copy, ExternalLink, Github, Globe2, Mail, Mailbox, MoreHorizontal, Plus, RefreshCcw, Scale, Search, ShieldCheck, Star, Trash2, Users } from "lucide-react"
|
||||
import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, MaildirSyncHealth, PermissionGroup, PermissionInfo, PermissionLimits, SystemSettings } from "@/lib/api"
|
||||
import { cn, decodeMimeHeader, formatBytes, formatDate } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -20,6 +20,7 @@ import { Switch } from "@/components/ui/switch"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog"
|
||||
import { SystemVersionDialog } from "@/components/system-version-dialog"
|
||||
import { useMe } from "@/hooks/use-me"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { hasAnyPermission, hasPermission } from "@/lib/permissions"
|
||||
@@ -28,17 +29,18 @@ import type { PermissionKey } from "@/lib/api-types"
|
||||
type Section = "overview" | "users" | "permissionGroups" | "domains" | "mailboxes" | "aliases" | "messages" | "sendAudit" | "settings"
|
||||
type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void }
|
||||
|
||||
const sectionLabels: Record<Section, string> = {
|
||||
overview: "概览",
|
||||
users: "用户",
|
||||
permissionGroups: "权限组",
|
||||
domains: "域名",
|
||||
mailboxes: "邮箱账号",
|
||||
aliases: "别名转发",
|
||||
messages: "全部邮件",
|
||||
sendAudit: "发送审计",
|
||||
settings: "系统设置",
|
||||
const sectionMeta: Record<Section, { label: string; frontLabel: string; description: string }> = {
|
||||
overview: { label: "数据总览", frontLabel: "数据统计", description: "系统运行、DNS、邮箱和消息状态集中查看。" },
|
||||
users: { label: "账号管理", frontLabel: "账号设置", description: "管理登录账号、身份状态、邮箱数量上限和绑定邮箱。" },
|
||||
permissionGroups: { label: "权限配额", frontLabel: "账号配额", description: "配置前台菜单权限、发信频率、附件和邮箱创建额度。" },
|
||||
domains: { label: "域名管理", frontLabel: "邮箱地址", description: "维护邮件域名、DKIM 和 DNS 检测。" },
|
||||
mailboxes: { label: "邮箱管理", frontLabel: "邮箱管理", description: "创建、分配、停用邮箱,保持与前台邮箱列表一致。" },
|
||||
aliases: { label: "邮件转发", frontLabel: "邮件转发", description: "管理域名转发规则。" },
|
||||
messages: { label: "全部邮件", frontLabel: "全部邮箱", description: "按邮箱、文件夹和关键词查看全站邮件。" },
|
||||
sendAudit: { label: "发送队列", frontLabel: "发送队列", description: "查看发信投递、重试和失败记录。" },
|
||||
settings: { label: "系统设置", frontLabel: "账号设置", description: "管理站点、发信、存储、注册、安全和邮件模板。" },
|
||||
}
|
||||
const sectionLabels = Object.fromEntries(Object.entries(sectionMeta).map(([key, value]) => [key, value.label])) as Record<Section, string>
|
||||
const sectionKeys = Object.keys(sectionLabels) as Section[]
|
||||
const sectionPermissions: Record<Section, PermissionKey[]> = {
|
||||
overview: ["admin.overview.view"],
|
||||
@@ -51,13 +53,15 @@ const sectionPermissions: Record<Section, PermissionKey[]> = {
|
||||
sendAudit: ["admin.messages.view"],
|
||||
settings: ["admin.settings.view", "admin.templates.view"],
|
||||
}
|
||||
const projectRepositoryUrl = "https://github.com/LanQin996/LanQin-Email"
|
||||
const projectRepositoryUrl = "https://github.com/zxyszx/NewSzxcn-Email"
|
||||
const projectTelegramUrl = "https://t.me/+EhII7MSyi3QwNDQ5"
|
||||
const projectTag = import.meta.env.VITE_APP_VERSION || ""
|
||||
const projectReleaseUrl = import.meta.env.VITE_RELEASE_URL || (projectTag ? `${projectRepositoryUrl}/releases/tag/${projectTag}` : "")
|
||||
const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 }
|
||||
const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, maxMailboxCount: 9, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 }
|
||||
const defaultMailboxLimitOverride = 9
|
||||
const accountLoginName = (user: Pick<AdminUser, "email" | "loginName">) => user.loginName || user.email
|
||||
|
||||
export function AdminPage() {
|
||||
const qc = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
const me = useMe()
|
||||
const user = me.data?.user
|
||||
const canOverview = hasPermission(user, "admin.overview.view")
|
||||
@@ -78,6 +82,7 @@ export function AdminPage() {
|
||||
const aliases = useQuery({ queryKey: ["admin", "aliases"], queryFn: api.aliases, enabled: !!user && canAliasesView })
|
||||
const settings = useQuery({ queryKey: ["admin", "settings"], queryFn: api.systemSettings, enabled: !!user && canSettingsView })
|
||||
const [params, setParams] = useSearchParams()
|
||||
const [refreshing, setRefreshing] = React.useState(false)
|
||||
|
||||
const domainItems = domains.data?.items || []
|
||||
const mailboxItems = mailboxes.data?.items || []
|
||||
@@ -88,19 +93,34 @@ export function AdminPage() {
|
||||
const rawSection = params.get("section") as Section | null
|
||||
const section: Section = rawSection && visibleSections.includes(rawSection) ? rawSection : visibleSections[0] || "overview"
|
||||
|
||||
async function refreshAdminPage() {
|
||||
if (refreshing) return
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await Promise.all([
|
||||
qc.invalidateQueries({ queryKey: ["admin"] }),
|
||||
qc.invalidateQueries({ queryKey: ["mailboxes"] }),
|
||||
qc.invalidateQueries({ queryKey: ["me"] }),
|
||||
])
|
||||
toast({ title: "后台数据已刷新" })
|
||||
} catch (error) {
|
||||
toast({ title: "刷新失败", description: error instanceof Error ? error.message : "请稍后重试" })
|
||||
} finally {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-[calc(100svh-3rem)] md:h-svh">
|
||||
<main className="p-4 sm:p-6">
|
||||
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{sectionLabels[section]}</h1>
|
||||
</div>
|
||||
<main className="mx-auto w-full max-w-[1180px] px-3 pb-10 pt-3 sm:px-4 sm:pt-4">
|
||||
<AdminPageHeader section={section} refreshing={refreshing} onRefresh={refreshAdminPage} />
|
||||
|
||||
{section === "overview" && canOverview && (
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-4">
|
||||
<Stat icon={<Users />} label="用户" value={overview.data?.users || 0} />
|
||||
<Stat icon={<Globe2 />} label="域名" value={overview.data?.domains || 0} />
|
||||
<Stat icon={<Mailbox />} label="邮箱账号" value={overview.data?.mailboxes || 0} />
|
||||
<Stat icon={<ShieldCheck />} label="存储" value={formatBytes(overview.data?.storageBytes || 0)} />
|
||||
<div className="mb-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Stat icon={<Users />} label="账号" value={overview.data?.users || 0} />
|
||||
<Stat icon={<Globe2 />} label="邮件域名" value={overview.data?.domains || 0} />
|
||||
<Stat icon={<Mailbox />} label="邮箱" value={overview.data?.mailboxes || 0} />
|
||||
<Stat icon={<ShieldCheck />} label="存储用量" value={formatBytes(overview.data?.storageBytes || 0)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -110,13 +130,39 @@ export function AdminPage() {
|
||||
{section === "domains" && <DomainsSection domains={domainItems} />}
|
||||
{section === "mailboxes" && <MailboxesSection mailboxes={mailboxItems} users={userItems} domains={domainItems} />}
|
||||
{section === "aliases" && <AliasesSection aliases={aliasItems} domains={domainItems} />}
|
||||
{section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} />}
|
||||
{section === "messages" && <AdminMessagesSection mailboxes={mailboxItems} systemAdmin={user?.role === "admin"} />}
|
||||
{section === "sendAudit" && <AdminSendAuditSection mailboxes={mailboxItems} />}
|
||||
{section === "settings" && <SystemSettingsSection settings={settings.data} domains={domainItems} />}
|
||||
</main>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
function AdminPageHeader({ section, refreshing, onRefresh }: { section: Section; refreshing: boolean; onRefresh: () => void }) {
|
||||
const meta = sectionMeta[section]
|
||||
return (
|
||||
<div className="mb-4 border-b pb-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>后台管理</span>
|
||||
<span className="h-1 w-1 rounded-full bg-muted-foreground/50" />
|
||||
<span>前台:{meta.frontLabel}</span>
|
||||
</div>
|
||||
<h1 className="text-[20px] font-semibold leading-7 tracking-tight">{meta.label}</h1>
|
||||
<p className="mt-1 text-sm leading-5 text-muted-foreground">{meta.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="icon" className="h-8 w-8 shadow-none" onClick={onRefresh} disabled={refreshing} aria-label="刷新后台数据" title="刷新后台数据">
|
||||
<RefreshCcw className={cn("h-4 w-4", refreshing && "animate-spin")} />
|
||||
</Button>
|
||||
<Badge variant="outline" className="h-7 rounded-md px-2.5 font-normal">NewSzxcn</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OverviewSection({ overview, domains, settings, visibleSections, onSectionChange }: { overview?: { activeUsers: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number }; domains: Domain[]; settings?: SystemSettings; visibleSections: Section[]; onSectionChange: (section: Section) => void }) {
|
||||
const checklist = setupChecklist(overview, domains, settings).filter((item) => visibleSections.includes(item.section))
|
||||
return (
|
||||
@@ -125,9 +171,9 @@ function OverviewSection({ overview, domains, settings, visibleSections, onSecti
|
||||
<Card>
|
||||
<CardHeader><CardTitle>系统状态</CardTitle></CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<InfoBox label="活跃用户" value={overview?.activeUsers || 0} />
|
||||
<InfoBox label="活跃账号" value={overview?.activeUsers || 0} />
|
||||
<InfoBox label="活跃邮箱" value={overview?.activeMailboxes || 0} />
|
||||
<InfoBox label="别名转发" value={overview?.aliases || 0} />
|
||||
<InfoBox label="邮件转发" value={overview?.aliases || 0} />
|
||||
<InfoBox label="未读邮件" value={overview?.unreadMessages || 0} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -161,7 +207,7 @@ function OverviewSection({ overview, domains, settings, visibleSections, onSecti
|
||||
<InfoLine label="公网地址" value={settings?.publicBaseUrl || "-"} />
|
||||
<InfoLine label="SMTP" value={settings?.smtpHost ? `${settings.smtpHost}:${settings.smtpPort}` : "-"} />
|
||||
<InfoLine label="注册" value={settings?.openRegistration ? "已开放" : "关闭"} />
|
||||
<InfoLine label="用户自助申请" value={settings?.userMailboxApplyEnabled ? "已启用" : "关闭"} />
|
||||
<InfoLine label="自助申请邮箱" value={settings?.userMailboxApplyEnabled ? "已启用" : "关闭"} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -177,7 +223,7 @@ function setupChecklist(overview: { activeUsers: number; activeMailboxes: number
|
||||
return [
|
||||
{ key: "domain", title: "添加邮件域名", detail: hasDomain ? `${domains.length} 个域名已添加` : "先添加 example.com 这样的邮件域名", done: hasDomain, section: "domains" as Section },
|
||||
{ key: "dns", title: "完成 DNS 检测", detail: dnsReady ? "至少一个域名 DNS 正常" : "配置 MX、SPF、DKIM、DMARC 后执行检测", done: dnsReady, section: "domains" as Section },
|
||||
{ key: "mailbox", title: "创建邮箱账号", detail: hasMailbox ? `${overview?.activeMailboxes || 0} 个活跃邮箱` : "给超级管理员或普通用户创建第一个邮箱", done: hasMailbox, section: "mailboxes" as Section },
|
||||
{ key: "mailbox", title: "创建邮箱", detail: hasMailbox ? `${overview?.activeMailboxes || 0} 个活跃邮箱` : "给管理员或普通账号创建第一个邮箱", done: hasMailbox, section: "mailboxes" as Section },
|
||||
{ key: "smtp", title: "确认发信链路", detail: settings?.smtpHost ? `内置 Postfix:${settings.smtpHost}:${settings.smtpPort}` : "默认使用内置 Postfix", done: true, section: "settings" as Section },
|
||||
{ key: "mail", title: "完成收发测试", detail: hasMail ? `${overview?.messages || 0} 封邮件已入库` : "发送或接收一封测试邮件", done: hasMail, section: "messages" as Section },
|
||||
]
|
||||
@@ -200,17 +246,18 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
|
||||
const canDelete = hasPermission(user, "admin.users.delete")
|
||||
const filteredUsers = users.filter((user) => {
|
||||
const keyword = query.trim().toLowerCase()
|
||||
const matchesKeyword = !keyword || [user.email, user.displayName, ...(user.mailboxes || [])].some((value) => value.toLowerCase().includes(keyword))
|
||||
const loginName = accountLoginName(user)
|
||||
const matchesKeyword = !keyword || [loginName, user.email, user.displayName, ...(user.mailboxes || [])].some((value) => value.toLowerCase().includes(keyword))
|
||||
const matchesRole = roleFilter === "all" || user.role === roleFilter
|
||||
const matchesStatus = statusFilter === "all" || (statusFilter === "active" ? !user.disabled : user.disabled)
|
||||
return matchesKeyword && matchesRole && matchesStatus
|
||||
})
|
||||
const remove = useMutation({ mutationFn: api.deleteUser, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "用户已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
|
||||
const remove = useMutation({ mutationFn: api.deleteUser, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "账号已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<CardTitle>用户管理</CardTitle>
|
||||
<CardTitle>账号管理</CardTitle>
|
||||
{canCreate && <CreateUserDialog permissionGroups={permissionGroups} />}
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -218,13 +265,13 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
|
||||
<div className="flex flex-col gap-3 lg:flex-row">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索用户、邮箱、显示名称" className="pl-9" />
|
||||
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索账号、邮箱、显示名称" className="pl-9" />
|
||||
</div>
|
||||
<Select value={roleFilter} onValueChange={setRoleFilter}>
|
||||
<SelectTrigger className="lg:w-36"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部角色</SelectItem>
|
||||
<SelectItem value="admin">超级管理员</SelectItem>
|
||||
<SelectItem value="admin">管理员</SelectItem>
|
||||
<SelectItem value="user">普通用户</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -243,9 +290,9 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{user.displayName}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{user.email}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{accountLoginName(user)}</div>
|
||||
</div>
|
||||
<UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除用户?", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除用户", onConfirm: () => remove.mutate(user.id) }) : undefined} />
|
||||
<UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除账号?", description: `将删除 ${accountLoginName(user)} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} />
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<RoleBadge user={user} />
|
||||
@@ -259,26 +306,26 @@ function UsersSection({ users, permissionGroups }: { users: AdminUser[]; permiss
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>用户</TableHead><TableHead>身份</TableHead><TableHead>权限组</TableHead><TableHead>邮箱</TableHead><TableHead>状态</TableHead><TableHead>创建时间</TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader>
|
||||
<TableHeader><TableRow><TableHead>账号</TableHead><TableHead>身份</TableHead><TableHead>权限配额</TableHead><TableHead className="w-[22rem]">邮箱</TableHead><TableHead>状态</TableHead><TableHead>创建</TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{filteredUsers.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{user.displayName}</div>
|
||||
<div className="text-xs text-muted-foreground">{user.email}</div>
|
||||
<div className="text-xs text-muted-foreground">{accountLoginName(user)}</div>
|
||||
</TableCell>
|
||||
<TableCell><RoleBadge user={user} /></TableCell>
|
||||
<TableCell><UserPermissionGroupsCell user={user} /></TableCell>
|
||||
<TableCell><UserMailboxCell user={user} /></TableCell>
|
||||
<TableCell className="w-[22rem] max-w-[22rem]"><UserMailboxCell user={user} /></TableCell>
|
||||
<TableCell><Badge variant={user.disabled ? "secondary" : "default"}>{user.disabled ? "停用" : "正常"}</Badge></TableCell>
|
||||
<TableCell className="text-muted-foreground">{new Date(user.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell><UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除用户?", description: `将删除 ${user.email} 及其关联数据。`, confirmText: "删除用户", onConfirm: () => remove.mutate(user.id) }) : undefined} /></TableCell>
|
||||
<TableCell><UserActions user={user} permissionGroups={permissionGroups} onDelete={canDelete ? () => setPendingConfirm({ title: "删除账号?", description: `将删除 ${accountLoginName(user)} 及其关联数据。`, confirmText: "删除账号", onConfirm: () => remove.mutate(user.id) }) : undefined} /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{filteredUsers.length === 0 && <Empty text="没有匹配的用户" />}
|
||||
{filteredUsers.length === 0 && <Empty text="没有匹配的账号" />}
|
||||
</CardContent>
|
||||
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
|
||||
</Card>
|
||||
@@ -301,7 +348,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
|
||||
onSuccess: () => {
|
||||
setPendingConfirm(null)
|
||||
invalidateAdmin(qc)
|
||||
toast({ title: "权限组已删除" })
|
||||
toast({ title: "权限配额已删除" })
|
||||
},
|
||||
onError: (e) => toast({ title: "删除失败", description: e.message }),
|
||||
})
|
||||
@@ -316,14 +363,14 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<CardTitle>权限组管理</CardTitle>
|
||||
<CardTitle>权限配额</CardTitle>
|
||||
{canCreate && <PermissionGroupDialog catalog={catalog} />}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索权限组、说明或权限键" className="pl-9" />
|
||||
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索权限配额、说明或权限键" className="pl-9" />
|
||||
</div>
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{filtered.map((group) => (
|
||||
@@ -341,14 +388,14 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
|
||||
{(canUpdate || canDelete) && <DropdownMenu>
|
||||
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem disabled={!isEditable(group) || !canUpdate} onSelect={() => setEditing(group)}>编辑权限组</DropdownMenuItem>
|
||||
<DropdownMenuItem disabled={!isEditable(group) || !canUpdate} onSelect={() => setEditing(group)}>编辑权限配额</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
disabled={!isDeletable(group) || !canDelete}
|
||||
onSelect={() => setPendingConfirm({ title: "删除权限组?", description: `${group.name} 删除后不能再分配给用户。`, confirmText: "删除权限组", onConfirm: () => remove.mutate(group.id) })}
|
||||
onSelect={() => setPendingConfirm({ title: "删除权限配额?", description: `${group.name} 删除后不能再分配给账号。`, confirmText: "删除权限配额", onConfirm: () => remove.mutate(group.id) })}
|
||||
>
|
||||
删除权限组
|
||||
删除权限配额
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>}
|
||||
@@ -358,7 +405,7 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{filtered.length === 0 && <Empty text="暂无匹配的权限组" />}
|
||||
{filtered.length === 0 && <Empty text="暂无匹配的权限配额" />}
|
||||
</CardContent>
|
||||
{editing && <PermissionGroupDialog group={editing} catalog={catalog} open={!!editing} onOpenChange={(open) => { if (!open) setEditing(null) }} />}
|
||||
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
|
||||
@@ -395,20 +442,20 @@ function PermissionGroupDialog({ group, catalog, open, onOpenChange }: { group?:
|
||||
onSuccess: () => {
|
||||
invalidateAdmin(qc)
|
||||
setDialogOpen(false)
|
||||
toast({ title: group ? "权限组已更新" : "权限组已创建" })
|
||||
toast({ title: group ? "权限配额已更新" : "权限配额已创建" })
|
||||
},
|
||||
onError: (e) => toast({ title: group ? "更新失败" : "创建失败", description: e.message }),
|
||||
})
|
||||
const trigger = group ? null : (
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm"><Plus className="h-4 w-4" />权限组</Button>
|
||||
<Button size="sm"><Plus className="h-4 w-4" />权限配额</Button>
|
||||
</DialogTrigger>
|
||||
)
|
||||
return (
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
{trigger}
|
||||
<DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader><DialogTitle>{group ? "编辑权限组" : "创建权限组"}</DialogTitle></DialogHeader>
|
||||
<DialogHeader><DialogTitle>{group ? "编辑权限配额" : "创建权限配额"}</DialogTitle></DialogHeader>
|
||||
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); mutation.mutate(new FormData(event.currentTarget)) }}>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field name="name" label="名称" defaultValue={group?.name || ""} placeholder="例如:客服主管" />
|
||||
@@ -481,6 +528,10 @@ function PermissionLimitEditor({ value, onChange }: { value: PermissionLimits; o
|
||||
<span className="text-xs text-muted-foreground">填 0 表示不限制</span>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label>邮箱数量上限</Label>
|
||||
<Input type="number" min={0} value={value.maxMailboxCount} onChange={(event) => update("maxMailboxCount", event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>附件上限 MB</Label>
|
||||
<Input type="number" min={0} value={value.maxAttachmentMb} onChange={(event) => update("maxAttachmentMb", event.target.value)} />
|
||||
@@ -525,6 +576,7 @@ function PermissionLimitBadges({ limits }: { limits?: PermissionLimits }) {
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
<Badge variant="secondary" className="font-normal">附件 {limitText(value.maxAttachmentMb, "MB")}</Badge>
|
||||
<Badge variant="secondary" className="font-normal">邮箱 {limitText(value.maxMailboxCount, "个")}</Badge>
|
||||
<Badge variant="secondary" className="font-normal">SMTP 每日 {limitText(value.smtpDailyLimit, "封")}</Badge>
|
||||
<Badge variant="secondary" className="font-normal">SMTP 每分钟 {limitText(value.smtpMinuteLimit, "封")}</Badge>
|
||||
<Badge variant="secondary" className="font-normal">IMAP 每分钟 {limitText(value.imapMinuteLimit, "次")}</Badge>
|
||||
@@ -582,7 +634,7 @@ function DomainsSection({ domains }: { domains: Domain[] }) {
|
||||
<Badge variant={domain.dnsStatus === "ok" ? "default" : "secondary"}>{domain.dnsStatus === "ok" ? "DNS 正常" : domain.dnsStatus}</Badge>
|
||||
{canViewDNS && <DomainDNSDialog domain={domain} />}
|
||||
{canUpdate && <Button variant="outline" size="sm" onClick={() => update.mutate({ id: domain.id, status: domain.status === "active" ? "disabled" : "active" })}>{domain.status === "active" ? "停用" : "启用"}</Button>}
|
||||
{canDelete && <Button variant="outline" size="sm" onClick={() => setPendingConfirm({ title: "删除域名?", description: `将删除 ${domain.name},相关邮箱、别名和邮件也可能受影响。`, confirmText: "删除域名", onConfirm: () => remove.mutate(domain.id) })}><Trash2 className="h-4 w-4" />删除</Button>}
|
||||
{canDelete && <Button variant="outline" size="sm" onClick={() => setPendingConfirm({ title: "删除域名?", description: `将删除 ${domain.name},相关邮箱、转发和邮件也可能受影响。`, confirmText: "删除域名", onConfirm: () => remove.mutate(domain.id) })}><Trash2 className="h-4 w-4" />删除</Button>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -621,7 +673,7 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<CardTitle>邮箱账号管理</CardTitle>
|
||||
<CardTitle>邮箱管理</CardTitle>
|
||||
{canCreate && <CreateMailboxDialog domains={domains} users={users} />}
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -646,7 +698,7 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<Table>
|
||||
<TableHeader><TableRow><TableHead>地址</TableHead><TableHead>归属用户</TableHead><TableHead>名称</TableHead><TableHead>配额</TableHead><TableHead>状态</TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader>
|
||||
<TableHeader><TableRow><TableHead>地址</TableHead><TableHead>归属账号</TableHead><TableHead>名称</TableHead><TableHead>配额</TableHead><TableHead>状态</TableHead><TableHead className="w-16"></TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{mailboxes.map((mailbox) => (
|
||||
<TableRow key={mailbox.id}>
|
||||
@@ -661,7 +713,7 @@ function MailboxesSection({ mailboxes, users, domains }: { mailboxes: MailboxTyp
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{mailboxes.length === 0 && <Empty text="暂无邮箱账号" />}
|
||||
{mailboxes.length === 0 && <Empty text="暂无邮箱" />}
|
||||
</CardContent>
|
||||
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
|
||||
</Card>
|
||||
@@ -677,13 +729,13 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai
|
||||
const canCreate = hasPermission(user, "admin.aliases.create")
|
||||
const canUpdate = hasPermission(user, "admin.aliases.update")
|
||||
const canDelete = hasPermission(user, "admin.aliases.delete")
|
||||
const update = useMutation({ mutationFn: ({ id, payload }: { id: string; payload: { source: string; destination: string; enabled: boolean } }) => api.updateAlias(id, payload), onSuccess: () => { invalidateAdmin(qc); toast({ title: "别名已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }) })
|
||||
const remove = useMutation({ mutationFn: api.deleteAlias, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "别名已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
|
||||
const update = useMutation({ mutationFn: ({ id, payload }: { id: string; payload: { source: string; destination: string; enabled: boolean } }) => api.updateAlias(id, payload), onSuccess: () => { invalidateAdmin(qc); toast({ title: "转发已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }) })
|
||||
const remove = useMutation({ mutationFn: api.deleteAlias, onSuccess: () => { setPendingConfirm(null); invalidateAdmin(qc); toast({ title: "转发已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) })
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<CardTitle>别名/转发管理</CardTitle>
|
||||
<CardTitle>邮件转发</CardTitle>
|
||||
{canCreate && <CreateAliasDialog domains={domains} />}
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -696,7 +748,7 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai
|
||||
<div className="truncate font-medium">{alias.source}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{alias.destination}</div>
|
||||
</div>
|
||||
<AliasActions alias={alias} onToggle={canUpdate ? () => update.mutate({ id: alias.id, payload: { source: alias.source, destination: alias.destination, enabled: !alias.enabled } }) : undefined} onDelete={canDelete ? () => setPendingConfirm({ title: "删除别名?", description: `${alias.source} 将不再转发到 ${alias.destination}。`, confirmText: "删除别名", onConfirm: () => remove.mutate(alias.id) }) : undefined} />
|
||||
<AliasActions alias={alias} onToggle={canUpdate ? () => update.mutate({ id: alias.id, payload: { source: alias.source, destination: alias.destination, enabled: !alias.enabled } }) : undefined} onDelete={canDelete ? () => setPendingConfirm({ title: "删除转发?", description: `${alias.source} 将不再转发到 ${alias.destination}。`, confirmText: "删除转发", onConfirm: () => remove.mutate(alias.id) }) : undefined} />
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Badge variant={alias.enabled ? "default" : "secondary"}>{alias.enabled ? "启用" : "停用"}</Badge>
|
||||
@@ -715,21 +767,20 @@ function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domai
|
||||
<TableCell>{alias.destination}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{domains.find((d) => d.id === alias.domainId)?.name || alias.domainId}</TableCell>
|
||||
<TableCell><Badge variant={alias.enabled ? "default" : "secondary"}>{alias.enabled ? "启用" : "停用"}</Badge></TableCell>
|
||||
<TableCell><AliasActions alias={alias} onToggle={canUpdate ? () => update.mutate({ id: alias.id, payload: { source: alias.source, destination: alias.destination, enabled: !alias.enabled } }) : undefined} onDelete={canDelete ? () => setPendingConfirm({ title: "删除别名?", description: `${alias.source} 将不再转发到 ${alias.destination}。`, confirmText: "删除别名", onConfirm: () => remove.mutate(alias.id) }) : undefined} /></TableCell>
|
||||
<TableCell><AliasActions alias={alias} onToggle={canUpdate ? () => update.mutate({ id: alias.id, payload: { source: alias.source, destination: alias.destination, enabled: !alias.enabled } }) : undefined} onDelete={canDelete ? () => setPendingConfirm({ title: "删除转发?", description: `${alias.source} 将不再转发到 ${alias.destination}。`, confirmText: "删除转发", onConfirm: () => remove.mutate(alias.id) }) : undefined} /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{aliases.length === 0 && <Empty text="暂无别名转发" />}
|
||||
{aliases.length === 0 && <Empty text="暂无邮件转发" />}
|
||||
</CardContent>
|
||||
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={remove.isPending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
const qc = useQueryClient()
|
||||
function AdminMessagesSection({ mailboxes, systemAdmin }: { mailboxes: MailboxType[]; systemAdmin: boolean }) {
|
||||
const [query, setQuery] = React.useState("")
|
||||
const [mailboxId, setMailboxId] = React.useState("all")
|
||||
const [folder, setFolder] = React.useState("all")
|
||||
@@ -752,8 +803,8 @@ function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<CardTitle>全部邮件</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={() => qc.invalidateQueries({ queryKey: ["admin", "messages"] })}>
|
||||
<RefreshCcw className="h-4 w-4" />刷新
|
||||
<Button variant="outline" size="sm" onClick={() => messages.refetch()} disabled={messages.isFetching}>
|
||||
<RefreshCcw className={cn("h-4 w-4", messages.isFetching && "animate-spin")} />{messages.isFetching ? "刷新中" : "刷新"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -767,7 +818,7 @@ function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
<SelectTrigger className="xl:w-72"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部邮箱</SelectItem>
|
||||
<SelectItem value="unregistered">未注册收件</SelectItem>
|
||||
{systemAdmin && <SelectItem value="unregistered">未知收件</SelectItem>}
|
||||
{mailboxes.map((mailbox) => <SelectItem key={mailbox.id} value={mailbox.id}>{mailbox.address}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -780,7 +831,7 @@ function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
<SelectItem value="Archive">归档</SelectItem>
|
||||
<SelectItem value="Spam">垃圾邮件</SelectItem>
|
||||
<SelectItem value="Trash">回收站</SelectItem>
|
||||
<SelectItem value="Unregistered">未注册收件</SelectItem>
|
||||
{systemAdmin && <SelectItem value="Unregistered">未知收件</SelectItem>}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -856,7 +907,6 @@ function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
}
|
||||
|
||||
function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
const qc = useQueryClient()
|
||||
const [mailboxId, setMailboxId] = React.useState("all")
|
||||
const [event, setEvent] = React.useState("all")
|
||||
const [messageId, setMessageId] = React.useState("")
|
||||
@@ -880,9 +930,9 @@ function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<CardTitle className="flex items-center gap-2"><ClipboardList className="h-5 w-5" />发送审计</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={() => qc.invalidateQueries({ queryKey: ["admin", "send-audit"] })}>
|
||||
<RefreshCcw className="h-4 w-4" />刷新
|
||||
<CardTitle className="flex items-center gap-2"><ClipboardList className="h-5 w-5" />发送队列</CardTitle>
|
||||
<Button variant="outline" size="sm" onClick={() => audit.refetch()} disabled={audit.isFetching}>
|
||||
<RefreshCcw className={cn("h-4 w-4", audit.isFetching && "animate-spin")} />{audit.isFetching ? "刷新中" : "刷新"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -955,7 +1005,7 @@ function AdminSendAuditSection({ mailboxes }: { mailboxes: MailboxType[] }) {
|
||||
</Table>
|
||||
</div>
|
||||
{audit.isLoading && <Empty text="加载中..." />}
|
||||
{!audit.isLoading && items.length === 0 && <Empty text="暂无发送审计" />}
|
||||
{!audit.isLoading && items.length === 0 && <Empty text="暂无发送记录" />}
|
||||
{!audit.isLoading && audit.hasNextPage && (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" size="sm" disabled={audit.isFetchingNextPage} onClick={() => audit.fetchNextPage()}>
|
||||
@@ -1157,7 +1207,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
<CardContent className="space-y-5">
|
||||
<SwitchRow label="无人收件" checked={catchAllEnabled} onCheckedChange={setCatchAllEnabled} />
|
||||
<Separator />
|
||||
<SwitchRow label="用户自助申请邮箱" checked={userMailboxApplyEnabled} onCheckedChange={setUserMailboxApplyEnabled} />
|
||||
<SwitchRow label="账号自助申请邮箱" checked={userMailboxApplyEnabled} onCheckedChange={setUserMailboxApplyEnabled} />
|
||||
{userMailboxApplyEnabled && (
|
||||
<div className="space-y-5 border-t pt-5">
|
||||
<div className="space-y-3">
|
||||
@@ -1202,7 +1252,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="rounded-lg border bg-muted/30 p-4 text-sm text-muted-foreground">
|
||||
默认关闭。关闭后用户端会隐藏外部 IMAP 接入,相关后端接口也会返回禁用。
|
||||
默认关闭。关闭后前台会隐藏外部 IMAP 接入,相关后端接口也会返回禁用。
|
||||
</div>
|
||||
<SwitchRow label="启用外部 IMAP" checked={externalImapEnabled} onCheckedChange={setExternalImapEnabled} />
|
||||
{externalImapEnabled && (
|
||||
@@ -1363,47 +1413,7 @@ function queryErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : "读取 Maildir 同步健康失败"
|
||||
}
|
||||
|
||||
function parseSemver(tag: string): number[] {
|
||||
return (tag.startsWith("v") ? tag.slice(1) : tag).split(".").map(Number)
|
||||
}
|
||||
|
||||
function AboutProjectCard() {
|
||||
const { toast } = useToast()
|
||||
const latestRelease = useQuery({
|
||||
queryKey: ["github", "latest-release"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("https://api.github.com/repos/LanQin996/LanQin-Email/releases/latest")
|
||||
if (!res.ok) throw new Error("rate limited or unavailable")
|
||||
return res.json() as Promise<{ tag_name: string; html_url: string }>
|
||||
},
|
||||
enabled: !!projectTag,
|
||||
staleTime: 1000 * 60 * 60, // 1 hour
|
||||
retry: 1,
|
||||
})
|
||||
const updateAvailable = React.useMemo(() => {
|
||||
if (!projectTag || !latestRelease.data) return false
|
||||
const current = parseSemver(projectTag)
|
||||
const latest = parseSemver(latestRelease.data.tag_name)
|
||||
for (let i = 0; i < Math.max(current.length, latest.length); i++) {
|
||||
const a = current[i] ?? 0
|
||||
const b = latest[i] ?? 0
|
||||
if (b > a) return true
|
||||
if (a > b) return false
|
||||
}
|
||||
return false
|
||||
}, [projectTag, latestRelease.data])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (updateAvailable && latestRelease.data) {
|
||||
toast({
|
||||
title: "发现新版本",
|
||||
description: `${latestRelease.data.tag_name} 已可用,点击版本号查看详情。`,
|
||||
})
|
||||
}
|
||||
// Only toast once on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [updateAvailable])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -1411,32 +1421,7 @@ function AboutProjectCard() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
<AboutRow label="版本">
|
||||
{projectTag ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
|
||||
<a href={projectReleaseUrl} target="_blank" rel="noreferrer">
|
||||
<GitBranch className="h-5 w-5 text-primary" />
|
||||
{projectTag}
|
||||
</a>
|
||||
</Button>
|
||||
{updateAvailable && latestRelease.data && (
|
||||
<Button type="button" variant="default" className="h-11 px-4 text-base font-normal" asChild>
|
||||
<a href={latestRelease.data.html_url} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-5 w-5" />
|
||||
新版本 {latestRelease.data.tag_name}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{latestRelease.isLoading && (
|
||||
<span className="text-xs text-muted-foreground">检查更新中...</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" disabled>
|
||||
<GitBranch className="h-5 w-5 text-muted-foreground" />
|
||||
未发布版本
|
||||
</Button>
|
||||
)}
|
||||
<SystemVersionDialog mode="inline" />
|
||||
</AboutRow>
|
||||
<AboutRow label="交流">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
@@ -1601,7 +1586,7 @@ function AdminMessageDialog({ message, loading, open, onOpenChange }: { message?
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-3 rounded-lg border p-4 text-sm md:grid-cols-2">
|
||||
<MessageMeta label="所属邮箱" value={message.mailboxAddress || message.recipientAddress || ""} />
|
||||
<MessageMeta label="所属用户" value={message.ownerEmail || ""} />
|
||||
<MessageMeta label="所属账号" value={message.ownerEmail || ""} />
|
||||
<MessageMeta label="发件人" value={adminSenderTitle(message)} />
|
||||
<MessageMeta label="收件人" value={message.recipientAddress || message.to?.join(", ") || ""} />
|
||||
<MessageMeta label="文件夹" value={folderName(message.folder)} />
|
||||
@@ -1687,12 +1672,80 @@ function DomainBadgeRow({ domain }: { domain: Domain }) { return <div className=
|
||||
function invalidateAdmin(qc: ReturnType<typeof useQueryClient>) { qc.invalidateQueries({ queryKey: ["admin"] }); qc.invalidateQueries({ queryKey: ["mailboxes"] }); qc.invalidateQueries({ queryKey: ["me"] }) }
|
||||
|
||||
function UserMailboxCell({ user }: { user: AdminUser }) {
|
||||
const { toast } = useToast()
|
||||
const loginAddress = accountLoginName(user)
|
||||
const mailboxes = user.mailboxes || []
|
||||
if (mailboxes.length === 0) return <span className="text-muted-foreground">未绑定</span>
|
||||
const [mailboxQuery, setMailboxQuery] = React.useState("")
|
||||
const normalizedQuery = mailboxQuery.trim().toLowerCase()
|
||||
const sortedMailboxes = React.useMemo(() => {
|
||||
return Array.from(new Set(mailboxes)).sort((a, b) => a.localeCompare(b, "en", { sensitivity: "base" }))
|
||||
}, [mailboxes])
|
||||
const [selectedAddress, setSelectedAddress] = React.useState(loginAddress)
|
||||
React.useEffect(() => {
|
||||
if (selectedAddress === loginAddress || sortedMailboxes.includes(selectedAddress)) return
|
||||
setSelectedAddress(loginAddress)
|
||||
}, [loginAddress, selectedAddress, sortedMailboxes])
|
||||
const filteredMailboxes = React.useMemo(() => {
|
||||
if (!normalizedQuery) return sortedMailboxes
|
||||
return sortedMailboxes.filter((mailbox) => mailbox.toLowerCase().includes(normalizedQuery))
|
||||
}, [normalizedQuery, sortedMailboxes])
|
||||
const limit = user.role === "admin" ? "不限" : limitText(user.limits?.maxMailboxCount ?? defaultMailboxLimitOverride, "个")
|
||||
const quota = <div className="text-[11px] text-muted-foreground">邮箱 {user.mailboxCount}/{limit}</div>
|
||||
async function copyMailbox(address: string) {
|
||||
if (!address) return
|
||||
await navigator.clipboard.writeText(address)
|
||||
toast({ title: "邮箱地址已复制", description: address })
|
||||
}
|
||||
return (
|
||||
<div className="flex max-w-md flex-wrap gap-1">
|
||||
{mailboxes.slice(0, 2).map((mailbox) => <Badge key={mailbox} variant="outline" className="font-normal">{mailbox}</Badge>)}
|
||||
{mailboxes.length > 2 && <Badge variant="secondary">+{mailboxes.length - 2}</Badge>}
|
||||
<div className="w-full max-w-[21rem] space-y-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<DropdownMenu onOpenChange={(open) => { if (!open) setMailboxQuery("") }}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-8 min-w-0 flex-1 justify-start gap-1.5 overflow-hidden rounded-md border-input bg-background px-2 text-left font-normal shadow-none hover:bg-background"
|
||||
title={selectedAddress}
|
||||
>
|
||||
<Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{selectedAddress}</span>
|
||||
{sortedMailboxes.length > 0 && <span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] font-medium text-muted-foreground">{sortedMailboxes.length} 个</span>}
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-[21rem] max-w-[calc(100vw-32px)] p-1">
|
||||
<div className="px-1 pb-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
autoFocus
|
||||
value={mailboxQuery}
|
||||
onChange={(event) => setMailboxQuery(event.target.value)}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
placeholder="搜索邮箱..."
|
||||
className="h-8 rounded-md bg-background pl-8 pr-2 text-[13px] shadow-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{filteredMailboxes.map((mailbox) => (
|
||||
<DropdownMenuItem
|
||||
key={mailbox}
|
||||
onSelect={() => setSelectedAddress(mailbox)}
|
||||
className={cn("h-8 min-w-0 gap-2 rounded-sm px-2 text-[13px] font-normal", selectedAddress === mailbox && "bg-accent text-accent-foreground")}
|
||||
>
|
||||
<Mail className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate" title={mailbox}>{mailbox}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{sortedMailboxes.length === 0 && <DropdownMenuItem disabled className="h-8 px-2 text-[13px] font-normal">暂无创建邮箱</DropdownMenuItem>}
|
||||
{sortedMailboxes.length > 0 && filteredMailboxes.length === 0 && <DropdownMenuItem disabled className="h-8 px-2 text-[13px] font-normal">没有匹配邮箱</DropdownMenuItem>}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button type="button" variant="outline" size="icon" className="h-8 w-8 shrink-0 rounded-md bg-background shadow-none hover:bg-background" disabled={!selectedAddress} onClick={() => copyMailbox(selectedAddress)} aria-label="复制邮箱地址" title="复制邮箱地址">
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{quota}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1721,7 +1774,7 @@ function PermissionGroupPicker({ groups, value, onChange }: { groups: Permission
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>权限组</Label>
|
||||
<Label>权限配额</Label>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
{groups.map((group) => {
|
||||
const checked = value.includes(group.id)
|
||||
@@ -1736,7 +1789,7 @@ function PermissionGroupPicker({ groups, value, onChange }: { groups: Permission
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{groups.length === 0 && <Empty text="暂无可分配权限组" />}
|
||||
{groups.length === 0 && <Empty text="暂无可分配权限配额" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1744,7 +1797,7 @@ function PermissionGroupPicker({ groups, value, onChange }: { groups: Permission
|
||||
function RoleBadge({ user }: { user: AdminUser }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant={user.role === "admin" ? "default" : "secondary"}>{user.role === "admin" ? "超级管理员" : "普通用户"}</Badge>
|
||||
<Badge variant={user.role === "admin" ? "default" : "secondary"}>{user.role === "admin" ? "管理员" : "普通用户"}</Badge>
|
||||
{user.protected && <Badge variant="outline">默认账号</Badge>}
|
||||
</div>
|
||||
)
|
||||
@@ -1761,7 +1814,7 @@ function UserActions({ user, permissionGroups, onDelete }: { user: AdminUser; pe
|
||||
const canResetPassword = hasPermission(currentUser, "admin.users.reset_password")
|
||||
const update = useMutation({
|
||||
mutationFn: (payload: { displayName: string; role: "admin" | "user"; disabled: boolean; permissionGroupIds?: string[] }) => api.updateUser(user.id, payload),
|
||||
onSuccess: () => { invalidateAdmin(qc); toast({ title: "用户已更新" }) },
|
||||
onSuccess: () => { invalidateAdmin(qc); toast({ title: "账号已更新" }) },
|
||||
onError: (e) => toast({ title: "更新失败", description: e.message }),
|
||||
})
|
||||
function quickPatch(patch: Partial<{ role: "admin" | "user"; disabled: boolean }>) {
|
||||
@@ -1774,7 +1827,7 @@ function UserActions({ user, permissionGroups, onDelete }: { user: AdminUser; pe
|
||||
})
|
||||
}
|
||||
if (!canUpdate && !canResetPassword && !onDelete) return null
|
||||
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setEditOpen(true)}>编辑用户</DropdownMenuItem>}{canResetPassword && <DropdownMenuItem onSelect={() => setPasswordOpen(true)}>重置密码</DropdownMenuItem>}{!user.protected && canUpdate && <><DropdownMenuSeparator /><DropdownMenuItem onSelect={() => quickPatch({ disabled: !user.disabled })}>{user.disabled ? "启用用户" : "停用用户"}</DropdownMenuItem><DropdownMenuItem onSelect={() => quickPatch({ role: user.role === "admin" ? "user" : "admin" })}>{user.role === "admin" ? "设为普通用户" : "设为超级管理员"}</DropdownMenuItem></>}{!user.protected && onDelete && <><DropdownMenuSeparator /><DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除用户</DropdownMenuItem></>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditUserDialog user={user} permissionGroups={permissionGroups} open={editOpen} onOpenChange={setEditOpen} />}{canResetPassword && <ResetPasswordDialog user={user} open={passwordOpen} onOpenChange={setPasswordOpen} />}</>
|
||||
return <><DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{canUpdate && <DropdownMenuItem onSelect={() => setEditOpen(true)}>编辑账号</DropdownMenuItem>}{canResetPassword && <DropdownMenuItem onSelect={() => setPasswordOpen(true)}>重置密码</DropdownMenuItem>}{!user.protected && canUpdate && <><DropdownMenuSeparator /><DropdownMenuItem onSelect={() => quickPatch({ disabled: !user.disabled })}>{user.disabled ? "启用账号" : "停用账号"}</DropdownMenuItem><DropdownMenuItem onSelect={() => quickPatch({ role: user.role === "admin" ? "user" : "admin" })}>{user.role === "admin" ? "设为普通用户" : "设为管理员"}</DropdownMenuItem></>}{!user.protected && onDelete && <><DropdownMenuSeparator /><DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除账号</DropdownMenuItem></>}</DropdownMenuContent></DropdownMenu>{canUpdate && <EditUserDialog user={user} permissionGroups={permissionGroups} open={editOpen} onOpenChange={setEditOpen} />}{canResetPassword && <ResetPasswordDialog user={user} open={passwordOpen} onOpenChange={setPasswordOpen} />}</>
|
||||
}
|
||||
|
||||
function CreateUserDialog({ permissionGroups }: { permissionGroups: PermissionGroup[] }) {
|
||||
@@ -1785,23 +1838,32 @@ function CreateUserDialog({ permissionGroups }: { permissionGroups: PermissionGr
|
||||
const [status, setStatus] = React.useState("active")
|
||||
const [permissionGroupIds, setPermissionGroupIds] = React.useState<string[]>([])
|
||||
const create = useMutation({
|
||||
mutationFn: (form: FormData) => api.createUser({ email: String(form.get("email") || ""), displayName: String(form.get("displayName") || ""), password: String(form.get("password") || ""), role, disabled: status === "disabled", permissionGroupIds: role === "user" ? permissionGroupIds : [] }),
|
||||
onSuccess: () => { invalidateAdmin(qc); setOpen(false); setPermissionGroupIds([]); toast({ title: "用户已创建" }) },
|
||||
mutationFn: (form: FormData) => api.createUser({
|
||||
loginName: String(form.get("loginName") || ""),
|
||||
displayName: String(form.get("displayName") || ""),
|
||||
password: String(form.get("password") || ""),
|
||||
role,
|
||||
disabled: status === "disabled",
|
||||
mailboxLimitOverride: role === "user" ? mailboxLimitFromForm(form) : undefined,
|
||||
permissionGroupIds: role === "user" ? permissionGroupIds : [],
|
||||
}),
|
||||
onSuccess: () => { invalidateAdmin(qc); setOpen(false); setPermissionGroupIds([]); toast({ title: "账号已创建" }) },
|
||||
onError: (e) => toast({ title: "创建失败", description: e.message }),
|
||||
})
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild><Button size="sm"><Plus className="h-4 w-4" />用户</Button></DialogTrigger>
|
||||
<DialogTrigger asChild><Button size="sm"><Plus className="h-4 w-4" />账号</Button></DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>创建用户</DialogTitle></DialogHeader>
|
||||
<DialogHeader><DialogTitle>创建账号</DialogTitle></DialogHeader>
|
||||
<form className="space-y-4" onSubmit={(event) => { event.preventDefault(); create.mutate(new FormData(event.currentTarget)) }}>
|
||||
<Field name="email" label="登录邮箱" type="email" placeholder="user@example.com" />
|
||||
<Field name="displayName" label="显示名称" placeholder="用户名称" />
|
||||
<Field name="loginName" label="登录名" type="text" autoComplete="off" placeholder="admin" />
|
||||
<Field name="displayName" label="显示名称" placeholder="账号名称" />
|
||||
<Field name="password" label="初始密码" type="password" minLength={8} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "超级管理员"]]} />
|
||||
<SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "管理员"]]} />
|
||||
<SelectField label="状态" value={status} onValueChange={setStatus} items={[["active", "正常"], ["disabled", "停用"]]} />
|
||||
</div>
|
||||
{role === "user" && <MailboxLimitField defaultValue={defaultMailboxLimitOverride} />}
|
||||
{role === "user" && <PermissionGroupPicker groups={permissionGroups} value={permissionGroupIds} onChange={setPermissionGroupIds} />}
|
||||
<DialogFooter><Button disabled={create.isPending}>{create.isPending ? "创建中..." : "创建"}</Button></DialogFooter>
|
||||
</form>
|
||||
@@ -1818,26 +1880,62 @@ function MailboxActions({ mailbox, users, canUpdate, onDelete }: { mailbox: Mail
|
||||
|
||||
function AliasActions({ alias, onToggle, onDelete }: { alias: Alias; onToggle?: () => void; onDelete?: () => void }) {
|
||||
if (!onToggle && !onDelete) return null
|
||||
return <DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{onToggle && <DropdownMenuItem onSelect={onToggle}>{alias.enabled ? "停用" : "启用"}</DropdownMenuItem>}{onToggle && onDelete && <DropdownMenuSeparator />}{onDelete && <DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除别名</DropdownMenuItem>}</DropdownMenuContent></DropdownMenu>
|
||||
return <DropdownMenu><DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger><DropdownMenuContent align="end">{onToggle && <DropdownMenuItem onSelect={onToggle}>{alias.enabled ? "停用" : "启用"}</DropdownMenuItem>}{onToggle && onDelete && <DropdownMenuSeparator />}{onDelete && <DropdownMenuItem className="text-destructive" onSelect={onDelete}>删除转发</DropdownMenuItem>}</DropdownMenuContent></DropdownMenu>
|
||||
}
|
||||
|
||||
function EditUserDialog({ user, permissionGroups, open, onOpenChange }: { user: AdminUser; permissionGroups: PermissionGroup[]; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const qc = useQueryClient(); const { toast } = useToast(); const [role, setRole] = React.useState(user.role); const [disabled, setDisabled] = React.useState(user.disabled ? "disabled" : "active"); const [permissionGroupIds, setPermissionGroupIds] = React.useState<string[]>(assignableUserGroupIDs(user))
|
||||
React.useEffect(() => { setRole(user.role); setDisabled(user.disabled ? "disabled" : "active"); setPermissionGroupIds(assignableUserGroupIDs(user)) }, [user, open])
|
||||
const mut = useMutation({ mutationFn: (form: FormData) => api.updateUser(user.id, { displayName: String(form.get("displayName") || ""), role, disabled: disabled === "disabled", permissionGroupIds: role === "user" ? permissionGroupIds : [] }), onSuccess: () => { invalidateAdmin(qc); onOpenChange(false); toast({ title: "用户已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }) })
|
||||
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle>编辑用户</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><Field name="email" label="登录邮箱" value={user.email} readOnly /><Field name="displayName" label="显示名称" defaultValue={user.displayName} /><div className="grid grid-cols-2 gap-3"><SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[['user','普通用户'],['admin','超级管理员']]} disabled={user.protected} /><SelectField label="状态" value={disabled} onValueChange={setDisabled} items={[['active','正常'],['disabled','停用']]} disabled={user.protected} /></div>{role === "user" && !user.protected && <PermissionGroupPicker groups={permissionGroups} value={permissionGroupIds} onChange={setPermissionGroupIds} />}<DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "保存中..." : "保存"}</Button></DialogFooter></form></DialogContent></Dialog>
|
||||
const qc = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
const [role, setRole] = React.useState(user.role)
|
||||
const [disabled, setDisabled] = React.useState(user.disabled ? "disabled" : "active")
|
||||
const [permissionGroupIds, setPermissionGroupIds] = React.useState<string[]>(assignableUserGroupIDs(user))
|
||||
React.useEffect(() => {
|
||||
setRole(user.role)
|
||||
setDisabled(user.disabled ? "disabled" : "active")
|
||||
setPermissionGroupIds(assignableUserGroupIDs(user))
|
||||
}, [user, open])
|
||||
const mut = useMutation({
|
||||
mutationFn: (form: FormData) => api.updateUser(user.id, {
|
||||
loginName: String(form.get("loginName") || ""),
|
||||
displayName: String(form.get("displayName") || ""),
|
||||
role,
|
||||
disabled: disabled === "disabled",
|
||||
mailboxLimitOverride: role === "user" ? mailboxLimitFromForm(form, effectiveMailboxLimit(user)) : undefined,
|
||||
permissionGroupIds: role === "user" ? permissionGroupIds : [],
|
||||
}),
|
||||
onSuccess: () => { invalidateAdmin(qc); onOpenChange(false); toast({ title: "账号已更新" }) },
|
||||
onError: (e) => toast({ title: "更新失败", description: e.message }),
|
||||
})
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>编辑账号</DialogTitle></DialogHeader>
|
||||
<form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}>
|
||||
<Field name="loginName" label="登录名" defaultValue={accountLoginName(user)} type="text" autoComplete="off" />
|
||||
<Field name="displayName" label="显示名称" defaultValue={user.displayName} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<SelectField label="身份" value={role} onValueChange={(value) => setRole(value as "admin" | "user")} items={[["user", "普通用户"], ["admin", "管理员"]]} disabled={user.protected} />
|
||||
<SelectField label="状态" value={disabled} onValueChange={setDisabled} items={[["active", "正常"], ["disabled", "停用"]]} disabled={user.protected} />
|
||||
</div>
|
||||
{role === "user" && !user.protected && <MailboxLimitField defaultValue={effectiveMailboxLimit(user)} />}
|
||||
{role === "user" && !user.protected && <PermissionGroupPicker groups={permissionGroups} value={permissionGroupIds} onChange={setPermissionGroupIds} />}
|
||||
<DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "保存中..." : "保存"}</Button></DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function ResetPasswordDialog({ user, open, onOpenChange }: { user: AdminUser; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const { toast } = useToast(); const mut = useMutation({ mutationFn: (form: FormData) => api.resetUserPassword(user.id, String(form.get("password") || "")), onSuccess: () => { onOpenChange(false); toast({ title: "密码已重置" }) }, onError: (e) => toast({ title: "重置失败", description: e.message }) })
|
||||
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle>重置密码</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field name="email" label="用户" value={user.email} readOnly /><Field name="password" label="新密码" type="password" minLength={8} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "重置中..." : "重置"}</Button></DialogFooter></form></DialogContent></Dialog>
|
||||
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle>重置密码</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)); e.currentTarget.reset() }}><Field name="loginName" label="登录名" value={accountLoginName(user)} readOnly /><Field name="password" label="新密码" type="password" minLength={8} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "重置中..." : "重置"}</Button></DialogFooter></form></DialogContent></Dialog>
|
||||
}
|
||||
|
||||
function EditMailboxDialog({ mailbox, users, open, onOpenChange }: { mailbox: MailboxType; users: AdminUser[]; open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const qc = useQueryClient(); const { toast } = useToast(); const [userId, setUserId] = React.useState(mailbox.userId); const [status, setStatus] = React.useState(mailbox.status)
|
||||
React.useEffect(() => { setUserId(mailbox.userId); setStatus(mailbox.status) }, [mailbox, open])
|
||||
const mut = useMutation({ mutationFn: (form: FormData) => api.updateMailbox(mailbox.id, { userId, displayName: String(form.get("displayName") || ""), quotaMb: Number(form.get("quotaMb") || 1024), status }), onSuccess: () => { invalidateAdmin(qc); onOpenChange(false); toast({ title: "邮箱已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }) })
|
||||
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle>编辑邮箱</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><Field name="address" label="邮箱地址" value={mailbox.address} readOnly /><SelectField label="归属用户" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, u.email])} /><div className="grid grid-cols-2 gap-3"><Field name="displayName" label="显示名称" defaultValue={mailbox.displayName} /><Field name="quotaMb" label="配额 MB" type="number" defaultValue={String(mailbox.quotaMb)} /></div><SelectField label="状态" value={status} onValueChange={setStatus} items={[['active','启用'],['disabled','停用']]} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "保存中..." : "保存"}</Button></DialogFooter></form></DialogContent></Dialog>
|
||||
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle>编辑邮箱</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><Field name="address" label="邮箱地址" value={mailbox.address} readOnly /><SelectField label="归属账号" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, u.email])} /><div className="grid grid-cols-2 gap-3"><Field name="displayName" label="显示名称" defaultValue={mailbox.displayName} /><Field name="quotaMb" label="配额 MB" type="number" defaultValue={String(mailbox.quotaMb)} /></div><SelectField label="状态" value={status} onValueChange={setStatus} items={[['active','启用'],['disabled','停用']]} /><DialogFooter><Button disabled={mut.isPending}>{mut.isPending ? "保存中..." : "保存"}</Button></DialogFooter></form></DialogContent></Dialog>
|
||||
}
|
||||
|
||||
function CreateDomainDialog() {
|
||||
@@ -1849,15 +1947,15 @@ function CreateDomainDialog() {
|
||||
function CreateMailboxDialog({ domains, users }: { domains: Domain[]; users: AdminUser[] }) {
|
||||
const qc = useQueryClient(); const { toast } = useToast(); const [open, setOpen] = React.useState(false); const [domainId, setDomainId] = React.useState(""); const [role, setRole] = React.useState("user"); const [ownerMode, setOwnerMode] = React.useState("new"); const [userId, setUserId] = React.useState("")
|
||||
React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id); if (!userId && users[0]) setUserId(users[0].id) }, [domains, domainId, users, userId])
|
||||
const mut = useMutation({ mutationFn: (form: FormData) => api.createMailbox({ domainId, localPart: String(form.get("localPart")), displayName: String(form.get("displayName")), password: String(form.get("password")), quotaMb: Number(form.get("quotaMb") || 1024), role: role as "admin" | "user", ownerEmail: String(form.get("ownerEmail") || ""), userId: ownerMode === "existing" ? userId : "" }), onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "邮箱已创建" }) }, onError: (e) => toast({ title: "创建失败", description: e.message }) })
|
||||
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button><Plus className="h-4 w-4" />邮箱</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>创建邮箱账号</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><DomainSelect domains={domains} value={domainId} onChange={setDomainId} /><div className="grid grid-cols-2 gap-3"><Field name="localPart" label="账号" placeholder="alice" /><Field name="displayName" label="显示名" placeholder="Alice" /></div><SelectField label="归属方式" value={ownerMode} onValueChange={setOwnerMode} items={[['new','新建/按邮箱匹配用户'],['existing','追加到已有用户']]} />{ownerMode === "existing" ? <SelectField label="已有用户" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, u.email])} /> : <Field name="ownerEmail" label="归属用户邮箱" placeholder="留空则使用新邮箱" required={false} />}<div className="grid grid-cols-2 gap-3"><Field name="password" label="密码" type="password" placeholder="至少 8 位" /><Field name="quotaMb" label="配额 MB" type="number" defaultValue="1024" /></div><SelectField label="身份" value={role} onValueChange={setRole} items={[['user','普通用户'],['admin','超级管理员']]} /><DialogFooter><Button disabled={mut.isPending || !domainId}>创建</Button></DialogFooter></form></DialogContent></Dialog>
|
||||
const mut = useMutation({ mutationFn: (form: FormData) => api.createMailbox({ domainId, localPart: String(form.get("localPart")), displayName: String(form.get("displayName")), password: String(form.get("password")), quotaMb: Number(form.get("quotaMb") || 1024), role: role as "admin" | "user", ownerLoginName: String(form.get("ownerLoginName") || ""), userId: ownerMode === "existing" ? userId : "" }), onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "邮箱已创建" }) }, onError: (e) => toast({ title: "创建失败", description: e.message }) })
|
||||
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button><Plus className="h-4 w-4" />邮箱</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>创建邮箱</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><DomainSelect domains={domains} value={domainId} onChange={setDomainId} /><div className="grid grid-cols-2 gap-3"><Field name="localPart" label="邮箱前缀" placeholder="alice" /><Field name="displayName" label="显示名" placeholder="Alice" /></div><SelectField label="归属方式" value={ownerMode} onValueChange={setOwnerMode} items={[['new','新建/按登录名匹配账号'],['existing','追加到已有账号']]} />{ownerMode === "existing" ? <SelectField label="已有账号" value={userId} onValueChange={setUserId} items={users.filter((u) => !u.disabled).map((u) => [u.id, accountLoginName(u)])} /> : <Field name="ownerLoginName" label="归属登录名" placeholder="留空则使用新邮箱地址" required={false} />}<div className="grid grid-cols-2 gap-3"><Field name="password" label="密码" type="password" placeholder="至少 8 位" /><Field name="quotaMb" label="配额 MB" type="number" defaultValue="1024" /></div><SelectField label="身份" value={role} onValueChange={setRole} items={[['user','普通用户'],['admin','管理员']]} /><DialogFooter><Button disabled={mut.isPending || !domainId}>创建</Button></DialogFooter></form></DialogContent></Dialog>
|
||||
}
|
||||
|
||||
function CreateAliasDialog({ domains }: { domains: Domain[] }) {
|
||||
const qc = useQueryClient(); const { toast } = useToast(); const [open, setOpen] = React.useState(false); const [domainId, setDomainId] = React.useState("")
|
||||
React.useEffect(() => { if (!domainId && domains[0]) setDomainId(domains[0].id) }, [domains, domainId])
|
||||
const mut = useMutation({ mutationFn: (form: FormData) => api.createAlias({ domainId, source: String(form.get("source")), destination: String(form.get("destination")), enabled: true }), onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "别名已创建" }) }, onError: (e) => toast({ title: "创建失败", description: e.message }) })
|
||||
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button variant="outline"><Plus className="h-4 w-4" />别名</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>创建别名/转发</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><DomainSelect domains={domains} value={domainId} onChange={setDomainId} /><Field name="source" label="来源" placeholder="sales 或 sales@example.com" /><Field name="destination" label="目标邮箱" placeholder="alice@example.com" /><DialogFooter><Button disabled={mut.isPending || !domainId}>创建</Button></DialogFooter></form></DialogContent></Dialog>
|
||||
const mut = useMutation({ mutationFn: (form: FormData) => api.createAlias({ domainId, source: String(form.get("source")), destination: String(form.get("destination")), enabled: true }), onSuccess: () => { invalidateAdmin(qc); setOpen(false); toast({ title: "转发已创建" }) }, onError: (e) => toast({ title: "创建失败", description: e.message }) })
|
||||
return <Dialog open={open} onOpenChange={setOpen}><DialogTrigger asChild><Button variant="outline"><Plus className="h-4 w-4" />转发</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>创建邮件转发</DialogTitle></DialogHeader><form className="space-y-4" onSubmit={(e) => { e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}><DomainSelect domains={domains} value={domainId} onChange={setDomainId} /><Field name="source" label="来源" placeholder="sales 或 sales@example.com" /><Field name="destination" label="目标邮箱" placeholder="alice@example.com" /><DialogFooter><Button disabled={mut.isPending || !domainId}>创建</Button></DialogFooter></form></DialogContent></Dialog>
|
||||
}
|
||||
|
||||
function DNSPanel({ domain, embedded = false }: { domain?: Domain; embedded?: boolean }) {
|
||||
@@ -1930,8 +2028,21 @@ function SwitchRow({ label, checked, onCheckedChange, className = "" }: { label:
|
||||
)
|
||||
}
|
||||
function Field({ label, required = true, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { label: string }) { return <div className="space-y-2"><Label>{label}</Label><Input required={required} {...props} /></div> }
|
||||
function MailboxLimitField({ defaultValue }: { defaultValue: number }) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>邮箱数量上限</Label>
|
||||
<Input name="mailboxLimitOverride" type="number" min={0} step={1} defaultValue={String(defaultValue)} />
|
||||
<div className="text-xs text-muted-foreground">普通用户默认 9 个,填 0 表示不限制。</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
function mailboxLimitFromForm(form: FormData, fallback = defaultMailboxLimitOverride) {
|
||||
const value = Number(form.get("mailboxLimitOverride") || fallback)
|
||||
return Number.isFinite(value) && value >= 0 ? Math.floor(value) : fallback
|
||||
}
|
||||
function effectiveMailboxLimit(user: AdminUser) {
|
||||
return user.mailboxLimitOverride ?? user.limits?.maxMailboxCount ?? defaultMailboxLimitOverride
|
||||
}
|
||||
function SelectField({ label, value, onValueChange, items, disabled = false }: { label: string; value: string; onValueChange: (value: string) => void; items: string[][]; disabled?: boolean }) { return <div className="space-y-2"><Label>{label}</Label><Select value={value} onValueChange={onValueChange} disabled={disabled}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent>{items.map(([value, label]) => <SelectItem key={value} value={value}>{label}</SelectItem>)}</SelectContent></Select></div> }
|
||||
function DomainSelect({ domains, value, onChange }: { domains: Domain[]; value: string; onChange: (value: string) => void }) { return <div className="space-y-2"><Label>域名</Label><Select value={value} onValueChange={onChange}><SelectTrigger><SelectValue placeholder="选择域名" /></SelectTrigger><SelectContent>{domains.map((d) => <SelectItem key={d.id} value={d.id}>{d.name}</SelectItem>)}</SelectContent></Select></div> }
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export function LoginPage() {
|
||||
const login = useMutation({
|
||||
mutationFn: (form: FormData) => challengeToken
|
||||
? api.login({ challengeToken, twoFactorCode: String(form.get("twoFactorCode") || "") })
|
||||
: api.login({ email: String(form.get("email") || ""), password: String(form.get("password") || ""), turnstileToken }),
|
||||
: api.login({ loginName: String(form.get("loginName") || ""), password: String(form.get("password") || ""), turnstileToken }),
|
||||
onSuccess: async (data) => {
|
||||
if (data.twoFactorRequired && data.challengeToken) {
|
||||
setChallengeToken(data.challengeToken)
|
||||
@@ -38,7 +38,7 @@ export function LoginPage() {
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
|
||||
<div className="w-full max-w-[420px]">
|
||||
<div className="mb-7 text-center">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">LanQin Email</h1>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn 邮箱</h1>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-background p-6 shadow-sm sm:p-7">
|
||||
<div className="mb-6 flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
@@ -49,8 +49,8 @@ export function LoginPage() {
|
||||
{!challengeToken ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-sm font-medium">邮箱</Label>
|
||||
<Input id="email" name="email" type="email" autoComplete="username" required className="h-11 text-base" />
|
||||
<Label htmlFor="loginName" className="text-sm font-medium">登录名</Label>
|
||||
<Input id="loginName" name="loginName" type="text" autoComplete="username" required className="h-11 text-base" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-sm font-medium">密码</Label>
|
||||
@@ -85,4 +85,3 @@ export function LoginPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ export function RegisterPage() {
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted/20 px-4 py-10">
|
||||
<div className="w-full max-w-[420px]">
|
||||
<div className="mb-7 text-center">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">LanQin Email</h1>
|
||||
<h1 className="text-3xl font-semibold tracking-tight">NewSzxcn 邮箱</h1>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-background p-6 shadow-sm sm:p-7">
|
||||
<div className="mb-6 flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
|
||||
@@ -7,14 +7,27 @@
|
||||
# =========================
|
||||
# 单容器部署镜像。默认使用你的 GHCR 镜像。
|
||||
# 本地源码构建请用:docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
LANQIN_IMAGE=ghcr.io/lanqin996/lanqin-email:latest
|
||||
LANQIN_IMAGE=ghcr.io/zxyszx/newszxcn-email:latest
|
||||
|
||||
# 多容器调试版镜像。只在 docker-compose.stack.yml 中使用。
|
||||
LANQIN_API_IMAGE=ghcr.io/lanqin996/lanqin-email-api:latest
|
||||
LANQIN_WEB_IMAGE=ghcr.io/lanqin996/lanqin-email-web:latest
|
||||
LANQIN_POSTFIX_IMAGE=ghcr.io/lanqin996/lanqin-email-postfix:latest
|
||||
LANQIN_DOVECOT_IMAGE=ghcr.io/lanqin996/lanqin-email-dovecot:latest
|
||||
LANQIN_RSPAMD_IMAGE=ghcr.io/lanqin996/lanqin-email-rspamd:latest
|
||||
LANQIN_API_IMAGE=ghcr.io/zxyszx/newszxcn-email-api:latest
|
||||
LANQIN_WEB_IMAGE=ghcr.io/zxyszx/newszxcn-email-web:latest
|
||||
LANQIN_POSTFIX_IMAGE=ghcr.io/zxyszx/newszxcn-email-postfix:latest
|
||||
LANQIN_DOVECOT_IMAGE=ghcr.io/zxyszx/newszxcn-email-dovecot:latest
|
||||
LANQIN_RSPAMD_IMAGE=ghcr.io/zxyszx/newszxcn-email-rspamd:latest
|
||||
|
||||
# 后台“立即更新”使用的内部鉴权令牌。一键安装会自动生成,请勿对外公开。
|
||||
# 手动部署可执行:openssl rand -hex 24
|
||||
LANQIN_UPDATE_TOKEN=
|
||||
|
||||
# 可选端口绑定。使用宿主机反向代理时,可将 HTTP 设为 127.0.0.1:8088。
|
||||
LANQIN_HTTP_BIND=80
|
||||
LANQIN_HTTPS_BIND=443
|
||||
LANQIN_SMTP_BIND=25
|
||||
LANQIN_SMTPS_BIND=465
|
||||
LANQIN_SUBMISSION_BIND=587
|
||||
LANQIN_IMAPS_BIND=993
|
||||
LANQIN_POP3S_BIND=995
|
||||
|
||||
# =========================
|
||||
# 对外访问地址
|
||||
@@ -35,8 +48,9 @@ LANQIN_TLS_KEY_FILE=
|
||||
# =========================
|
||||
# 初始管理员
|
||||
# =========================
|
||||
# 第一次启动时会创建这个管理员账号。
|
||||
LANQIN_ADMIN_EMAIL=admin@example.com
|
||||
# 第一次启动时只创建管理员账号,不会自动创建同名邮箱或域名。
|
||||
# 登录名不能使用邮箱地址,之后可在后台“账号”中修改。
|
||||
LANQIN_ADMIN_USERNAME=admin
|
||||
|
||||
# 生产环境必须改掉默认密码。
|
||||
LANQIN_ADMIN_PASSWORD=ChangeMe123!
|
||||
@@ -97,6 +111,16 @@ LANQIN_SMTP_PASSWORD=
|
||||
# 外部 SMTP 要求 STARTTLS / TLS 时改 true;本机 Postfix 默认 false。
|
||||
LANQIN_SMTP_REQUIRE_TLS=false
|
||||
|
||||
# 开放 API 最终投递事件回调的 HMAC-SHA256 密钥。生产环境请使用高强度随机值。
|
||||
# 未配置时 /api/open/v1/delivery-events 返回 503。
|
||||
LANQIN_DELIVERY_WEBHOOK_SECRET=
|
||||
|
||||
# 可选:把发送队列与最终投递状态主动推送给外部系统。URL 默认必须为公网 HTTPS。
|
||||
LANQIN_STATUS_WEBHOOK_URL=
|
||||
LANQIN_STATUS_WEBHOOK_SECRET=
|
||||
# 仅可信内网或本地测试可开启;开启后也允许 HTTP 与私网目标。
|
||||
LANQIN_STATUS_WEBHOOK_ALLOW_PRIVATE_HOSTS=false
|
||||
|
||||
# 第三方客户端 SMTP 提交,由 LanQin API 监听 587/465;启用前必须配置可读 TLS 证书。
|
||||
LANQIN_SUBMISSION_ADDR=
|
||||
LANQIN_SUBMISSION_TLS_ADDR=
|
||||
@@ -157,4 +181,3 @@ LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET=
|
||||
# =========================
|
||||
# 容器时区。
|
||||
TZ=Asia/Shanghai
|
||||
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
# LanQin Email Docker 部署说明
|
||||
# NewSzxcn Email Docker 部署说明
|
||||
|
||||
## 一键安装与更新
|
||||
|
||||
推荐直接使用仓库根目录的管理脚本:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh | sudo bash
|
||||
```
|
||||
|
||||
后续操作:
|
||||
|
||||
```bash
|
||||
sudo newszxcn-email update
|
||||
sudo newszxcn-email status
|
||||
sudo newszxcn-email logs
|
||||
sudo newszxcn-email rollback
|
||||
```
|
||||
|
||||
一键安装会把配置和数据放在 `/opt/newszxcn-email`,并部署内部 Watchtower 更新服务。该服务不映射公网端口,仅接受带随机令牌的容器内请求;后台“立即更新”也只允许超级管理员执行。
|
||||
|
||||
## 最简单部署:单容器镜像版
|
||||
|
||||
@@ -7,7 +26,7 @@
|
||||
```bash
|
||||
cd deploy
|
||||
cp .env.example .env
|
||||
# 修改 LANQIN_PUBLIC_HOSTNAME / LANQIN_PUBLIC_BASE_URL / LANQIN_ADMIN_EMAIL / LANQIN_ADMIN_PASSWORD
|
||||
# 修改 LANQIN_PUBLIC_HOSTNAME / LANQIN_PUBLIC_BASE_URL / LANQIN_ADMIN_USERNAME / LANQIN_ADMIN_PASSWORD
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
@@ -55,12 +74,12 @@ docker compose down
|
||||
默认镜像:
|
||||
|
||||
```text
|
||||
ghcr.io/lanqin996/lanqin-email:latest
|
||||
ghcr.io/lanqin996/lanqin-email-api:latest
|
||||
ghcr.io/lanqin996/lanqin-email-web:latest
|
||||
ghcr.io/lanqin996/lanqin-email-postfix:latest
|
||||
ghcr.io/lanqin996/lanqin-email-dovecot:latest
|
||||
ghcr.io/lanqin996/lanqin-email-rspamd:latest
|
||||
ghcr.io/zxyszx/newszxcn-email:latest
|
||||
ghcr.io/zxyszx/newszxcn-email-api:latest
|
||||
ghcr.io/zxyszx/newszxcn-email-web:latest
|
||||
ghcr.io/zxyszx/newszxcn-email-postfix:latest
|
||||
ghcr.io/zxyszx/newszxcn-email-dovecot:latest
|
||||
ghcr.io/zxyszx/newszxcn-email-rspamd:latest
|
||||
```
|
||||
|
||||
如果拉取时报:
|
||||
@@ -135,6 +154,14 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up
|
||||
## 邮件客户端 TLS 证书
|
||||
|
||||
Web 站点可以由宿主机 Nginx / 宝塔反代到容器 `80`,但 SMTP/IMAP/POP3 端口不会使用 Web 反代的证书。
|
||||
此时可在 `.env` 调整 Web 端口绑定,避免与宿主机 Nginx 的 `80/443` 冲突:
|
||||
|
||||
```dotenv
|
||||
LANQIN_HTTP_BIND=127.0.0.1:8088
|
||||
LANQIN_HTTPS_BIND=127.0.0.1:8443
|
||||
```
|
||||
|
||||
宿主机 Nginx 再反向代理到 `http://127.0.0.1:8088`。不使用宿主机反向代理时保留默认的 `80` 与 `443` 即可。
|
||||
如果第三方客户端连接 `993/995` 时提示证书是 `localhost`,说明 Dovecot 仍在使用容器自带的测试证书。LanQin API 的 SMTP `465/587` submission 不会使用自签测试证书;启用前必须配置可读的真实证书。
|
||||
|
||||
生产环境请把域名证书挂载进容器,并在 `.env` 指向证书文件:
|
||||
@@ -174,6 +201,24 @@ LANQIN_SMTP_PORT=25
|
||||
LANQIN_SMTP_REQUIRE_TLS=false
|
||||
```
|
||||
|
||||
如需把上游服务商或 DSN 处理器的最终送达、退信、投诉、拒收事件写回开放 API,请设置:
|
||||
|
||||
```env
|
||||
LANQIN_DELIVERY_WEBHOOK_SECRET=replace-with-a-long-random-secret
|
||||
```
|
||||
|
||||
回调地址、签名算法和事件格式见仓库中的 `docs/API.md` 与 `docs/openapi.json`。该接口未配置密钥时返回 `503`。
|
||||
|
||||
如需把状态变化主动推送到集成方,可额外设置:
|
||||
|
||||
```env
|
||||
LANQIN_STATUS_WEBHOOK_URL=https://integration.example.com/hooks/lanqin
|
||||
LANQIN_STATUS_WEBHOOK_SECRET=replace-with-another-long-random-secret
|
||||
LANQIN_STATUS_WEBHOOK_ALLOW_PRIVATE_HOSTS=false
|
||||
```
|
||||
|
||||
事件先写入 SQLite outbox,再由后台 worker 投递;非 2xx 响应会按退避策略重试,最多 10 次。默认只允许公网 HTTPS,禁止重定向、URL 用户信息和私网/本机目标。只有可信内网或本地测试才应开启 `LANQIN_STATUS_WEBHOOK_ALLOW_PRIVATE_HOSTS`。
|
||||
|
||||
Split stack 使用 `docker-compose.stack.yml` 时,API 容器默认会把 `LANQIN_SMTP_HOST` 覆盖为 `postfix`,让 Webmail 和 SMTP 提交都 relay 到 Postfix service。只有改用外部 SMTP 时才需要在 `.env` 明确填写 `LANQIN_STACK_SMTP_HOST` / `LANQIN_STACK_SMTP_PORT`。
|
||||
|
||||
如果发送队列里出现 relay 失败,通常是 Postfix 会话被中断或外部 SMTP 配置错误。优先检查:
|
||||
@@ -194,4 +239,3 @@ docker compose logs --tail=200 lanqin-email
|
||||
- 建议在服务器或边缘网关配置 HTTPS。
|
||||
- 云厂商通常默认封禁 25 端口,需要单独申请解封。
|
||||
- SQLite 适合 V1 单机部署;多节点部署前迁移到 PostgreSQL,并把 Postfix/Dovecot maps 改为 PostgreSQL。
|
||||
|
||||
|
||||
@@ -6,9 +6,13 @@ COPY apps/api/go.mod apps/api/go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
COPY apps/api ./
|
||||
ARG APP_VERSION="dev"
|
||||
ARG APP_COMMIT=""
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/lanqin-api ./cmd/server
|
||||
CGO_ENABLED=0 GOOS=linux go build -trimpath \
|
||||
-ldflags "-s -w -X lanqin-email-api/internal/app.BuildVersion=${APP_VERSION} -X lanqin-email-api/internal/app.BuildCommit=${APP_COMMIT}" \
|
||||
-o /out/lanqin-api ./cmd/server
|
||||
|
||||
FROM node:20-bookworm-slim AS web-build
|
||||
WORKDIR /src
|
||||
@@ -16,7 +20,7 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile --filter lanqin-email-web...
|
||||
pnpm install --frozen-lockfile --filter newszxcn-email-web...
|
||||
COPY apps/web apps/web
|
||||
ARG VITE_APP_VERSION=""
|
||||
ARG VITE_RELEASE_URL=""
|
||||
|
||||
@@ -17,7 +17,17 @@ server {
|
||||
proxy_pass http://127.0.0.1:8080/healthz;
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
}
|
||||
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,13 @@ COPY apps/api/go.mod apps/api/go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
COPY apps/api ./
|
||||
ARG APP_VERSION="dev"
|
||||
ARG APP_COMMIT=""
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/lanqin-api ./cmd/server
|
||||
CGO_ENABLED=0 GOOS=linux go build -trimpath \
|
||||
-ldflags "-s -w -X lanqin-email-api/internal/app.BuildVersion=${APP_VERSION} -X lanqin-email-api/internal/app.BuildCommit=${APP_COMMIT}" \
|
||||
-o /out/lanqin-api ./cmd/server
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
|
||||
@@ -3,3 +3,8 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/all-in-one/Dockerfile
|
||||
args:
|
||||
APP_VERSION: ${LANQIN_BUILD_VERSION:-dev}
|
||||
APP_COMMIT: ${LANQIN_BUILD_COMMIT:-local}
|
||||
VITE_APP_VERSION: ${LANQIN_BUILD_VERSION:-dev}
|
||||
VITE_RELEASE_URL: ${LANQIN_BUILD_RELEASE_URL:-}
|
||||
|
||||
@@ -3,11 +3,17 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/api.Dockerfile
|
||||
args:
|
||||
APP_VERSION: ${LANQIN_BUILD_VERSION:-dev}
|
||||
APP_COMMIT: ${LANQIN_BUILD_COMMIT:-local}
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/web.Dockerfile
|
||||
args:
|
||||
VITE_APP_VERSION: ${LANQIN_BUILD_VERSION:-dev}
|
||||
VITE_RELEASE_URL: ${LANQIN_BUILD_RELEASE_URL:-}
|
||||
|
||||
postfix:
|
||||
build:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
api:
|
||||
image: ${LANQIN_API_IMAGE:-ghcr.io/lanqin996/lanqin-email-api:latest}
|
||||
image: ${LANQIN_API_IMAGE:-ghcr.io/zxyszx/newszxcn-email-api:latest}
|
||||
env_file: .env
|
||||
environment:
|
||||
LANQIN_SMTP_HOST: ${LANQIN_STACK_SMTP_HOST:-postfix}
|
||||
@@ -23,7 +23,7 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
web:
|
||||
image: ${LANQIN_WEB_IMAGE:-ghcr.io/lanqin996/lanqin-email-web:latest}
|
||||
image: ${LANQIN_WEB_IMAGE:-ghcr.io/zxyszx/newszxcn-email-web:latest}
|
||||
restart: unless-stopped
|
||||
|
||||
nginx:
|
||||
@@ -39,7 +39,7 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
postfix:
|
||||
image: ${LANQIN_POSTFIX_IMAGE:-ghcr.io/lanqin996/lanqin-email-postfix:latest}
|
||||
image: ${LANQIN_POSTFIX_IMAGE:-ghcr.io/zxyszx/newszxcn-email-postfix:latest}
|
||||
env_file: .env
|
||||
volumes:
|
||||
- ./data:/data
|
||||
@@ -54,7 +54,7 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
dovecot:
|
||||
image: ${LANQIN_DOVECOT_IMAGE:-ghcr.io/lanqin996/lanqin-email-dovecot:latest}
|
||||
image: ${LANQIN_DOVECOT_IMAGE:-ghcr.io/zxyszx/newszxcn-email-dovecot:latest}
|
||||
env_file: .env
|
||||
volumes:
|
||||
- ./data:/data
|
||||
@@ -69,7 +69,7 @@ services:
|
||||
restart: unless-stopped
|
||||
|
||||
rspamd:
|
||||
image: ${LANQIN_RSPAMD_IMAGE:-ghcr.io/lanqin996/lanqin-email-rspamd:latest}
|
||||
image: ${LANQIN_RSPAMD_IMAGE:-ghcr.io/zxyszx/newszxcn-email-rspamd:latest}
|
||||
env_file: .env
|
||||
volumes:
|
||||
- ./data:/data:ro
|
||||
|
||||
@@ -1,19 +1,38 @@
|
||||
services:
|
||||
lanqin-email:
|
||||
image: ${LANQIN_IMAGE:-ghcr.io/lanqin996/lanqin-email:latest}
|
||||
image: ${LANQIN_IMAGE:-ghcr.io/zxyszx/newszxcn-email:latest}
|
||||
env_file: .env
|
||||
environment:
|
||||
LANQIN_UPDATE_SERVICE_URL: http://updater:8080/v1/update
|
||||
LANQIN_UPDATE_SERVICE_TOKEN: ${LANQIN_UPDATE_TOKEN:-}
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "25:25"
|
||||
- "465:465"
|
||||
- "587:587"
|
||||
- "993:993"
|
||||
- "995:995"
|
||||
- "${LANQIN_HTTP_BIND:-80}:80"
|
||||
- "${LANQIN_HTTPS_BIND:-443}:443"
|
||||
- "${LANQIN_SMTP_BIND:-25}:25"
|
||||
- "${LANQIN_SMTPS_BIND:-465}:465"
|
||||
- "${LANQIN_SUBMISSION_BIND:-587}:587"
|
||||
- "${LANQIN_IMAPS_BIND:-993}:993"
|
||||
- "${LANQIN_POP3S_BIND:-995}:995"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./mail:/var/mail/vhosts
|
||||
- ./dkim:/var/lib/rspamd/dkim
|
||||
# 生产环境如需第三方客户端校验证书,请取消下面挂载,并在 .env 配置:
|
||||
# - /etc/letsencrypt:/etc/letsencrypt:ro
|
||||
labels:
|
||||
com.centurylinklabs.watchtower.enable: "true"
|
||||
com.centurylinklabs.watchtower.scope: "newszxcn-email"
|
||||
restart: unless-stopped
|
||||
|
||||
updater:
|
||||
image: nickfedor/watchtower:1.20.2
|
||||
environment:
|
||||
WATCHTOWER_HTTP_API_TOKEN: ${LANQIN_UPDATE_TOKEN:?请在 .env 设置 LANQIN_UPDATE_TOKEN}
|
||||
command:
|
||||
- --http-api-update
|
||||
- --label-enable
|
||||
- --scope
|
||||
- newszxcn-email
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1,34 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
set -Eeuo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "未检测到 docker,请先安装 Docker Engine / Docker Compose。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
echo "未检测到 docker compose,请先安装 Docker Compose v2。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
cp .env.example .env
|
||||
echo "已生成 deploy/.env。请先编辑域名和管理员密码:"
|
||||
echo " LANQIN_PUBLIC_HOSTNAME"
|
||||
echo " LANQIN_PUBLIC_BASE_URL"
|
||||
echo " LANQIN_ADMIN_EMAIL"
|
||||
echo " LANQIN_ADMIN_PASSWORD"
|
||||
echo
|
||||
echo "编辑完成后再次执行:./install.sh"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "拉取镜像..."
|
||||
docker compose pull
|
||||
|
||||
echo "启动服务..."
|
||||
docker compose up -d
|
||||
|
||||
echo "完成。查看日志:docker compose logs -f lanqin-email"
|
||||
exec "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/install.sh" "$@"
|
||||
|
||||
@@ -3,5 +3,15 @@ server {
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
location / { try_files $uri $uri/ /index.html; }
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
}
|
||||
location /assets/ {
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
location / {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ virtual_alias_maps = sqlite:/etc/postfix/sqlite-aliases.cf
|
||||
virtual_transport = lmtp:inet:dovecot:24
|
||||
virtual_mailbox_base = /var/mail/vhosts
|
||||
|
||||
smtpd_banner = $myhostname ESMTP LanQin Email
|
||||
smtpd_banner = $myhostname ESMTP NewSzxcn Email
|
||||
smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination
|
||||
smtpd_recipient_restrictions = permit_mynetworks, reject_unauth_destination
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile --filter lanqin-email-web...
|
||||
pnpm install --frozen-lockfile --filter newszxcn-email-web...
|
||||
COPY apps/web apps/web
|
||||
ARG VITE_APP_VERSION=""
|
||||
ARG VITE_RELEASE_URL=""
|
||||
|
||||
@@ -0,0 +1,685 @@
|
||||
# LanQin Email API
|
||||
|
||||
LanQin Email exposes versioned integration APIs under `/api/open/v1`. The original `/api/open` paths remain compatibility aliases.
|
||||
|
||||
这些接口用于外部系统集成,稳定版本入口为 `/api/open/v1`。原 `/api/open` 路径继续作为兼容别名。它们不是匿名公开接口,只接受 API Token,不接受浏览器登录 Session Cookie。
|
||||
|
||||
Machine-readable OpenAPI 3.1 contract: [`docs/openapi.json`](./openapi.json).
|
||||
|
||||
机器可读的 OpenAPI 3.1 契约见 [`docs/openapi.json`](./openapi.json)。
|
||||
|
||||
## Base URL
|
||||
|
||||
All API endpoints are relative to your LanQin Email instance:
|
||||
|
||||
所有接口地址都相对于你的 LanQin Email 实例:
|
||||
|
||||
```
|
||||
https://your-instance.example.com
|
||||
```
|
||||
|
||||
## HTTP Status Codes
|
||||
|
||||
The API uses standard HTTP status codes:
|
||||
|
||||
接口使用标准 HTTP 状态码:
|
||||
|
||||
| Code 状态码 | Meaning 含义 |
|
||||
|------|---------|
|
||||
| `200 OK` | Request succeeded / 请求成功 |
|
||||
| `201 Created` | Resource created successfully / 资源创建成功 |
|
||||
| `400 Bad Request` | Invalid request parameters or validation error / 请求参数无效或校验失败 |
|
||||
| `401 Unauthorized` | Missing or invalid API token / 缺少或无效的 API Token |
|
||||
| `403 Forbidden` | Token lacks required permissions / Token 缺少所需权限 |
|
||||
| `404 Not Found` | Resource does not exist / 资源不存在 |
|
||||
| `409 Conflict` | Idempotency key conflict or concurrent status change / 幂等键冲突或状态并发变化 |
|
||||
| `429 Too Many Requests` | Rate limit exceeded / 超过频率限制 |
|
||||
| `500 Internal Server Error` | Server error / 服务器错误 |
|
||||
|
||||
Validation failures — including a duplicate domain name — return `400 Bad Request`, not `409`.
|
||||
|
||||
校验失败(包括域名重复)会返回 `400 Bad Request`,而不是 `409`。
|
||||
|
||||
## Error Responses
|
||||
|
||||
All API errors return JSON with this structure:
|
||||
|
||||
所有接口的错误都以如下 JSON 结构返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "error message"
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: The API uses `DisallowUnknownFields()` for JSON parsing. Sending fields not defined in the request schema will result in a `400 Bad Request` error.
|
||||
|
||||
**重要提示**:接口在解析 JSON 时启用了 `DisallowUnknownFields()`。如果请求体中包含 schema 未定义的字段,将返回 `400 Bad Request` 错误。
|
||||
|
||||
Examples:
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "invalid token"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "domain not found"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "localPart is required"
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
Open API requests must use a Bearer API Token:
|
||||
|
||||
Open API 请求必须使用 Bearer API Token:
|
||||
|
||||
```http
|
||||
Authorization: Bearer lq_xxx
|
||||
```
|
||||
|
||||
Create tokens in **Profile / API Token**. The plain token is shown only once after creation, so store it securely and revoke it if it may have leaked.
|
||||
|
||||
请在 **个人中心 / API Token** 中创建 Token。明文 Token 只会在创建后显示一次,请安全保存;如果怀疑泄露,应立即撤销并重新创建。
|
||||
|
||||
Created token example:
|
||||
|
||||
创建后的 Token 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "lq_xxx"
|
||||
}
|
||||
```
|
||||
|
||||
Tokens created without a custom expiration default to 90 days. You can disable or revoke tokens from the same profile page.
|
||||
|
||||
如果没有自定义到期时间,Token 默认 90 天后过期。你可以在同一个个人中心页面中禁用或撤销 Token。
|
||||
|
||||
Each token has independent scopes. Scopes only reduce the permissions of the owning user; they never grant permissions the user does not already have. Existing tokens created before scope support are migrated to `*` for compatibility.
|
||||
|
||||
每个 Token 都有独立 scope。scope 只会收缩 Token 所属用户已有的权限,不会授予用户原本没有的权限。scope 功能上线前创建的 Token 会迁移为 `*`,以保持兼容。
|
||||
|
||||
| Scope | Purpose |
|
||||
|---|---|
|
||||
| `domains:read` / `domains:write` | View or manage sending domains |
|
||||
| `mailboxes:read` / `mailboxes:write` | View or manage mailboxes; password reset is a write operation |
|
||||
| `messages:read` / `messages:send` / `messages:manage` | Read messages/status, send, or retry/cancel |
|
||||
| `aliases:read` / `aliases:write` | View or manage aliases |
|
||||
| `dns:read` / `dns:check` | View required records or execute DNS checks |
|
||||
| `*` | Compatibility wildcard; avoid for new integrations |
|
||||
|
||||
## Permissions
|
||||
|
||||
All Open API endpoints require an API token with appropriate permissions and role requirements:
|
||||
|
||||
所有 Open API 接口都需要具备相应权限和角色的 API Token:
|
||||
|
||||
| Endpoint group | Required scope | Role |
|
||||
|---|---|---|
|
||||
| Domains | `domains:read` or `domains:write` | admin |
|
||||
| Mailboxes | `mailboxes:read` or `mailboxes:write` | admin |
|
||||
| DNS | `dns:read` or `dns:check` | admin |
|
||||
| Aliases | `aliases:read` or `aliases:write` | admin |
|
||||
| Send / status / messages | `messages:send`, `messages:read`, or `messages:manage` | user or admin |
|
||||
|
||||
**Notes:**
|
||||
- Admin endpoints check for `requireAdminAccess` (role must be `admin`).
|
||||
- Mail sending/reading endpoints work for regular users but only for mailboxes they own.
|
||||
- Users can only read messages from their own active mailboxes.
|
||||
|
||||
**说明:**
|
||||
- 域名和邮箱管理接口会检查 `requireAdminAccess`(角色必须为 `admin`)。
|
||||
- 发信/读信接口对普通用户也可用,但只能操作自己拥有的邮箱。
|
||||
- 用户只能读取自己拥有的 active 邮箱中的邮件。
|
||||
|
||||
## Domains
|
||||
|
||||
### List domains
|
||||
|
||||
```http
|
||||
GET /api/open/v1/domains
|
||||
Authorization: Bearer lq_xxx
|
||||
```
|
||||
|
||||
**Status:** `200 OK`
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "dom_xxx",
|
||||
"name": "example.com",
|
||||
"status": "active",
|
||||
"dkimSelector": "lanqin",
|
||||
"dkimPublicKey": "v=DKIM1; k=rsa; p=MIIBIjANBgkq...",
|
||||
"dnsStatus": "unchecked",
|
||||
"dnsCheckedAt": null,
|
||||
"createdAt": "2026-06-29T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Field descriptions:**
|
||||
- `status`: `active` or `disabled`
|
||||
- `dnsStatus`: `unchecked` (initial), `ok` (all DNS records verified), or `error` (verification failed)
|
||||
- `dnsCheckedAt`: Timestamp of last DNS check (nullable)
|
||||
- `dkimPublicKey`: Public key for DKIM signing (omitted in some contexts)
|
||||
|
||||
**字段说明:**
|
||||
- `status`:`active` 或 `disabled`
|
||||
- `dnsStatus`:`unchecked`(初始)、`ok`(所有 DNS 记录校验通过)或 `error`(校验失败)
|
||||
- `dnsCheckedAt`:上次 DNS 检查的时间戳(可为 null)
|
||||
- `dkimPublicKey`:用于 DKIM 签名的公钥(部分场景下会省略)
|
||||
|
||||
**Note:** This endpoint returns all domains without pagination.
|
||||
|
||||
**注意:** 该接口一次性返回所有域名,不分页。
|
||||
|
||||
### Create domain
|
||||
|
||||
```http
|
||||
POST /api/open/v1/domains
|
||||
Authorization: Bearer lq_xxx
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "example.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** `201 Created`
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "dom_xxx",
|
||||
"name": "example.com",
|
||||
"status": "active",
|
||||
"dkimSelector": "lanqin",
|
||||
"dkimPublicKey": "v=DKIM1; k=rsa; p=MIIBIjANBgkq...",
|
||||
"dnsStatus": "unchecked",
|
||||
"dnsCheckedAt": null,
|
||||
"createdAt": "2026-06-29T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Domain name is automatically normalized to lowercase
|
||||
- DKIM keys are generated automatically
|
||||
- Initial `dnsStatus` is `unchecked`
|
||||
|
||||
**说明:**
|
||||
- 域名会自动规范化为小写
|
||||
- DKIM 密钥会自动生成
|
||||
- 初始 `dnsStatus` 为 `unchecked`
|
||||
|
||||
### Get domain
|
||||
|
||||
```http
|
||||
GET /api/open/v1/domains/{id}
|
||||
Authorization: Bearer lq_xxx
|
||||
```
|
||||
|
||||
**Status:** `200 OK` or `404 Not Found`
|
||||
|
||||
**Response:** Same as domain object in list response.
|
||||
|
||||
**响应:** 与列表接口中的 domain 对象结构相同。
|
||||
|
||||
### Update domain status
|
||||
|
||||
```http
|
||||
POST /api/open/v1/domains/{id}
|
||||
Authorization: Bearer lq_xxx
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"status": "active"
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** `200 OK` or `404 Not Found`
|
||||
|
||||
**Request body:**
|
||||
- `status`: Must be `active` or `disabled`
|
||||
|
||||
**请求体:**
|
||||
- `status`:必须为 `active` 或 `disabled`
|
||||
|
||||
**Response:** Updated domain object.
|
||||
|
||||
**响应:** 更新后的 domain 对象。
|
||||
|
||||
**Note:** This endpoint uses `POST` (not `PATCH`/`PUT`) for simplicity in client implementations.
|
||||
|
||||
**注意:** 该接口使用 `POST`(而非 `PATCH`/`PUT`),以简化客户端实现。
|
||||
|
||||
### Delete domain
|
||||
|
||||
```http
|
||||
DELETE /api/open/v1/domains/{id}
|
||||
Authorization: Bearer lq_xxx
|
||||
```
|
||||
|
||||
**Status:** `200 OK`, `404 Not Found`, or `400 Bad Request`
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
**Error cases:**
|
||||
- `400`: Domain still has mailboxes (must delete mailboxes first)
|
||||
- `404`: Domain not found
|
||||
|
||||
**错误情况:**
|
||||
- `400`:域名下仍有邮箱(需先删除邮箱)
|
||||
- `404`:域名不存在
|
||||
|
||||
## Mailboxes
|
||||
|
||||
### List mailboxes
|
||||
|
||||
```http
|
||||
GET /api/open/v1/mailboxes
|
||||
Authorization: Bearer lq_xxx
|
||||
```
|
||||
|
||||
**Status:** `200 OK`
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "mbx_xxx",
|
||||
"userId": "usr_xxx",
|
||||
"userEmail": "alice@example.com",
|
||||
"domainId": "dom_xxx",
|
||||
"localPart": "alice",
|
||||
"address": "alice@example.com",
|
||||
"displayName": "Alice",
|
||||
"quotaMb": 1024,
|
||||
"status": "active",
|
||||
"createdAt": "2026-06-29T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** This endpoint returns all mailboxes without pagination.
|
||||
|
||||
**注意:** 该接口一次性返回所有邮箱,不分页。
|
||||
|
||||
### Create mailbox
|
||||
|
||||
```http
|
||||
POST /api/open/v1/mailboxes
|
||||
Authorization: Bearer lq_xxx
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"domainId": "dom_xxx",
|
||||
"localPart": "alice",
|
||||
"displayName": "Alice",
|
||||
"password": "Password123!",
|
||||
"quotaMb": 1024,
|
||||
"ownerEmail": "alice@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** `201 Created`
|
||||
|
||||
**Request fields:**
|
||||
|
||||
| Field 字段 | Required 必填 | Description 说明 |
|
||||
|-------|----------|-------------|
|
||||
| `domainId` | Yes | ID of an existing domain / 已存在域名的 ID |
|
||||
| `localPart` | Yes | Local part of the address. Normalized to lowercase; only `a-z 0-9 . _ % + -` are kept, other characters stripped / 地址本地部分。会规范化为小写,仅保留 `a-z 0-9 . _ % + -`,其余字符会被移除 |
|
||||
| `password` | Yes | At least 8 characters. Used as the mailbox password / 至少 8 位,用作邮箱密码 |
|
||||
| `displayName` | No | Defaults to the mailbox address if omitted / 省略时默认使用邮箱地址 |
|
||||
| `quotaMb` | No | Mailbox quota in MB / 邮箱配额(MB) |
|
||||
| `ownerEmail` | No | Owner's email. See owner resolution below / 拥有者邮箱,见下方拥有者解析规则 |
|
||||
| `userId` | No | Bind to an existing user by ID. Takes precedence over `ownerEmail` / 绑定到已有用户的 ID,优先级高于 `ownerEmail` |
|
||||
|
||||
**Owner resolution:**
|
||||
- If `userId` is provided, the mailbox is bound to that existing user (must be an active user).
|
||||
- Otherwise, if `ownerEmail` is provided, LanQin Email looks up an active user with that email.
|
||||
- If `ownerEmail` is omitted, the mailbox address is used as the owner email.
|
||||
- If no active user with that email exists, a new user is created automatically.
|
||||
|
||||
**拥有者解析规则:**
|
||||
- 如果传了 `userId`,邮箱会绑定到该已有用户(必须是启用状态的用户)。
|
||||
- 否则,如果传了 `ownerEmail`,系统会查找该邮箱对应的启用用户。
|
||||
- 如果省略 `ownerEmail`,则使用邮箱地址作为拥有者邮箱。
|
||||
- 如果不存在对应的启用用户,系统会自动创建一个新用户。
|
||||
|
||||
**Response:** Created mailbox object (same shape as list response).
|
||||
|
||||
**响应:** 创建后的 mailbox 对象(结构与列表接口相同)。
|
||||
|
||||
### Get mailbox
|
||||
|
||||
```http
|
||||
GET /api/open/v1/mailboxes/{id}
|
||||
Authorization: Bearer lq_xxx
|
||||
```
|
||||
|
||||
**Status:** `200 OK` or `404 Not Found`
|
||||
|
||||
**Response:** Mailbox object (same shape as list response).
|
||||
|
||||
**响应:** mailbox 对象(结构与列表接口相同)。
|
||||
|
||||
### Update mailbox
|
||||
|
||||
```http
|
||||
POST /api/open/v1/mailboxes/{id}
|
||||
Authorization: Bearer lq_xxx
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"displayName": "Alice Work",
|
||||
"quotaMb": 2048,
|
||||
"status": "active",
|
||||
"userId": "usr_xxx"
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** `200 OK` or `404 Not Found`
|
||||
|
||||
All fields are optional. Omitted (or empty / non-positive) fields keep their current value. `status` can be `active` or `disabled`. When `userId` is provided, the target user must exist and be active.
|
||||
|
||||
所有字段均可选。省略(或为空 / 非正数)的字段会保留原值。`status` 可为 `active` 或 `disabled`。如果传了 `userId`,目标用户必须存在且处于启用状态。
|
||||
|
||||
**Response:** Updated mailbox object.
|
||||
|
||||
**响应:** 更新后的 mailbox 对象。
|
||||
|
||||
### Delete mailbox
|
||||
|
||||
```http
|
||||
DELETE /api/open/v1/mailboxes/{id}
|
||||
Authorization: Bearer lq_xxx
|
||||
```
|
||||
|
||||
**Status:** `200 OK`, `404 Not Found`, or `400 Bad Request`
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Deleting a mailbox also deletes all of its messages.
|
||||
- If the token owner is deleting their own mailbox, it cannot be their last remaining mailbox (returns `400`).
|
||||
|
||||
**说明:**
|
||||
- 删除邮箱会同时删除该邮箱下的所有邮件。
|
||||
- 如果 Token 拥有者删除的是自己的邮箱,则不能删除最后一个邮箱(会返回 `400`)。
|
||||
|
||||
## Send Mail
|
||||
|
||||
```http
|
||||
POST /api/open/v1/send
|
||||
Authorization: Bearer lq_xxx
|
||||
Idempotency-Key: invoice-2026-0001
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"mailboxId": "mbx_xxx",
|
||||
"to": ["bob@example.com"],
|
||||
"cc": [],
|
||||
"bcc": [],
|
||||
"subject": "Hello",
|
||||
"text": "Plain text body",
|
||||
"html": "<p>HTML body</p>",
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "report.pdf",
|
||||
"contentType": "application/pdf",
|
||||
"contentBase64": "JVBERi0xLjQK..."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Status:** `201 Created`
|
||||
|
||||
**Request fields:**
|
||||
|
||||
| Field 字段 | Required 必填 | Description 说明 |
|
||||
|-------|----------|-------------|
|
||||
| `mailboxId` | Yes | Sending mailbox ID (must be owned by the token user) / 发信邮箱 ID(必须属于 Token 拥有者) |
|
||||
| `to` | Yes | Recipient addresses (at least one recipient across to/cc/bcc) / 收件人地址(to/cc/bcc 至少需有一个收件人) |
|
||||
| `cc` | No | CC addresses / 抄送地址 |
|
||||
| `bcc` | No | BCC addresses / 密送地址 |
|
||||
| `subject` | No | Message subject / 邮件主题 |
|
||||
| `text` | No | Plain text body / 纯文本正文 |
|
||||
| `html` | No | HTML body / HTML 正文 |
|
||||
| `attachments` | No | List of attachments (see below) / 附件列表(见下方) |
|
||||
|
||||
**Attachment fields:**
|
||||
|
||||
| Field 字段 | Description 说明 |
|
||||
|-------|-------------|
|
||||
| `filename` | Attachment file name / 附件文件名 |
|
||||
| `contentType` | MIME type, e.g. `application/pdf` / MIME 类型,如 `application/pdf` |
|
||||
| `contentBase64` | Base64-encoded file content / Base64 编码的文件内容 |
|
||||
|
||||
Total attachment size is limited by the sender's permission group (`maxAttachmentMb`, default 25 MB). Exceeding it returns `400`.
|
||||
|
||||
附件总大小受发信人所在权限组限制(`maxAttachmentMb`,默认 25 MB)。超出会返回 `400`。
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "mail_xxx",
|
||||
"queueId": "snd_xxx",
|
||||
"status": "queued",
|
||||
"messageId": "mail_xxx",
|
||||
"rfcMessageId": "<msg_xxx@example.com>",
|
||||
"mailboxId": "mbx_xxx",
|
||||
"mailboxAddress": "alice@example.com",
|
||||
"subject": "Hello",
|
||||
"recipients": ["bob@example.com"],
|
||||
"attemptCount": 0,
|
||||
"maxAttempts": 5,
|
||||
"createdAt": "2026-06-29T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Response fields:**
|
||||
|
||||
| Field 字段 | Description 说明 |
|
||||
|-------|-------------|
|
||||
| `id` | Send identifier; use it with `GET /api/open/v1/send/{id}` / 发信标识,可配合 `GET /api/open/v1/send/{id}` 使用 |
|
||||
| `queueId` | SMTP queue item id. Omitted when the message was only `accepted` / SMTP 队列项 ID;仅 `accepted` 时不返回 |
|
||||
| `status` | Delivery status, see values below / 投递状态,见下方取值 |
|
||||
| `messageId` | Internal stored message id / 内部存储的消息 ID |
|
||||
| `rfcMessageId` | RFC 5322 `Message-ID` header / RFC 5322 的 `Message-ID` 头 |
|
||||
| `mailboxAddress` | Sending mailbox address / 发信邮箱地址 |
|
||||
| `recipients` | Deduplicated recipients (to + cc + bcc) / 去重后的收件人(to + cc + bcc) |
|
||||
| `attemptCount` / `maxAttempts` | Delivery attempt counters (queue only) / 投递尝试次数(仅队列项返回) |
|
||||
| `nextAttemptAt` / `lastError` | Next retry time / last delivery error (present when applicable) / 下次重试时间 / 最近一次投递错误(在适用时返回) |
|
||||
| `updatedAt` / `deliveredAt` | Update / delivery timestamps (present when applicable) / 更新 / 投递时间戳(在适用时返回) |
|
||||
|
||||
When SMTP delivery is not configured, the message can be stored as accepted without a queue item:
|
||||
|
||||
如果没有配置 SMTP 投递,邮件可能只会进入 `accepted` 状态,不会产生 `queueId`。
|
||||
|
||||
`id` is always the stable stored send id (`mail_*`). `queueId` is the queue item (`snd_*`) and may be absent. A repeated request with the same `Idempotency-Key` and identical body returns the original send with `200` and `Idempotency-Replayed: true`; reusing the key with a different body returns `409`. Keys are retained for 24 hours.
|
||||
|
||||
`id` 始终是稳定的发送邮件 ID(`mail_*`);`queueId` 是队列项 ID(`snd_*`),可能不存在。相同 `Idempotency-Key` 与相同请求体重试时返回原发送结果、状态码 `200`,并带 `Idempotency-Replayed: true`;相同 key 配不同请求体返回 `409`。key 保留 24 小时。
|
||||
|
||||
Current status values:
|
||||
|
||||
当前状态取值:
|
||||
|
||||
- `accepted`: message was accepted and stored, but no SMTP queue item exists.
|
||||
- `queued`: queued for SMTP delivery.
|
||||
- `sending`: currently being delivered.
|
||||
- `relayed`: the configured upstream SMTP server accepted the message; this is not final recipient delivery.
|
||||
- `failed`: delivery failed and may be retried.
|
||||
- `canceled`: delivery was canceled.
|
||||
- `delivered`, `bounced`, `complained`, `rejected`, `deferred`: final per-recipient provider/DSN event.
|
||||
- `partial`: final events currently differ between recipients or only cover part of the recipient list.
|
||||
|
||||
<br>
|
||||
|
||||
- `accepted`:邮件已被接受并存储,但没有 SMTP 队列项。
|
||||
- `queued`:已进入 SMTP 投递队列。
|
||||
- `sending`:正在投递中。
|
||||
- `relayed`:配置的上游 SMTP 已接受邮件,但这不代表最终收件成功。
|
||||
- `failed`:投递失败,可能会重试。
|
||||
- `canceled`:投递已取消。
|
||||
- `delivered`、`bounced`、`complained`、`rejected`、`deferred`:每个收件人的最终供应商或 DSN 事件。
|
||||
- `partial`:不同收件人的最终状态不同,或当前只收到了部分收件人的事件。
|
||||
|
||||
**Error cases:**
|
||||
|
||||
| Status 状态码 | Cause 原因 |
|
||||
|--------|-------|
|
||||
| `400` | No recipients / invalid MIME / attachment too large / 无收件人、MIME 无效或附件过大 |
|
||||
| `403` | Sender address is not authorized / 发信地址未被授权 |
|
||||
| `404` | Mailbox not found or not owned by the token user / 邮箱不存在或不属于 Token 拥有者 |
|
||||
| `429` | SMTP send rate limit exceeded / 超过 SMTP 发信频率限制 |
|
||||
| `507` | Mailbox quota exceeded / 邮箱配额已满 |
|
||||
|
||||
Final delivery events are exposed in `recipientStatuses` and through `GET /api/open/v1/send/{id}/events`.
|
||||
|
||||
最终投递事件会出现在 `recipientStatuses`,完整时间线可通过 `GET /api/open/v1/send/{id}/events` 获取。
|
||||
|
||||
## Send Status
|
||||
|
||||
```http
|
||||
GET /api/open/v1/send/{id}
|
||||
Authorization: Bearer lq_xxx
|
||||
```
|
||||
|
||||
**Status:** `200 OK` or `404 Not Found`
|
||||
|
||||
`id` can be the value returned by `POST /api/open/v1/send`. If a queue item exists, it can also be the queue id.
|
||||
|
||||
`id` 可以使用发信接口返回的 `id`;如果存在队列项,也可以使用 `queueId`。
|
||||
|
||||
**Response:** Same shape as the `POST /api/open/v1/send` response. Only messages belonging to the token user's mailboxes are returned; otherwise `404`.
|
||||
|
||||
**响应:** 结构与 `POST /api/open/v1/send` 的响应相同。只会返回属于 Token 拥有者邮箱的邮件,否则返回 `404`。
|
||||
|
||||
## Received Messages
|
||||
|
||||
```http
|
||||
GET /api/open/v1/mailboxes/{id}/messages?folder=Inbox&limit=30&cursor=opaque&q=keyword
|
||||
Authorization: Bearer lq_xxx
|
||||
```
|
||||
|
||||
**Status:** `200 OK` or `404 Not Found`
|
||||
|
||||
Query parameters:
|
||||
|
||||
查询参数:
|
||||
|
||||
- `folder`: folder name. Defaults to `Inbox`; use `all` for all folders.
|
||||
- `limit`: page size, defaults to `30`, maximum `100`.
|
||||
- `cursor`: opaque stable cursor. Pass back `nextCursor` unchanged. Numeric offsets remain accepted for compatibility.
|
||||
- `q`: optional search keyword. Matches subject, from, to, snippet, and body text.
|
||||
|
||||
<br>
|
||||
|
||||
- `folder`:文件夹名称。默认为 `Inbox`;使用 `all` 表示所有文件夹。
|
||||
- `limit`:每页数量,默认 `30`,最大 `100`。
|
||||
- `cursor`:数字偏移量。把上一次响应中的 `nextCursor` 传回即可获取下一页。
|
||||
- `q`:可选搜索关键词。会匹配主题、发件人、收件人、摘要和正文。
|
||||
|
||||
Response:
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "mail_xxx",
|
||||
"mailboxId": "mbx_xxx",
|
||||
"folder": "Inbox",
|
||||
"messageId": "<message@example.com>",
|
||||
"subject": "Hello",
|
||||
"from": "sender@example.com",
|
||||
"to": ["alice@example.com"],
|
||||
"receivedAt": "2026-06-29T00:00:00Z",
|
||||
"snippet": "Preview text",
|
||||
"isRead": false,
|
||||
"hasAttachments": false
|
||||
}
|
||||
],
|
||||
"nextCursor": ""
|
||||
}
|
||||
```
|
||||
|
||||
`nextCursor` is empty when there are no more pages. Otherwise it contains the offset to pass as `cursor` for the next request.
|
||||
|
||||
当没有更多分页时,`nextCursor` 为空字符串;否则应将它原样作为下一次请求的 `cursor` 传入。
|
||||
|
||||
Users can only read messages from their own active mailboxes. Fetch message bodies and attachment metadata with `GET /api/open/v1/messages/{id}`; download an owned attachment with `GET /api/open/v1/attachments/{id}`.
|
||||
|
||||
用户只能读取自己拥有的 active 邮箱。
|
||||
|
||||
## Additional V1 Endpoints / 其他 V1 接口
|
||||
|
||||
- `GET /api/open/v1/send`: paginated send records.
|
||||
- `GET /api/open/v1/send/{id}/events`: queue audit and final delivery events.
|
||||
- `POST /api/open/v1/send/{id}/retry`: retry a failed queue item.
|
||||
- `POST /api/open/v1/send/{id}/cancel`: cancel a queued or failed item.
|
||||
- `POST /api/open/v1/mailboxes/{id}/password`: reset the owner user's password and all mailbox passwords owned by that user.
|
||||
- `GET /api/open/v1/domains/{id}/dns-records` and `POST .../dns-check`: DNS configuration and check.
|
||||
- `/api/open/v1/aliases`: alias CRUD.
|
||||
|
||||
Domain names and mailbox addresses are immutable. Renaming them requires a storage/identity migration and is intentionally not exposed as a normal update operation.
|
||||
|
||||
域名名称和邮箱地址不可直接修改。重命名需要迁移存储路径及身份信息,因此不作为普通更新操作开放。
|
||||
|
||||
## Delivery Event Webhook / 投递事件回调
|
||||
|
||||
Configure `LANQIN_DELIVERY_WEBHOOK_SECRET`, then post up to 100 events to `POST /api/open/v1/delivery-events`. This endpoint does not accept an API Token. Set the Unix timestamp in `X-LanQin-Timestamp`, compute `HMAC-SHA256(secret, timestamp + "." + rawBody)`, and send the lowercase hexadecimal digest as `X-LanQin-Signature: sha256=<digest>`. Timestamps outside five minutes are rejected. `(provider, event id)` is idempotent.
|
||||
|
||||
配置 `LANQIN_DELIVERY_WEBHOOK_SECRET` 后,可向 `POST /api/open/v1/delivery-events` 一次提交最多 100 条事件。该接口不接受 API Token。将 Unix 时间戳放入 `X-LanQin-Timestamp`,计算 `HMAC-SHA256(secret, timestamp + "." + 原始请求体)`,再以 `X-LanQin-Signature: sha256=<小写十六进制>` 发送。超过五分钟的时间戳会被拒绝;`(provider, event id)` 具备幂等性。
|
||||
|
||||
Accepted event statuses: `delivered`, `bounced`, `complained`, `rejected`, `deferred`. Every event must identify an existing send using `queueId`, `messageId`, or `rfcMessageId`, and its recipient must belong to that send.
|
||||
|
||||
## Outbound Status Webhook / 主动状态推送
|
||||
|
||||
Set `LANQIN_STATUS_WEBHOOK_URL` and `LANQIN_STATUS_WEBHOOK_SECRET` to receive status changes proactively. Events are persisted in a SQLite outbox before delivery. Non-2xx responses are retried with backoff up to 10 attempts. Delivered and retry-exhausted records are removed after 30 days.
|
||||
|
||||
设置 `LANQIN_STATUS_WEBHOOK_URL` 和 `LANQIN_STATUS_WEBHOOK_SECRET` 后,可主动接收状态变化。事件会先持久化到 SQLite outbox,非 2xx 响应会按退避策略重试,最多 10 次;已送达和重试耗尽的记录会在 30 天后清理。
|
||||
|
||||
Outbound requests include `X-LanQin-Webhook-Id`, `X-LanQin-Timestamp`, and `X-LanQin-Signature`. Signature calculation is the same HMAC-SHA256 construction used by the inbound delivery-event endpoint: `HMAC(secret, timestamp + "." + rawBody)`. Event types include `send.accepted`, `send.queued`, `send.retry`, `send.delivered` (upstream SMTP accepted), `send.failed`, `send.canceled`, and `delivery.<final-status>`.
|
||||
|
||||
出站请求包含 `X-LanQin-Webhook-Id`、`X-LanQin-Timestamp` 和 `X-LanQin-Signature`。签名算法与入站投递事件相同:`HMAC(secret, timestamp + "." + 原始请求体)`。事件类型包括 `send.accepted`、`send.queued`、`send.retry`、`send.delivered`(上游 SMTP 接受)、`send.failed`、`send.canceled` 和 `delivery.<最终状态>`。
|
||||
|
||||
The target must be a public HTTPS URL by default. Redirects, URL credentials, loopback, private, link-local, and unspecified addresses are rejected. `LANQIN_STATUS_WEBHOOK_ALLOW_PRIVATE_HOSTS=true` relaxes this for explicitly trusted private deployments and also permits HTTP.
|
||||
|
||||
目标地址默认必须是公网 HTTPS。重定向、URL 用户信息、loopback、私网、链路本地和未指定地址都会被拒绝。只有明确可信的私有部署才应设置 `LANQIN_STATUS_WEBHOOK_ALLOW_PRIVATE_HOSTS=true`;开启后也允许 HTTP。
|
||||
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "LanQin Email Open API",
|
||||
"version": "1.0.0",
|
||||
"description": "Versioned integration API. The unversioned /api/open routes are compatibility aliases for /api/open/v1."
|
||||
},
|
||||
"servers": [{ "url": "/api/open/v1" }],
|
||||
"security": [{ "bearerAuth": [] }],
|
||||
"paths": {
|
||||
"/domains": { "get": { "parameters": [{ "$ref": "#/components/parameters/Limit" }, { "$ref": "#/components/parameters/Cursor" }], "responses": { "200": { "description": "Paginated domains" } } }, "post": { "responses": { "201": { "description": "Domain created" } } } },
|
||||
"/domains/{id}": { "parameters": [{ "$ref": "#/components/parameters/ResourceId" }], "get": { "responses": { "200": { "description": "Domain" }, "404": { "$ref": "#/components/responses/NotFound" } } }, "post": { "responses": { "200": { "description": "Domain updated" } } }, "delete": { "responses": { "200": { "description": "Domain deleted" }, "404": { "$ref": "#/components/responses/NotFound" } } } },
|
||||
"/domains/{id}/dns-records": { "parameters": [{ "$ref": "#/components/parameters/ResourceId" }], "get": { "responses": { "200": { "description": "Required DNS records" }, "404": { "$ref": "#/components/responses/NotFound" } } } },
|
||||
"/domains/{id}/dns-check": { "parameters": [{ "$ref": "#/components/parameters/ResourceId" }], "post": { "responses": { "200": { "description": "DNS check result" } } } },
|
||||
"/mailboxes": { "get": { "parameters": [{ "$ref": "#/components/parameters/Limit" }, { "$ref": "#/components/parameters/Cursor" }], "responses": { "200": { "description": "Paginated mailboxes" } } }, "post": { "responses": { "201": { "description": "Mailbox created" } } } },
|
||||
"/mailboxes/{id}": { "parameters": [{ "$ref": "#/components/parameters/ResourceId" }], "get": { "responses": { "200": { "description": "Mailbox" }, "404": { "$ref": "#/components/responses/NotFound" } } }, "post": { "responses": { "200": { "description": "Mailbox updated" } } }, "delete": { "responses": { "200": { "description": "Mailbox deleted" }, "404": { "$ref": "#/components/responses/NotFound" } } } },
|
||||
"/mailboxes/{id}/password": { "parameters": [{ "$ref": "#/components/parameters/ResourceId" }], "post": { "responses": { "200": { "description": "Owner and mailbox passwords reset" } } } },
|
||||
"/mailboxes/{id}/messages": { "parameters": [{ "$ref": "#/components/parameters/ResourceId" }, { "$ref": "#/components/parameters/Limit" }, { "$ref": "#/components/parameters/Cursor" }], "get": { "responses": { "200": { "description": "Paginated messages" } } } },
|
||||
"/messages/{id}": { "parameters": [{ "$ref": "#/components/parameters/ResourceId" }], "get": { "responses": { "200": { "description": "Message detail" }, "404": { "$ref": "#/components/responses/NotFound" } } } },
|
||||
"/attachments/{id}": { "parameters": [{ "$ref": "#/components/parameters/ResourceId" }], "get": { "responses": { "200": { "description": "Attachment bytes" }, "404": { "$ref": "#/components/responses/NotFound" } } } },
|
||||
"/send": {
|
||||
"get": { "parameters": [{ "$ref": "#/components/parameters/Limit" }, { "$ref": "#/components/parameters/Cursor" }], "responses": { "200": { "description": "Paginated sends" } } },
|
||||
"post": {
|
||||
"parameters": [{ "$ref": "#/components/parameters/IdempotencyKey" }],
|
||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SendRequest" } } } },
|
||||
"responses": { "200": { "description": "Idempotent replay", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SendStatus" } } } }, "201": { "description": "Queued", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SendStatus" } } } }, "409": { "$ref": "#/components/responses/Conflict" } }
|
||||
}
|
||||
},
|
||||
"/send/{id}": { "parameters": [{ "$ref": "#/components/parameters/ResourceId" }], "get": { "responses": { "200": { "description": "Send status", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SendStatus" } } } }, "404": { "$ref": "#/components/responses/NotFound" } } } },
|
||||
"/send/{id}/events": { "parameters": [{ "$ref": "#/components/parameters/ResourceId" }], "get": { "responses": { "200": { "description": "Queue audit and delivery events" }, "404": { "$ref": "#/components/responses/NotFound" } } } },
|
||||
"/send/{id}/retry": { "parameters": [{ "$ref": "#/components/parameters/ResourceId" }], "post": { "responses": { "200": { "description": "Send requeued" }, "409": { "$ref": "#/components/responses/Conflict" } } } },
|
||||
"/send/{id}/cancel": { "parameters": [{ "$ref": "#/components/parameters/ResourceId" }], "post": { "responses": { "200": { "description": "Send canceled" }, "409": { "$ref": "#/components/responses/Conflict" } } } },
|
||||
"/aliases": { "get": { "parameters": [{ "$ref": "#/components/parameters/Limit" }, { "$ref": "#/components/parameters/Cursor" }], "responses": { "200": { "description": "Paginated aliases" } } }, "post": { "responses": { "201": { "description": "Alias created" } } } },
|
||||
"/aliases/{id}": { "parameters": [{ "$ref": "#/components/parameters/ResourceId" }], "get": { "responses": { "200": { "description": "Alias" }, "404": { "$ref": "#/components/responses/NotFound" } } }, "post": { "responses": { "200": { "description": "Alias updated" } } }, "delete": { "responses": { "200": { "description": "Alias deleted" }, "404": { "$ref": "#/components/responses/NotFound" } } } },
|
||||
"/delivery-events": {
|
||||
"post": {
|
||||
"security": [],
|
||||
"description": "HMAC-SHA256 signed delivery event callback. Sign timestamp + '.' + raw body.",
|
||||
"parameters": [
|
||||
{ "name": "X-LanQin-Timestamp", "in": "header", "required": true, "schema": { "type": "string" } },
|
||||
{ "name": "X-LanQin-Signature", "in": "header", "required": true, "schema": { "type": "string", "pattern": "^sha256=[a-f0-9]{64}$" } }
|
||||
],
|
||||
"requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "required": ["events"], "properties": { "events": { "type": "array", "minItems": 1, "maxItems": 100, "items": { "$ref": "#/components/schemas/DeliveryEventInput" } } } } } } },
|
||||
"responses": { "200": { "description": "Events stored or deduplicated" }, "401": { "$ref": "#/components/responses/Unauthorized" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"securitySchemes": { "bearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "lq_* API Token" } },
|
||||
"parameters": {
|
||||
"IdempotencyKey": { "name": "Idempotency-Key", "in": "header", "required": false, "description": "Up to 128 characters; retained for 24 hours.", "schema": { "type": "string", "maxLength": 128 } },
|
||||
"ResourceId": { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } },
|
||||
"Limit": { "name": "limit", "in": "query", "required": false, "schema": { "type": "integer", "minimum": 1, "maximum": 100 } },
|
||||
"Cursor": { "name": "cursor", "in": "query", "required": false, "schema": { "type": "string" } }
|
||||
},
|
||||
"responses": {
|
||||
"Unauthorized": { "description": "Missing or invalid authentication" },
|
||||
"NotFound": { "description": "Resource not found or not owned by the token user" },
|
||||
"Conflict": { "description": "Idempotency key conflict or concurrent state change" }
|
||||
},
|
||||
"schemas": {
|
||||
"SendRequest": {
|
||||
"type": "object", "required": ["mailboxId"],
|
||||
"properties": {
|
||||
"mailboxId": { "type": "string" }, "from": { "type": "string" }, "fromName": { "type": "string" },
|
||||
"to": { "type": "array", "items": { "type": "string", "format": "email" } },
|
||||
"cc": { "type": "array", "items": { "type": "string", "format": "email" } },
|
||||
"bcc": { "type": "array", "items": { "type": "string", "format": "email" } },
|
||||
"subject": { "type": "string" }, "text": { "type": "string" }, "html": { "type": "string" },
|
||||
"attachments": { "type": "array", "items": { "$ref": "#/components/schemas/AttachmentInput" } }
|
||||
}
|
||||
},
|
||||
"AttachmentInput": { "type": "object", "required": ["filename", "contentBase64"], "properties": { "filename": { "type": "string" }, "contentType": { "type": "string" }, "contentBase64": { "type": "string", "contentEncoding": "base64" } } },
|
||||
"SendStatus": {
|
||||
"type": "object", "required": ["id", "status", "messageId", "rfcMessageId", "mailboxId", "createdAt"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "description": "Stable sent message id (mail_*)." },
|
||||
"queueId": { "type": "string", "description": "Internal queue id (snd_*)." },
|
||||
"status": { "type": "string", "enum": ["accepted", "queued", "sending", "relayed", "failed", "canceled", "delivered", "bounced", "complained", "rejected", "deferred", "partial"] },
|
||||
"queueStatus": { "type": "string", "enum": ["queued", "sending", "delivered", "failed", "canceled"] },
|
||||
"messageId": { "type": "string" }, "rfcMessageId": { "type": "string" }, "mailboxId": { "type": "string" },
|
||||
"mailboxAddress": { "type": "string" }, "subject": { "type": "string" }, "recipients": { "type": "array", "items": { "type": "string" } },
|
||||
"recipientStatuses": { "type": "array", "items": { "$ref": "#/components/schemas/RecipientStatus" } },
|
||||
"createdAt": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
},
|
||||
"RecipientStatus": { "type": "object", "required": ["recipient", "status", "occurredAt"], "properties": { "recipient": { "type": "string" }, "status": { "type": "string" }, "reason": { "type": "string" }, "provider": { "type": "string" }, "occurredAt": { "type": "string", "format": "date-time" } } },
|
||||
"DeliveryEventInput": {
|
||||
"type": "object", "required": ["id", "provider", "recipient", "status", "occurredAt"],
|
||||
"properties": {
|
||||
"id": { "type": "string" }, "provider": { "type": "string" }, "queueId": { "type": "string" }, "messageId": { "type": "string" }, "rfcMessageId": { "type": "string" },
|
||||
"recipient": { "type": "string", "format": "email" }, "status": { "type": "string", "enum": ["delivered", "bounced", "complained", "rejected", "deferred"] },
|
||||
"reason": { "type": "string" }, "occurredAt": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
},
|
||||
"Error": { "type": "object", "required": ["error"], "properties": { "error": { "type": "string" } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 220 KiB |
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 194 KiB |
@@ -0,0 +1,267 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
REPOSITORY="zxyszx/NewSzxcn-Email"
|
||||
RAW_BASE="https://raw.githubusercontent.com/${REPOSITORY}/main"
|
||||
INSTALL_DIR="${LANQIN_INSTALL_DIR:-/opt/newszxcn-email}"
|
||||
COMMAND="${1:-install}"
|
||||
ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image"
|
||||
|
||||
log() { printf '\033[1;34m[NewSzxcn]\033[0m %s\n' "$*"; }
|
||||
success() { printf '\033[1;32m[完成]\033[0m %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33m[提示]\033[0m %s\n' "$*"; }
|
||||
fail() { printf '\033[1;31m[错误]\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
NewSzxcn Email 管理命令
|
||||
|
||||
用法:newszxcn-email <command>
|
||||
|
||||
install 首次安装或修复部署
|
||||
update 备份数据库并更新到最新版
|
||||
status 查看容器与健康状态
|
||||
logs 持续查看运行日志
|
||||
rollback 回滚到上次命令行更新前的镜像
|
||||
uninstall 停止并移除容器,保留邮件与配置
|
||||
EOF
|
||||
}
|
||||
|
||||
require_root() {
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
fail "请使用 root 运行,例如:curl -fsSL ${RAW_BASE}/install.sh | sudo bash"
|
||||
fi
|
||||
}
|
||||
|
||||
require_curl() {
|
||||
command -v curl >/dev/null 2>&1 || fail "系统缺少 curl,请先安装 curl。"
|
||||
}
|
||||
|
||||
ensure_docker() {
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
log "未检测到 Docker,正在安装 Docker Engine..."
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
fi
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl enable --now docker >/dev/null 2>&1 || true
|
||||
fi
|
||||
docker compose version >/dev/null 2>&1 || fail "需要 Docker Compose v2。"
|
||||
}
|
||||
|
||||
compose() {
|
||||
docker compose --project-directory "${INSTALL_DIR}" -f "${INSTALL_DIR}/docker-compose.yml" "$@"
|
||||
}
|
||||
|
||||
script_dir() {
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd
|
||||
}
|
||||
|
||||
refresh_assets() {
|
||||
local source_dir
|
||||
source_dir="$(script_dir || true)"
|
||||
install -d -m 0755 "${INSTALL_DIR}"
|
||||
if [[ -f "${source_dir}/deploy/docker-compose.yml" && -f "${source_dir}/deploy/.env.example" ]]; then
|
||||
install -m 0644 "${source_dir}/deploy/docker-compose.yml" "${INSTALL_DIR}/docker-compose.yml"
|
||||
install -m 0644 "${source_dir}/deploy/.env.example" "${INSTALL_DIR}/.env.example"
|
||||
install -m 0755 "${source_dir}/install.sh" /usr/local/bin/newszxcn-email
|
||||
else
|
||||
curl -fsSL "${RAW_BASE}/deploy/docker-compose.yml" -o "${INSTALL_DIR}/docker-compose.yml"
|
||||
curl -fsSL "${RAW_BASE}/deploy/.env.example" -o "${INSTALL_DIR}/.env.example"
|
||||
curl -fsSL "${RAW_BASE}/install.sh" -o /usr/local/bin/newszxcn-email.new
|
||||
chmod 0755 /usr/local/bin/newszxcn-email.new
|
||||
mv /usr/local/bin/newszxcn-email.new /usr/local/bin/newszxcn-email
|
||||
fi
|
||||
}
|
||||
|
||||
random_secret() {
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
openssl rand -hex 24
|
||||
else
|
||||
od -An -N24 -tx1 /dev/urandom | tr -d ' \n'
|
||||
fi
|
||||
}
|
||||
|
||||
set_env() {
|
||||
local key="$1" value="$2" file="${INSTALL_DIR}/.env" tmp
|
||||
tmp="$(mktemp)"
|
||||
awk -v key="${key}" -v value="${value}" '
|
||||
BEGIN { found=0 }
|
||||
$0 ~ "^" key "=" { print key "=" value; found=1; next }
|
||||
{ print }
|
||||
END { if (!found) print key "=" value }
|
||||
' "${file}" > "${tmp}"
|
||||
cat "${tmp}" > "${file}"
|
||||
rm -f "${tmp}"
|
||||
}
|
||||
|
||||
env_value() {
|
||||
local key="$1"
|
||||
sed -n "s/^${key}=//p" "${INSTALL_DIR}/.env" | tail -n 1
|
||||
}
|
||||
|
||||
prompt_value() {
|
||||
local variable="$1" prompt="$2" default_value="$3" secret="${4:-false}"
|
||||
local value="${!variable:-}"
|
||||
if [[ -z "${value}" && -r /dev/tty ]]; then
|
||||
if [[ "${secret}" == "true" ]]; then
|
||||
read -r -s -p "${prompt}${default_value:+ [自动生成]}: " value </dev/tty
|
||||
printf '\n' >/dev/tty
|
||||
else
|
||||
read -r -p "${prompt}${default_value:+ [${default_value}]}: " value </dev/tty
|
||||
fi
|
||||
fi
|
||||
value="${value:-${default_value}}"
|
||||
printf '%s' "${value}"
|
||||
}
|
||||
|
||||
configure_first_install() {
|
||||
if [[ -f "${INSTALL_DIR}/.env" ]]; then
|
||||
return
|
||||
fi
|
||||
install -m 0600 "${INSTALL_DIR}/.env.example" "${INSTALL_DIR}/.env"
|
||||
|
||||
local hostname public_url admin_username admin_password update_token
|
||||
hostname="$(prompt_value LANQIN_PUBLIC_HOSTNAME "邮件服务器域名,例如 mail.example.com" "")"
|
||||
[[ "${hostname}" =~ ^[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]] || fail "邮件服务器域名格式不正确。"
|
||||
public_url="$(prompt_value LANQIN_PUBLIC_BASE_URL "Webmail 访问地址" "https://${hostname}")"
|
||||
admin_username="$(prompt_value LANQIN_ADMIN_USERNAME "初始管理员用户名" "admin")"
|
||||
[[ "${admin_username}" =~ ^[A-Za-z0-9][A-Za-z0-9._%+-]{1,79}$ ]] || fail "管理员用户名格式不正确,需为 2-80 位且不能包含 @。"
|
||||
admin_password="$(prompt_value LANQIN_ADMIN_PASSWORD "初始管理员密码" "" true)"
|
||||
if [[ -z "${admin_password}" ]]; then
|
||||
admin_password="$(random_secret)"
|
||||
warn "已自动生成管理员密码:${admin_password}"
|
||||
fi
|
||||
[[ ${#admin_password} -ge 10 ]] || fail "管理员密码至少需要 10 个字符。"
|
||||
update_token="$(random_secret)"
|
||||
|
||||
set_env LANQIN_PUBLIC_HOSTNAME "${hostname}"
|
||||
set_env LANQIN_PUBLIC_BASE_URL "${public_url}"
|
||||
set_env LANQIN_ADMIN_USERNAME "${admin_username}"
|
||||
set_env LANQIN_ADMIN_PASSWORD "${admin_password}"
|
||||
set_env LANQIN_UPDATE_TOKEN "${update_token}"
|
||||
chmod 0600 "${INSTALL_DIR}/.env"
|
||||
}
|
||||
|
||||
ensure_update_token() {
|
||||
local token
|
||||
token="$(env_value LANQIN_UPDATE_TOKEN || true)"
|
||||
if [[ -z "${token}" ]]; then
|
||||
set_env LANQIN_UPDATE_TOKEN "$(random_secret)"
|
||||
chmod 0600 "${INSTALL_DIR}/.env"
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_directories() {
|
||||
install -d -m 0755 "${INSTALL_DIR}/data" "${INSTALL_DIR}/mail" "${INSTALL_DIR}/dkim"
|
||||
install -d -m 0700 "${INSTALL_DIR}/data/backups"
|
||||
}
|
||||
|
||||
wait_for_health() {
|
||||
local attempts="${1:-60}" bind port
|
||||
bind="$(env_value LANQIN_HTTP_BIND || true)"
|
||||
bind="${bind:-80}"
|
||||
port="${bind##*:}"
|
||||
for ((i=1; i<=attempts; i++)); do
|
||||
if curl -fsS --max-time 3 "http://127.0.0.1:${port}/healthz" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
backup_database() {
|
||||
local timestamp
|
||||
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
if [[ -n "$(compose ps -q lanqin-email 2>/dev/null || true)" ]]; then
|
||||
compose exec -T lanqin-email sh -c "mkdir -p /data/backups && sqlite3 /data/lanqin.db \".backup '/data/backups/cli-update-${timestamp}.db'\"" >/dev/null
|
||||
log "数据库已备份到 data/backups/cli-update-${timestamp}.db"
|
||||
fi
|
||||
}
|
||||
|
||||
remember_current_image() {
|
||||
local container_id image_id rollback_tag
|
||||
container_id="$(compose ps -q lanqin-email 2>/dev/null || true)"
|
||||
[[ -n "${container_id}" ]] || return 0
|
||||
image_id="$(docker inspect --format '{{.Image}}' "${container_id}")"
|
||||
rollback_tag="newszxcn-email:rollback-$(date -u +%Y%m%d%H%M%S)"
|
||||
docker image tag "${image_id}" "${rollback_tag}"
|
||||
printf '%s\n' "${rollback_tag}" > "${ROLLBACK_FILE}"
|
||||
}
|
||||
|
||||
do_install() {
|
||||
ensure_docker
|
||||
refresh_assets
|
||||
configure_first_install
|
||||
ensure_update_token
|
||||
prepare_directories
|
||||
log "正在拉取 NewSzxcn Email 镜像..."
|
||||
compose pull
|
||||
log "正在启动服务..."
|
||||
compose up -d --remove-orphans
|
||||
wait_for_health 90 || fail "服务未能通过健康检查,请执行 newszxcn-email logs 查看日志。"
|
||||
success "安装完成:$(env_value LANQIN_PUBLIC_BASE_URL)"
|
||||
warn "下一步请配置 MX、SPF、DKIM、DMARC,并确认 25/465/587/993/995 端口可访问。"
|
||||
}
|
||||
|
||||
do_update() {
|
||||
[[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装,请先执行 install。"
|
||||
ensure_docker
|
||||
refresh_assets
|
||||
ensure_update_token
|
||||
backup_database
|
||||
remember_current_image
|
||||
log "正在拉取最新版..."
|
||||
compose pull
|
||||
if ! compose up -d --remove-orphans; then
|
||||
warn "新版本容器启动失败,正在自动回滚。"
|
||||
do_rollback
|
||||
fail "更新失败,已回滚到原镜像。"
|
||||
fi
|
||||
if ! wait_for_health 90; then
|
||||
warn "新版本健康检查失败,正在自动回滚。"
|
||||
do_rollback
|
||||
fail "更新失败,已回滚到原镜像。"
|
||||
fi
|
||||
success "系统已更新,配置、邮件和数据库均已保留。"
|
||||
}
|
||||
|
||||
do_rollback() {
|
||||
[[ -f "${ROLLBACK_FILE}" ]] || fail "没有可用的回滚镜像。"
|
||||
local image
|
||||
image="$(tr -d '\r\n' < "${ROLLBACK_FILE}")"
|
||||
docker image inspect "${image}" >/dev/null 2>&1 || fail "回滚镜像已不存在:${image}"
|
||||
log "正在回滚到 ${image}..."
|
||||
LANQIN_IMAGE="${image}" compose up -d --no-deps --force-recreate lanqin-email
|
||||
wait_for_health 90 || fail "回滚后服务仍未通过健康检查,请查看日志。"
|
||||
success "已回滚到 ${image}。"
|
||||
}
|
||||
|
||||
do_status() {
|
||||
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。"
|
||||
compose ps
|
||||
if wait_for_health 1; then
|
||||
success "Web 与 API 健康检查正常。"
|
||||
else
|
||||
fail "健康检查失败。"
|
||||
fi
|
||||
}
|
||||
|
||||
do_uninstall() {
|
||||
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || fail "尚未安装。"
|
||||
compose down --remove-orphans
|
||||
success "容器已移除,${INSTALL_DIR} 中的配置、邮件和数据库仍然保留。"
|
||||
}
|
||||
|
||||
require_root
|
||||
require_curl
|
||||
case "${COMMAND}" in
|
||||
install) do_install ;;
|
||||
update) do_update ;;
|
||||
status) ensure_docker; do_status ;;
|
||||
logs) ensure_docker; compose logs -f --tail=200 lanqin-email updater ;;
|
||||
rollback) ensure_docker; do_rollback ;;
|
||||
uninstall) ensure_docker; do_uninstall ;;
|
||||
help|-h|--help) usage ;;
|
||||
*) usage; fail "未知命令:${COMMAND}" ;;
|
||||
esac
|
||||