Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 497aefb903 | |||
| 1af4b7250e | |||
| 1ce75ef241 | |||
| 99b8b7dee8 | |||
| 697ed236cc | |||
| b55c298ef4 | |||
| 800d482c77 | |||
| ccd5c4efd5 | |||
| da888234b9 | |||
| 2f7494e5e6 | |||
| 1350611908 | |||
| 9a489992ed | |||
| 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 | |||
| ed68abb00d | |||
| b8d4553036 | |||
| 587bd77f6a | |||
| ef7af15010 | |||
| bce259ca8a | |||
| 707f11687d | |||
| cc75382869 | |||
| efcdc691de | |||
| 081cfad02d | |||
| 39f5249008 | |||
| 11734bf119 | |||
| 2558aa96ed | |||
| 98e7190512 | |||
| 4ab7815886 | |||
| 361daf8693 | |||
| d2a762a426 | |||
| 7799d5d5b2 | |||
| 07054ca342 | |||
| 84657f91f9 | |||
| a5f5adf752 | |||
| 947e3ad248 |
@@ -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"
|
||||
@@ -0,0 +1,33 @@
|
||||
## 本次更新
|
||||
|
||||
### 一键部署与运维
|
||||
|
||||
- 新增统一管理菜单,支持一键安装、更新、修复、查看状态、重启服务、查看日志、配置 SSL、版本回滚和卸载。
|
||||
- 空白服务器进入安装流程,检测到已有安装时可直接更新或修复,减少重复操作。
|
||||
- 重新安装前自动完整备份旧安装目录,避免误覆盖现有配置和数据。
|
||||
- 更新前自动备份 SQLite 数据库,更新失败时支持回滚。
|
||||
- 完善交互式安装引导,可选择防火墙策略,并依次设置邮件服务器域名、管理员用户名、管理员密码以及 Nginx 和 SSL。
|
||||
- 管理员用户名默认使用 `admin`;管理员密码可回车自动生成 12 位随机密码,也可输入不少于 6 位的自定义密码。
|
||||
|
||||
### 邮箱与账号体验
|
||||
|
||||
- 修复管理员登录名 `admin` 被误显示为邮箱地址的问题。
|
||||
- 邮箱列表加载时显示“加载邮箱...”,账号尚未创建邮箱时显示“未创建邮箱”。
|
||||
- 统一空邮箱状态下的操作按钮为“前往邮箱管理”。
|
||||
- 管理员及具备邮箱管理权限的用户跳转至后台邮箱管理,普通用户跳转至个人中心的邮箱申请页面。
|
||||
|
||||
### 自助申请邮箱
|
||||
|
||||
- 在未创建邮箱页面明确标注开关位置:`后台管理 -> 系统设置 -> 邮件 -> 账号自助申请邮箱`。
|
||||
- 区分“未开启自助申请”和“未选择开放域名”两种状态,并给出对应处理提示。
|
||||
- 管理员可通过“前往设置”直接进入后台邮件设置页。
|
||||
- 普通用户无法自行申请时会提示联系管理员处理。
|
||||
- 修复通过链接进入系统设置时未自动切换到“邮件”设置标签的问题。
|
||||
- 补充简体中文、繁体中文和英文界面文案。
|
||||
|
||||
### 兼容与验证
|
||||
|
||||
- 更新和修复流程保留现有端口、反向代理、邮件数据、证书及数据库配置。
|
||||
- 已通过 ShellCheck、安装脚本测试、Go 后端测试和前端生产构建检查。
|
||||
|
||||
**完整更新日志**:[v1.2.4...v1.2.5](https://github.com/zxyszx/NewSzxcn-Email/compare/v1.2.4...v1.2.5)
|
||||
@@ -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:
|
||||
@@ -29,6 +40,14 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check installer
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y shellcheck
|
||||
bash -n install.sh tests/install_test.sh
|
||||
shellcheck -x install.sh tests/install_test.sh
|
||||
bash tests/install_test.sh
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
@@ -42,7 +61,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:
|
||||
@@ -27,6 +27,14 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check installer
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y shellcheck
|
||||
bash -n install.sh tests/install_test.sh
|
||||
shellcheck -x install.sh tests/install_test.sh
|
||||
bash tests/install_test.sh
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
@@ -35,12 +43,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 +81,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,51 +103,60 @@ 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
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GHCR_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Prepare image name
|
||||
id: image
|
||||
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 +169,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 +177,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 }}
|
||||
@@ -189,29 +212,35 @@ jobs:
|
||||
image_base="${image_base,,}"
|
||||
current_commit="$(git rev-list -n 1 "${tag}")"
|
||||
previous_tag="$(git describe --tags --abbrev=0 "${current_commit}^" 2>/dev/null || true)"
|
||||
generate_args=(-f "tag_name=${tag}")
|
||||
if [[ -n "${previous_tag}" ]]; then
|
||||
generate_args+=(-f "previous_tag_name=${previous_tag}")
|
||||
fi
|
||||
version_notes=".github/release-notes/${tag}.md"
|
||||
|
||||
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 [[ -f "${version_notes}" ]]; then
|
||||
cp "${version_notes}" generated-release-notes.md
|
||||
else
|
||||
generate_args=(-f "tag_name=${tag}")
|
||||
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
|
||||
generate_args+=(-f "previous_tag_name=${previous_tag}")
|
||||
fi
|
||||
|
||||
if ! gh api -X POST "repos/${repo}/releases/generate-notes" "${generate_args[@]}" --jq '.body' > generated-release-notes.md; then
|
||||
{
|
||||
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
|
||||
fi
|
||||
|
||||
cat > release-notes.md <<EOF
|
||||
# LanQin Email ${tag}
|
||||
|
||||
自建邮箱 Webmail 全栈方案,包含 Web、API、Postfix、Dovecot、Rspamd 等组件。
|
||||
|
||||
## 注意
|
||||
@@ -248,7 +277,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,66 @@
|
||||
# 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.
|
||||
|
||||
During first installation it prompts for the firewall policy, mail hostname, administrator username/password, and Web mode. Automatic mode configures host Nginx and obtains a Let's Encrypt certificate with the official `acme.sh` client. The default username is `admin`; an empty password generates 12 characters, while a custom password requires at least 6 characters.
|
||||
|
||||
## 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 restart
|
||||
sudo newszxcn-email certificate
|
||||
sudo newszxcn-email uninstall
|
||||
```
|
||||
|
||||
The uninstall command removes the containers and generated Nginx configuration while preserving certificates, 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,206 +1,180 @@
|
||||
# LanQin Email
|
||||
# NewSzxcn-Email
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后台的开源邮箱系统。
|
||||
|
||||
LanQin Email 是一个自建邮箱 Webmail 全栈方案:前端使用 React + TypeScript + shadcn/ui,后端使用 Go + SQLite,部署时可用单容器集成 API、Web、Nginx、Postfix、Dovecot、Rspamd。
|
||||
[](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)
|
||||
|
||||
## 功能特性
|
||||
[版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
|
||||
|
||||
- **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 |
|
||||
| 收信规则 | 多条件匹配、移动、标记、删除、转发、规则排序与应用到已有邮件 |
|
||||
| 管理后台 | 账号、权限配额、域名、邮箱、转发、全部邮件、发送队列、系统设置 |
|
||||
| 邮件服务 | 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
|
||||
```
|
||||
|
||||
## 环境要求
|
||||
已使用 `root` 登录时,也可以使用:
|
||||
|
||||
### 开发环境
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
|
||||
```
|
||||
|
||||
- Go 1.25+
|
||||
- Node.js 20+
|
||||
- pnpm 10.28.2(可通过 corepack 启用)
|
||||
脚本会先显示统一管理菜单。空白服务器默认选择安装,并进入防火墙、邮件域名、管理员
|
||||
账号和 Web 部署方式的引导;检测到已有安装时默认选择安全更新。选择重新安装会先将
|
||||
`/opt/newszxcn-email` 完整改名备份,再进入首次安装流程。更新会先备份数据库,并在
|
||||
启动失败时自动回滚。
|
||||
|
||||
### 部署环境
|
||||
脚本会自动完成:
|
||||
|
||||
- Docker Engine
|
||||
- Docker Compose v2
|
||||
- 可解析的邮件域名,以及可用的 25 / 465 / 587 / 993 / 995 等端口
|
||||
- 安装或检查 Docker Engine 与 Docker Compose v2
|
||||
- 首先选择仅开放必要端口、保留现有防火墙或开放全部端口
|
||||
- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位
|
||||
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
|
||||
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
|
||||
- 创建 `/opt/newszxcn-email` 持久化目录
|
||||
- 拉取 GHCR 镜像并启动邮件服务
|
||||
- 生成后台在线更新所需的内部鉴权令牌
|
||||
- 等待 Web 与 API 健康检查通过
|
||||
|
||||
> 公网收发邮件还需要正确配置 MX、SPF、DKIM、DMARC,并确认云厂商未封禁 SMTP 端口。
|
||||
安装完成后访问配置的 `LANQIN_PUBLIC_BASE_URL`。首次登录后,在后台添加邮件域名并按照 DNS 检测页配置记录。
|
||||
|
||||
## 快速开始
|
||||
> 一键安装不会替你修改 DNS,也不能绕过云厂商对 25 端口的限制。公网收信前必须确认 25 端口可入站,公网发信前需确认 25 端口可出站。
|
||||
|
||||
### 本地开发
|
||||
## 更新与回滚
|
||||
|
||||
后端:
|
||||
### 后台页面更新
|
||||
|
||||
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
|
||||
|
||||
更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。
|
||||
|
||||
### 命令行更新
|
||||
|
||||
```bash
|
||||
sudo newszxcn-email update
|
||||
```
|
||||
|
||||
命令行更新会保留当前镜像、备份数据库并执行健康检查。需要回滚时运行:
|
||||
|
||||
```bash
|
||||
sudo newszxcn-email rollback
|
||||
```
|
||||
|
||||
常用运维命令:
|
||||
|
||||
```bash
|
||||
sudo newszxcn-email status
|
||||
sudo newszxcn-email logs
|
||||
sudo newszxcn-email restart
|
||||
sudo newszxcn-email certificate
|
||||
sudo newszxcn-email uninstall
|
||||
```
|
||||
|
||||
`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/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 私钥
|
||||
`-- certs/ # Web、SMTP、IMAP、POP3 共用的 TLS 证书
|
||||
```
|
||||
|
||||
升级和重建容器不会删除这些目录。备份时应同时保存 `data`、`mail`、`dkim`、`certs` 与 `.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 check
|
||||
```
|
||||
|
||||
常用命令:
|
||||
## 开源协议
|
||||
|
||||
```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` |
|
||||
|
||||
## 架构
|
||||
|
||||
```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` 的证书。
|
||||
|
||||
## 开发与验证
|
||||
|
||||
```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)
|
||||
[MIT](LICENSE)
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
# NewSzxcn-Email
|
||||
|
||||
NewSzxcn-Email 是一个可自建、可管理、带完整 Webmail 与管理后台的开源邮箱系统。
|
||||
|
||||
[版本发布](https://github.com/zxyszx/NewSzxcn-Email/releases) · [部署文档](deploy/README.md) · [English](README.en.md)
|
||||
|
||||
## 主要功能
|
||||
|
||||
| 模块 | 能力 |
|
||||
| --- | --- |
|
||||
| Webmail | 收发邮件、草稿、附件、搜索、星标、标签、自定义文件夹、稍后提醒、导入与导出 |
|
||||
| 邮箱管理 | 多邮箱切换、邮箱申请、暂停收信、账号级与邮箱级转发、外部 IMAP |
|
||||
| 收信规则 | 多条件匹配、移动、标记、删除、转发、规则排序与应用到已有邮件 |
|
||||
| 管理后台 | 账号、权限配额、域名、邮箱、转发、全部邮件、发送队列、系统设置 |
|
||||
| 邮件服务 | Postfix、Dovecot、Rspamd、DKIM、IMAP、POP3、SMTP Submission |
|
||||
| 安全 | 2FA、Turnstile、权限组、API Token、转发邮箱验证、SSRF 防护 |
|
||||
| 运维 | 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
|
||||
```
|
||||
|
||||
已使用 `root` 登录时,也可以使用:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://raw.githubusercontent.com/zxyszx/NewSzxcn-Email/main/install.sh)
|
||||
```
|
||||
|
||||
脚本会自动完成:
|
||||
|
||||
- 安装或检查 Docker Engine 与 Docker Compose v2
|
||||
- 首先选择仅开放必要端口、保留现有防火墙或开放全部端口
|
||||
- 询问邮件域名、管理员用户名和密码;默认用户名为 `admin`,回车自动生成 12 位密码,自定义密码最少 6 位
|
||||
- 选择自动 Nginx + SSL、宝塔/已有 Nginx 反代或 HTTP 测试模式
|
||||
- 自动模式使用官方 `acme.sh` 签发和续期证书,不会强制停止占用 80 端口的进程
|
||||
- 创建 `/opt/newszxcn-email` 持久化目录
|
||||
- 拉取 GHCR 镜像并启动邮件服务
|
||||
- 生成后台在线更新所需的内部鉴权令牌
|
||||
- 等待 Web 与 API 健康检查通过
|
||||
|
||||
安装完成后访问配置的 `LANQIN_PUBLIC_BASE_URL`。首次登录后,在后台添加邮件域名并按照 DNS 检测页配置记录。
|
||||
|
||||
> 一键安装不会替你修改 DNS,也不能绕过云厂商对 25 端口的限制。公网收信前必须确认 25 端口可入站,公网发信前需确认 25 端口可出站。
|
||||
|
||||
## 更新与回滚
|
||||
|
||||
### 后台页面更新
|
||||
|
||||
超级管理员可点击后台侧栏中的版本号,查看当前版本、最新版本与更新日志。点击“立即更新”后,系统会先在线备份 SQLite 数据库,再拉取新镜像并重启;页面会等待服务恢复后自动刷新。
|
||||
|
||||
更新服务只在 Docker 内部网络开放,不映射公网端口。普通用户和普通后台权限组无法执行系统更新。
|
||||
|
||||
### 命令行更新
|
||||
|
||||
```bash
|
||||
sudo newszxcn-email update
|
||||
```
|
||||
|
||||
命令行更新会保留当前镜像、备份数据库并执行健康检查。需要回滚时运行:
|
||||
|
||||
```bash
|
||||
sudo newszxcn-email rollback
|
||||
```
|
||||
|
||||
常用运维命令:
|
||||
|
||||
```bash
|
||||
sudo newszxcn-email status
|
||||
sudo newszxcn-email logs
|
||||
sudo newszxcn-email restart
|
||||
sudo newszxcn-email certificate
|
||||
sudo newszxcn-email uninstall
|
||||
```
|
||||
|
||||
`uninstall` 会移除容器和自动生成的 Nginx 配置,但不删除 `/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 私钥
|
||||
└── certs/ # Web、SMTP、IMAP、POP3 共用的 TLS 证书
|
||||
```
|
||||
|
||||
升级和重建容器不会删除这些目录。备份时应同时保存 `data`、`mail`、`dkim`、`certs` 与 `.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 run ./cmd/server
|
||||
```
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
pnpm install
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
提交前建议运行:
|
||||
|
||||
```bash
|
||||
cd apps/api && go test ./...
|
||||
cd apps/web && pnpm run build
|
||||
```
|
||||
|
||||
## 开源协议
|
||||
|
||||
[MIT](LICENSE)
|
||||
@@ -3,9 +3,14 @@ module lanqin-email-api
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
|
||||
github.com/emersion/go-smtp v0.24.0
|
||||
github.com/go-chi/chi/v5 v5.3.0
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
golang.org/x/crypto v0.26.0
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/text v0.38.0
|
||||
modernc.org/sqlite v1.31.1
|
||||
)
|
||||
@@ -13,16 +18,14 @@ require (
|
||||
require (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
github.com/emersion/go-smtp v0.24.0 // indirect
|
||||
github.com/emersion/go-message v0.18.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/net v0.26.0 // indirect
|
||||
golang.org/x/sys v0.23.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
|
||||
@@ -2,12 +2,16 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
|
||||
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
|
||||
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
|
||||
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
|
||||
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -26,21 +30,54 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
|
||||
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||
|
||||
@@ -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,8 +135,16 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusForbidden, "only administrators can create administrator users")
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
||||
mailboxLimitOverride, err := normalizeMailboxLimitOverride(req.MailboxLimitOverride)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if role == "admin" {
|
||||
mailboxLimitOverride = nil
|
||||
}
|
||||
if !hasMinimumPasswordLength(req.Password) {
|
||||
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||
return
|
||||
}
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
@@ -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
|
||||
}
|
||||
@@ -296,8 +344,8 @@ func (a *App) handleResetUserPassword(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
||||
if !hasMinimumPasswordLength(req.Password) {
|
||||
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
@@ -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)
|
||||
@@ -513,8 +562,8 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
||||
if !hasMinimumPasswordLength(req.Password) {
|
||||
badRequest(w, errors.New("password must be at least 6 characters"))
|
||||
return
|
||||
}
|
||||
role := req.Role
|
||||
@@ -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 {
|
||||
@@ -1149,6 +1212,14 @@ func (a *App) ensureFolder(ctx context.Context, mailboxID, folder string) (strin
|
||||
}
|
||||
role := strings.ToLower(folder)
|
||||
id = newID("fld")
|
||||
_, err := a.db.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,uid_validity,uid_next,highest_modseq,created_at) VALUES(?,?,?,?,?,?,?,?)`, id, mailboxID, folder, role, a.newUIDValidity(), 1, 1, a.now().UTC().Format(time.RFC3339Nano))
|
||||
sortOrder := 0
|
||||
if !isSystemFolderName(folder) {
|
||||
var err error
|
||||
sortOrder, err = a.nextCustomFolderSortOrder(ctx, mailboxID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,sort_order,uid_validity,uid_next,highest_modseq,created_at) VALUES(?,?,?,?,?,?,?,?,?)`, id, mailboxID, folder, role, sortOrder, a.newUIDValidity(), 1, 1, a.now().UTC().Format(time.RFC3339Nano))
|
||||
return id, err
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -23,12 +24,33 @@ import (
|
||||
|
||||
type App struct {
|
||||
cfg Config
|
||||
cfgMu sync.RWMutex
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
policy *HTMLPolicy
|
||||
workerCancel context.CancelFunc
|
||||
workerWG sync.WaitGroup
|
||||
maildirHealth *maildirSyncHealthTracker
|
||||
externalIMAP externalIMAPClientFactory
|
||||
}
|
||||
|
||||
func (a *App) config() Config {
|
||||
a.cfgMu.RLock()
|
||||
defer a.cfgMu.RUnlock()
|
||||
return a.cfg
|
||||
}
|
||||
|
||||
func (a *App) setConfig(cfg Config) {
|
||||
a.cfgMu.Lock()
|
||||
a.cfg = cfg
|
||||
a.cfgMu.Unlock()
|
||||
}
|
||||
|
||||
func (a *App) updateConfig(update func(*Config)) {
|
||||
a.cfgMu.Lock()
|
||||
defer a.cfgMu.Unlock()
|
||||
update(&a.cfg)
|
||||
}
|
||||
|
||||
func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
@@ -49,6 +71,7 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
db.SetMaxOpenConns(1)
|
||||
|
||||
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker()}
|
||||
a.externalIMAP = a
|
||||
if err := a.configureSQLite(context.Background()); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
@@ -71,15 +94,25 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
}
|
||||
workerCtx, cancel := context.WithCancel(context.Background())
|
||||
a.workerCancel = cancel
|
||||
go a.scheduledSendWorker(workerCtx)
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) != "" {
|
||||
go a.maildirWorker(workerCtx)
|
||||
a.startWorker(func() { a.scheduledSendWorker(workerCtx) })
|
||||
if strings.TrimSpace(a.config().MaildirRoot) != "" {
|
||||
a.startWorker(func() { a.maildirWorker(workerCtx) })
|
||||
}
|
||||
go a.sendQueueWorker(workerCtx)
|
||||
go a.smtpEventsCleanupWorker(workerCtx)
|
||||
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
|
||||
}
|
||||
|
||||
func (a *App) startWorker(fn func()) {
|
||||
a.workerWG.Add(1)
|
||||
go func() {
|
||||
defer a.workerWG.Done()
|
||||
fn()
|
||||
}()
|
||||
}
|
||||
|
||||
func (a *App) Close() error {
|
||||
if a == nil || a.db == nil {
|
||||
return nil
|
||||
@@ -87,6 +120,7 @@ func (a *App) Close() error {
|
||||
if a.workerCancel != nil {
|
||||
a.workerCancel()
|
||||
}
|
||||
a.workerWG.Wait()
|
||||
return a.db.Close()
|
||||
}
|
||||
|
||||
@@ -108,12 +142,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
|
||||
@@ -123,7 +159,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
|
||||
@@ -149,6 +185,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,
|
||||
@@ -188,6 +248,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,
|
||||
@@ -202,6 +291,7 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
uid_validity INTEGER NOT NULL DEFAULT 0,
|
||||
uid_next INTEGER NOT NULL DEFAULT 1,
|
||||
highest_modseq INTEGER NOT NULL DEFAULT 1,
|
||||
@@ -299,6 +389,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,
|
||||
@@ -343,6 +472,70 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_pop3_events_user_created ON pop3_events(user_id, created_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS external_imap_accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
tls_mode TEXT NOT NULL CHECK(tls_mode IN ('tls','starttls','plain')),
|
||||
username TEXT NOT NULL,
|
||||
password_ciphertext TEXT NOT NULL,
|
||||
auth_mode TEXT NOT NULL DEFAULT 'password' CHECK(auth_mode IN ('password','oauth2')),
|
||||
oauth_provider TEXT NOT NULL DEFAULT '',
|
||||
oauth_email TEXT NOT NULL DEFAULT '',
|
||||
oauth_access_token_ciphertext TEXT NOT NULL DEFAULT '',
|
||||
oauth_refresh_token_ciphertext TEXT NOT NULL DEFAULT '',
|
||||
oauth_expiry TEXT,
|
||||
storage_mode TEXT NOT NULL DEFAULT 'local' CHECK(storage_mode IN ('local','remote')),
|
||||
sync_read_state INTEGER NOT NULL DEFAULT 1,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_sync_at TEXT,
|
||||
last_status TEXT NOT NULL DEFAULT 'idle',
|
||||
last_error TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_external_imap_accounts_user_mailbox ON external_imap_accounts(user_id, mailbox_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_external_imap_accounts_enabled ON external_imap_accounts(enabled, updated_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS external_imap_folder_states (
|
||||
account_id TEXT NOT NULL REFERENCES external_imap_accounts(id) ON DELETE CASCADE,
|
||||
remote_folder TEXT NOT NULL,
|
||||
local_folder_id TEXT NOT NULL DEFAULT '',
|
||||
uid_validity INTEGER NOT NULL DEFAULT 0,
|
||||
last_uid INTEGER NOT NULL DEFAULT 0,
|
||||
last_sync_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(account_id, remote_folder)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS external_imap_messages (
|
||||
account_id TEXT NOT NULL REFERENCES external_imap_accounts(id) ON DELETE CASCADE,
|
||||
remote_folder TEXT NOT NULL,
|
||||
uid_validity INTEGER NOT NULL,
|
||||
uid INTEGER NOT NULL,
|
||||
message_id TEXT NOT NULL DEFAULT '',
|
||||
local_message_id TEXT NOT NULL DEFAULT '',
|
||||
is_read INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(account_id, remote_folder, uid_validity, uid)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_external_imap_messages_local ON external_imap_messages(local_message_id) WHERE local_message_id <> ''`,
|
||||
`CREATE TABLE IF NOT EXISTS external_imap_sync_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL REFERENCES external_imap_accounts(id) ON DELETE CASCADE,
|
||||
folder TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL,
|
||||
imported INTEGER NOT NULL DEFAULT 0,
|
||||
skipped INTEGER NOT NULL DEFAULT 0,
|
||||
failed INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_external_imap_sync_runs_account_started ON external_imap_sync_runs(account_id, started_at DESC)`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS contacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -427,9 +620,18 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
if err := a.migrateMessageAuthentication(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -445,12 +647,130 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
if err := a.migrateIMAPMetadata(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateFolderSortOrder(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
@@ -492,6 +812,47 @@ func (a *App) migrateMessageAuthentication(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) rebuildHTMLOnlyMessageSnippets(ctx context.Context) error {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id,body_html,snippet FROM messages WHERE trim(body_text)='' AND body_html<>''`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
type update struct {
|
||||
id string
|
||||
snippet string
|
||||
}
|
||||
updates := []update{}
|
||||
for rows.Next() {
|
||||
var id, bodyHTML, current string
|
||||
if err := rows.Scan(&id, &bodyHTML, ¤t); err != nil {
|
||||
return err
|
||||
}
|
||||
next := snippetFrom("", bodyHTML)
|
||||
if next != current {
|
||||
updates = append(updates, update{id: id, snippet: next})
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
tx, err := a.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, item := range updates {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE messages SET snippet=?,updated_at=? WHERE id=?`, item.snippet, now, item.id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (a *App) migrateSendQueueMessageID(ctx context.Context) error {
|
||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(send_queue)`)
|
||||
if err != nil {
|
||||
@@ -574,7 +935,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
|
||||
}
|
||||
|
||||
@@ -583,7 +944,7 @@ func (a *App) migratePermissionGroupLimits(ctx context.Context) error {
|
||||
// Current seed() creates mailboxes with display_name = admin email, so this migration
|
||||
// has no effect on fresh installs. It only cleans up after upgrades from pre-v1.0 schema.
|
||||
func (a *App) migrateLegacyBootstrapMailbox(ctx context.Context) error {
|
||||
adminEmail := normalizeEmail(a.cfg.AdminEmail)
|
||||
adminEmail := normalizeEmail(a.config().AdminEmail)
|
||||
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
|
||||
return nil
|
||||
}
|
||||
@@ -772,6 +1133,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 {
|
||||
@@ -922,6 +1406,7 @@ func messageIndexes() []string {
|
||||
}
|
||||
|
||||
func (a *App) seed(ctx context.Context) error {
|
||||
cfg := a.config()
|
||||
var count int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
|
||||
return err
|
||||
@@ -930,7 +1415,7 @@ func (a *App) seed(ctx context.Context) error {
|
||||
return a.ensureConfiguredAdminSuperAdmin(ctx)
|
||||
}
|
||||
|
||||
adminPassword := a.cfg.AdminPassword
|
||||
adminPassword := cfg.AdminPassword
|
||||
if adminPassword == "" {
|
||||
buf := make([]byte, 16)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
@@ -945,12 +1430,25 @@ func (a *App) seed(ctx context.Context) error {
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
userID := newID("usr")
|
||||
adminEmail := normalizeEmail(a.cfg.AdminEmail)
|
||||
if strings.TrimSpace(cfg.AdminUsername) != "" {
|
||||
adminUsername, err := cleanUsername(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(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)
|
||||
@@ -985,7 +1483,13 @@ func (a *App) seed(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (a *App) ensureConfiguredAdminSuperAdmin(ctx context.Context) error {
|
||||
adminEmail := normalizeEmail(a.cfg.AdminEmail)
|
||||
cfg := a.config()
|
||||
if adminUsername := normalizeLoginName(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(cfg.AdminEmail)
|
||||
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
|
||||
return nil
|
||||
}
|
||||
@@ -1054,6 +1558,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")
|
||||
@@ -1065,7 +1585,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
|
||||
@@ -1073,46 +1593,38 @@ 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
|
||||
}
|
||||
for _, f := range defaultFolderDefs() {
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,uid_validity,uid_next,highest_modseq,created_at) VALUES(?,?,?,?,?,?,?,?)`, newID("fld"), id, f.name, f.role, a.newUIDValidity(), 1, 1, now)
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,sort_order,uid_validity,uid_next,highest_modseq,created_at) VALUES(?,?,?,?,?,?,?,?,?)`, newID("fld"), id, f.name, f.role, 0, a.newUIDValidity(), 1, 1, now)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
|
||||
cfg := a.config()
|
||||
folderID, err := a.ensureFolder(ctx, mailboxID, "Inbox")
|
||||
if err != nil {
|
||||
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 {
|
||||
rendered := renderMailTemplate(tpl, templateRenderData{
|
||||
To: a.cfg.AdminEmail,
|
||||
To: cfg.AdminEmail,
|
||||
From: "system@lanqin.local",
|
||||
PublicHostname: a.cfg.PublicHostname,
|
||||
PublicBaseURL: a.cfg.PublicBaseURL,
|
||||
PublicHostname: cfg.PublicHostname,
|
||||
PublicBaseURL: cfg.PublicBaseURL,
|
||||
Time: now,
|
||||
})
|
||||
subject, bodyText, bodyHTML = rendered.Subject, rendered.Text, rendered.HTML
|
||||
@@ -1124,8 +1636,8 @@ 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",
|
||||
To: []string{a.cfg.AdminEmail},
|
||||
FromName: "NewSzxcn 邮箱",
|
||||
To: []string{cfg.AdminEmail},
|
||||
SentAt: now,
|
||||
ReceivedAt: now,
|
||||
Snippet: snippetFrom(bodyText, bodyHTML),
|
||||
|
||||
@@ -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,17 +50,27 @@ 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 {
|
||||
if a.config().TwoFactorEnabled && user.TwoFactorEnabled {
|
||||
challengeToken, err := a.createLoginChallenge(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "验证码生成失败,请稍后重试")
|
||||
@@ -76,7 +87,7 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.cfg.OpenRegistration {
|
||||
if !a.config().OpenRegistration {
|
||||
respondError(w, http.StatusForbidden, "当前未开放注册")
|
||||
return
|
||||
}
|
||||
@@ -101,8 +112,8 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("邮箱地址无效"))
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
badRequest(w, errors.New("密码至少需要 8 个字符"))
|
||||
if !hasMinimumPasswordLength(req.Password) {
|
||||
badRequest(w, errors.New("密码至少需要 6 个字符"))
|
||||
return
|
||||
}
|
||||
displayName := strings.TrimSpace(req.DisplayName)
|
||||
@@ -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
|
||||
@@ -165,7 +176,7 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if mailboxDomainID != "" && mailboxLocalPart != "" {
|
||||
// Check reserved prefixes
|
||||
reserved := map[string]bool{}
|
||||
for _, item := range parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes) {
|
||||
for _, item := range parseReservedPrefixes(a.config().ReservedMailboxPrefixes) {
|
||||
reserved[item] = true
|
||||
}
|
||||
if reserved[mailboxLocalPart] {
|
||||
@@ -181,10 +192,10 @@ func (a *App) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if cookie, err := r.Cookie(a.cfg.CookieName); err == nil {
|
||||
if cookie, err := r.Cookie(a.config().CookieName); err == nil {
|
||||
_, _ = a.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE token_hash=?`, hashToken(cookie.Value))
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: a.cfg.CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
|
||||
http.SetCookie(w, &http.Cookie{Name: a.config().CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode})
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
@@ -234,8 +245,8 @@ func (a *App) handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if len(req.NewPassword) < 8 {
|
||||
badRequest(w, errors.New("新密码至少需要 8 个字符"))
|
||||
if !hasMinimumPasswordLength(req.NewPassword) {
|
||||
badRequest(w, errors.New("新密码至少需要 6 个字符"))
|
||||
return
|
||||
}
|
||||
row := a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=?`, user.ID)
|
||||
|
||||
@@ -8,77 +8,115 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Addr string
|
||||
DBPath string
|
||||
DataDir string
|
||||
CookieName string
|
||||
SessionTTLHours int
|
||||
AdminEmail string
|
||||
AdminPassword string
|
||||
PublicHostname string
|
||||
PublicBaseURL string
|
||||
SMTPHost string
|
||||
SMTPPort string
|
||||
SMTPUsername string
|
||||
SMTPPassword string
|
||||
SMTPRequireTLS bool
|
||||
SubmissionAddr string
|
||||
SubmissionTLSAddr string
|
||||
SubmissionMaxMessageMB int
|
||||
TLSCertFile string
|
||||
TLSKeyFile string
|
||||
MaildirRoot string
|
||||
MaildirScanSeconds int
|
||||
AllowInsecureHTTP bool
|
||||
OpenRegistration bool
|
||||
TwoFactorEnabled bool
|
||||
TurnstileEnabled bool
|
||||
TurnstileSiteKey string
|
||||
TurnstileSecretKey string
|
||||
CatchAllEnabled bool
|
||||
MailAutoRefresh bool
|
||||
MailRefreshSeconds int
|
||||
UserMailboxApplyEnabled bool
|
||||
UserMailboxDomainIDs string
|
||||
ReservedMailboxPrefixes string
|
||||
Addr string
|
||||
AppVersion string
|
||||
DBPath string
|
||||
DataDir string
|
||||
CookieName string
|
||||
SessionTTLHours int
|
||||
AdminUsername string
|
||||
AdminEmail string
|
||||
AdminPassword string
|
||||
PublicHostname string
|
||||
PublicBaseURL string
|
||||
SMTPHost string
|
||||
SMTPPort string
|
||||
SMTPUsername string
|
||||
SMTPPassword string
|
||||
SMTPRequireTLS bool
|
||||
SubmissionAddr string
|
||||
SubmissionTLSAddr string
|
||||
SubmissionMaxMessageMB int
|
||||
TLSCertFile string
|
||||
TLSKeyFile string
|
||||
MaildirRoot string
|
||||
MaildirScanSeconds int
|
||||
AllowInsecureHTTP bool
|
||||
OpenRegistration bool
|
||||
TwoFactorEnabled bool
|
||||
TurnstileEnabled bool
|
||||
TurnstileSiteKey string
|
||||
TurnstileSecretKey string
|
||||
CatchAllEnabled bool
|
||||
MailAutoRefresh bool
|
||||
MailRefreshSeconds int
|
||||
UserMailboxApplyEnabled bool
|
||||
UserMailboxDomainIDs string
|
||||
ReservedMailboxPrefixes string
|
||||
ExternalIMAPEnabled bool
|
||||
ExternalIMAPSecretKey string
|
||||
ExternalIMAPSyncSeconds int
|
||||
ExternalIMAPAllowPrivateHosts bool
|
||||
ExternalIMAPGmailClientID string
|
||||
ExternalIMAPGmailClientSecret string
|
||||
ExternalIMAPOutlookClientID string
|
||||
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"),
|
||||
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),
|
||||
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
|
||||
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""),
|
||||
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
|
||||
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
|
||||
SMTPHost: getenv("LANQIN_SMTP_HOST", ""),
|
||||
SMTPPort: getenv("LANQIN_SMTP_PORT", "25"),
|
||||
SMTPUsername: getenv("LANQIN_SMTP_USERNAME", ""),
|
||||
SMTPPassword: getenv("LANQIN_SMTP_PASSWORD", ""),
|
||||
SMTPRequireTLS: getenvBool("LANQIN_SMTP_REQUIRE_TLS", false),
|
||||
SubmissionAddr: getenv("LANQIN_SUBMISSION_ADDR", ""),
|
||||
SubmissionTLSAddr: getenv("LANQIN_SUBMISSION_TLS_ADDR", ""),
|
||||
SubmissionMaxMessageMB: getenvInt("LANQIN_SUBMISSION_MAX_MESSAGE_MB", 35),
|
||||
TLSCertFile: getenv("LANQIN_TLS_CERT_FILE", ""),
|
||||
TLSKeyFile: getenv("LANQIN_TLS_KEY_FILE", ""),
|
||||
MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""),
|
||||
MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30),
|
||||
AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true),
|
||||
OpenRegistration: getenvBool("LANQIN_OPEN_REGISTRATION", false),
|
||||
TwoFactorEnabled: getenvBool("LANQIN_TWO_FACTOR_ENABLED", false),
|
||||
TurnstileEnabled: getenvBool("LANQIN_TURNSTILE_ENABLED", false),
|
||||
TurnstileSiteKey: getenv("LANQIN_TURNSTILE_SITE_KEY", ""),
|
||||
TurnstileSecretKey: getenv("LANQIN_TURNSTILE_SECRET_KEY", ""),
|
||||
CatchAllEnabled: getenvBool("LANQIN_CATCH_ALL_ENABLED", false),
|
||||
MailAutoRefresh: getenvBool("LANQIN_MAIL_AUTO_REFRESH", true),
|
||||
MailRefreshSeconds: getenvInt("LANQIN_MAIL_REFRESH_SECONDS", 30),
|
||||
UserMailboxApplyEnabled: getenvBool("LANQIN_USER_MAILBOX_APPLY_ENABLED", false),
|
||||
UserMailboxDomainIDs: getenv("LANQIN_USER_MAILBOX_DOMAIN_IDS", ""),
|
||||
ReservedMailboxPrefixes: getenv("LANQIN_RESERVED_MAILBOX_PREFIXES", "admin,postmaster,abuse,hostmaster,webmaster,root,security,noreply,no-reply,mailer-daemon"),
|
||||
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"),
|
||||
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
|
||||
SMTPHost: getenv("LANQIN_SMTP_HOST", ""),
|
||||
SMTPPort: getenv("LANQIN_SMTP_PORT", "25"),
|
||||
SMTPUsername: getenv("LANQIN_SMTP_USERNAME", ""),
|
||||
SMTPPassword: getenv("LANQIN_SMTP_PASSWORD", ""),
|
||||
SMTPRequireTLS: getenvBool("LANQIN_SMTP_REQUIRE_TLS", false),
|
||||
SubmissionAddr: getenv("LANQIN_SUBMISSION_ADDR", ""),
|
||||
SubmissionTLSAddr: getenv("LANQIN_SUBMISSION_TLS_ADDR", ""),
|
||||
SubmissionMaxMessageMB: getenvInt("LANQIN_SUBMISSION_MAX_MESSAGE_MB", 35),
|
||||
TLSCertFile: getenv("LANQIN_TLS_CERT_FILE", ""),
|
||||
TLSKeyFile: getenv("LANQIN_TLS_KEY_FILE", ""),
|
||||
MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""),
|
||||
MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30),
|
||||
AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true),
|
||||
OpenRegistration: getenvBool("LANQIN_OPEN_REGISTRATION", false),
|
||||
TwoFactorEnabled: getenvBool("LANQIN_TWO_FACTOR_ENABLED", false),
|
||||
TurnstileEnabled: getenvBool("LANQIN_TURNSTILE_ENABLED", false),
|
||||
TurnstileSiteKey: getenv("LANQIN_TURNSTILE_SITE_KEY", ""),
|
||||
TurnstileSecretKey: getenv("LANQIN_TURNSTILE_SECRET_KEY", ""),
|
||||
CatchAllEnabled: getenvBool("LANQIN_CATCH_ALL_ENABLED", false),
|
||||
MailAutoRefresh: getenvBool("LANQIN_MAIL_AUTO_REFRESH", true),
|
||||
MailRefreshSeconds: getenvInt("LANQIN_MAIL_REFRESH_SECONDS", 30),
|
||||
UserMailboxApplyEnabled: getenvBool("LANQIN_USER_MAILBOX_APPLY_ENABLED", false),
|
||||
UserMailboxDomainIDs: getenv("LANQIN_USER_MAILBOX_DOMAIN_IDS", ""),
|
||||
ReservedMailboxPrefixes: getenv("LANQIN_RESERVED_MAILBOX_PREFIXES", "admin,postmaster,abuse,hostmaster,webmaster,root,security,noreply,no-reply,mailer-daemon"),
|
||||
ExternalIMAPEnabled: getenvBool("LANQIN_EXTERNAL_IMAP_ENABLED", false),
|
||||
ExternalIMAPSecretKey: getenv("LANQIN_EXTERNAL_IMAP_SECRET_KEY", ""),
|
||||
ExternalIMAPSyncSeconds: getenvInt("LANQIN_EXTERNAL_IMAP_SYNC_SECONDS", 300),
|
||||
ExternalIMAPAllowPrivateHosts: getenvBool("LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS", false),
|
||||
ExternalIMAPGmailClientID: getenv("LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_ID", ""),
|
||||
ExternalIMAPGmailClientSecret: getenv("LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET", ""),
|
||||
ExternalIMAPOutlookClientID: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID", ""),
|
||||
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", ""),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ func (a *App) handleDNSCheck(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) dnsRecordsFor(d *Domain) []DNSRecord {
|
||||
name := strings.TrimSuffix(d.Name, ".")
|
||||
host := strings.TrimSuffix(a.cfg.PublicHostname, ".") + "."
|
||||
host := strings.TrimSuffix(a.config().PublicHostname, ".") + "."
|
||||
return []DNSRecord{
|
||||
{Type: "MX", Name: name, Value: fmt.Sprintf("10 %s", host), TTL: 300},
|
||||
{Type: "TXT", Name: name, Value: "v=spf1 mx -all", TTL: 300},
|
||||
@@ -58,7 +58,7 @@ func (a *App) checkDNS(ctx context.Context, d *Domain) DNSCheckResult {
|
||||
for _, item := range mx {
|
||||
entry := fmt.Sprintf("%d %s", item.Pref, strings.TrimSuffix(item.Host, "."))
|
||||
found = append(found, entry)
|
||||
if strings.EqualFold(strings.TrimSuffix(item.Host, "."), strings.TrimSuffix(a.cfg.PublicHostname, ".")) {
|
||||
if strings.EqualFold(strings.TrimSuffix(item.Host, "."), strings.TrimSuffix(a.config().PublicHostname, ".")) {
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.config().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.config().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.config().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.config().PublicBaseURL), "/")
|
||||
if base == "" {
|
||||
base = "https://" + strings.Trim(strings.TrimSpace(a.config().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
|
||||
}
|
||||
@@ -44,6 +44,38 @@ func (a *App) migrateIMAPMetadata(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) migrateFolderSortOrder(ctx context.Context) error {
|
||||
if err := a.ensureTableColumn(ctx, "folders", "sort_order", `ALTER TABLE folders ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0`); err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id FROM folders WHERE lower(name) NOT IN ('inbox','sent','drafts','archive','spam','trash') ORDER BY mailbox_id, created_at, name, id`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var folderIDs []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
folderIDs = append(folderIDs, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
rows.Close()
|
||||
order := customFolderDefaultSortOrderBase + 1
|
||||
for _, id := range folderIDs {
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE folders SET sort_order=? WHERE id=? AND sort_order=0`, order, id); err != nil {
|
||||
return err
|
||||
}
|
||||
order++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) ensureTableColumn(ctx context.Context, table, column, alterSQL string) error {
|
||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
|
||||
if err != nil {
|
||||
@@ -200,25 +232,6 @@ func (a *App) bumpFolderModSeqWithDB(ctx context.Context, db dbExecutor, folderI
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func (a *App) touchMessageIMAPModSeq(ctx context.Context, messageID string) error {
|
||||
var folderID sql.NullString
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, messageID).Scan(&folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
if !folderID.Valid || folderID.String == "" {
|
||||
return nil
|
||||
}
|
||||
modSeq, err := a.bumpFolderModSeq(ctx, folderID.String)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if modSeq == 0 {
|
||||
return nil
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET imap_modseq=? WHERE id=?`, modSeq, messageID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) updateMessageModSeq(ctx context.Context, messageID string, folderID string) (int64, error) {
|
||||
if folderID == "" {
|
||||
var dbFolderID sql.NullString
|
||||
|
||||
@@ -22,6 +22,12 @@ import (
|
||||
// mailMessagesPageSize is the max number of messages returned per page in mail listing.
|
||||
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"`
|
||||
@@ -53,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")
|
||||
@@ -67,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
|
||||
}
|
||||
@@ -79,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")
|
||||
@@ -87,10 +101,18 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,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,
|
||||
f.uid_validity,f.uid_next,f.highest_modseq
|
||||
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
||||
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
|
||||
WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role
|
||||
ORDER BY CASE f.role WHEN 'inbox' THEN 1 WHEN 'sent' THEN 2 WHEN 'drafts' THEN 3 WHEN 'archive' THEN 4 WHEN 'spam' THEN 5 WHEN 'trash' THEN 6 ELSE 99 END, f.name`, mb.ID)
|
||||
WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
||||
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 f.sort_order
|
||||
END, f.created_at,f.name`, mb.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load folders")
|
||||
return
|
||||
@@ -99,7 +121,7 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||
items := []MailFolder{}
|
||||
for rows.Next() {
|
||||
var f MailFolder
|
||||
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
||||
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
|
||||
}
|
||||
@@ -108,7 +130,293 @@ 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 {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
FolderIDs []string `json:"folderIds"`
|
||||
Folders []struct {
|
||||
ID string `json:"id"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
} `json:"folders"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
if len(req.Folders) == 0 {
|
||||
for i, id := range req.FolderIDs {
|
||||
id = strings.TrimSpace(id)
|
||||
req.Folders = append(req.Folders, struct {
|
||||
ID string `json:"id"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
}{ID: id, SortOrder: customFolderDefaultSortOrderBase + i + 1})
|
||||
}
|
||||
}
|
||||
for i := range req.Folders {
|
||||
req.Folders[i].ID = strings.TrimSpace(req.Folders[i].ID)
|
||||
if req.Folders[i].ID == "" || req.Folders[i].SortOrder <= 0 || seen[req.Folders[i].ID] {
|
||||
badRequest(w, errors.New("invalid folder order"))
|
||||
return
|
||||
}
|
||||
seen[req.Folders[i].ID] = true
|
||||
}
|
||||
if len(req.Folders) == 0 {
|
||||
badRequest(w, errors.New("folderIds is required"))
|
||||
return
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,name FROM folders WHERE mailbox_id=?`, mb.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load folders")
|
||||
return
|
||||
}
|
||||
customIDs := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var id, name string
|
||||
if err := rows.Scan(&id, &name); err != nil {
|
||||
rows.Close()
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
||||
return
|
||||
}
|
||||
if !isSystemFolderName(name) {
|
||||
customIDs[id] = true
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan folders")
|
||||
return
|
||||
}
|
||||
rows.Close()
|
||||
if len(customIDs) != len(req.Folders) {
|
||||
badRequest(w, errors.New("invalid folder order"))
|
||||
return
|
||||
}
|
||||
for _, item := range req.Folders {
|
||||
if !customIDs[item.ID] {
|
||||
badRequest(w, errors.New("invalid folder order"))
|
||||
return
|
||||
}
|
||||
}
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to reorder folders")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, item := range req.Folders {
|
||||
res, err := tx.ExecContext(r.Context(), `UPDATE folders SET sort_order=? WHERE id=? AND mailbox_id=?`, item.SortOrder, item.ID, mb.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to reorder folders")
|
||||
return
|
||||
}
|
||||
if affected, err := res.RowsAffected(); err != nil || affected != 1 {
|
||||
badRequest(w, errors.New("invalid folder order"))
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to reorder folders")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
|
||||
mb, err := a.mailboxForCurrentUser(r)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
name, err := normalizeCustomFolderName(req.Name)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if isSystemFolderName(name) {
|
||||
badRequest(w, errors.New("system folder already exists"))
|
||||
return
|
||||
}
|
||||
folderID, err := a.ensureCustomFolder(r.Context(), mb.ID, name)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to create folder")
|
||||
return
|
||||
}
|
||||
folder, err := a.folderByID(r.Context(), folderID, mb.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load folder")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, folder)
|
||||
}
|
||||
|
||||
func (a *App) handleDeleteMailFolder(w http.ResponseWriter, r *http.Request) {
|
||||
mb, err := a.mailboxForCurrentUser(r)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
folderID := strings.TrimSpace(chi.URLParam(r, "id"))
|
||||
if folderID == "" {
|
||||
badRequest(w, errors.New("folder id is required"))
|
||||
return
|
||||
}
|
||||
var folderName string
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT name FROM folders WHERE id=? AND mailbox_id=?`, folderID, mb.ID).Scan(&folderName); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
respondError(w, http.StatusNotFound, "folder not found")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to load folder")
|
||||
return
|
||||
}
|
||||
if isSystemFolderName(folderName) {
|
||||
badRequest(w, errors.New("system folders cannot be deleted"))
|
||||
return
|
||||
}
|
||||
inboxID, err := a.ensureFolder(r.Context(), mb.ID, "Inbox")
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load inbox")
|
||||
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 delete folder")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
rows, err := tx.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? ORDER BY received_at,id`, mb.ID, folderID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load folder messages")
|
||||
return
|
||||
}
|
||||
var messageIDs []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan folder messages")
|
||||
return
|
||||
}
|
||||
messageIDs = append(messageIDs, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan folder messages")
|
||||
return
|
||||
}
|
||||
rows.Close()
|
||||
for _, messageID := range messageIDs {
|
||||
meta, err := a.nextIMAPMetadata(r.Context(), tx, inboxID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to allocate message uid")
|
||||
return
|
||||
}
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE messages SET folder_id=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, inboxID, meta.UID, meta.ModSeq, now, messageID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to move folder messages")
|
||||
return
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(r.Context(), `DELETE FROM folders WHERE id=? AND mailbox_id=?`, folderID, mb.ID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete folder")
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete folder")
|
||||
return
|
||||
}
|
||||
_, _ = a.bumpFolderModSeq(r.Context(), inboxID)
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "moved": len(messageIDs)})
|
||||
}
|
||||
|
||||
func (a *App) ensureCustomFolder(ctx context.Context, mailboxID, name string) (string, error) {
|
||||
return a.ensureFolder(ctx, mailboxID, name)
|
||||
}
|
||||
|
||||
func (a *App) nextCustomFolderSortOrder(ctx context.Context, mailboxID string) (int, error) {
|
||||
var maxOrder int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(sort_order),0) FROM folders WHERE mailbox_id=? AND lower(name) NOT IN ('inbox','sent','drafts','archive','spam','trash')`, mailboxID).Scan(&maxOrder); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if maxOrder < customFolderDefaultSortOrderBase {
|
||||
maxOrder = customFolderDefaultSortOrderBase
|
||||
}
|
||||
return maxOrder + 1, nil
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -126,6 +434,12 @@ func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) {
|
||||
if folder == "" {
|
||||
folder = "Inbox"
|
||||
}
|
||||
if normalized, err := normalizeFolderNameForUser(folder); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
} else {
|
||||
folder = normalized
|
||||
}
|
||||
folderID, err := a.ensureFolder(r.Context(), mb.ID, folder)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load folder")
|
||||
@@ -135,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")
|
||||
@@ -152,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
|
||||
@@ -186,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")
|
||||
@@ -449,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
|
||||
}
|
||||
@@ -500,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)
|
||||
}
|
||||
@@ -514,7 +971,7 @@ func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mail
|
||||
for _, rcpt := range localRecipients {
|
||||
rcptMailbox, err := a.mailboxByAddress(ctx, rcpt)
|
||||
if err != nil {
|
||||
if !a.cfg.CatchAllEnabled || !a.isLocalDomainAddress(ctx, rcpt) {
|
||||
if !a.config().CatchAllEnabled || !a.isLocalDomainAddress(ctx, rcpt) {
|
||||
continue
|
||||
}
|
||||
copyMsg := base
|
||||
@@ -529,7 +986,7 @@ func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mail
|
||||
continue
|
||||
}
|
||||
if rcptMailbox.Status != "active" {
|
||||
if a.cfg.CatchAllEnabled && a.isLocalDomainAddress(ctx, rcpt) {
|
||||
if a.config().CatchAllEnabled && a.isLocalDomainAddress(ctx, rcpt) {
|
||||
copyMsg := base
|
||||
copyMsg.MailboxID = ""
|
||||
copyMsg.FolderID = ""
|
||||
@@ -554,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -871,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
|
||||
@@ -916,19 +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)
|
||||
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 {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
status := strings.TrimSpace(r.URL.Query().Get("status"))
|
||||
cursor, _ := strconv.Atoi(r.URL.Query().Get("cursor"))
|
||||
if cursor < 0 {
|
||||
cursor = 0
|
||||
}
|
||||
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"))
|
||||
@@ -937,9 +1412,44 @@ func (a *App) handleSendQueue(w http.ResponseWriter, r *http.Request) {
|
||||
where += ` AND sq.status=?`
|
||||
args = append(args, status)
|
||||
}
|
||||
args = append(args, limit+1, cursor)
|
||||
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 ? OFFSET ?`, args...)
|
||||
if messageID := strings.TrimSpace(r.URL.Query().Get("messageId")); messageID != "" {
|
||||
where += ` AND (sq.message_id=? OR sq.sent_message_id=? OR m.message_id=?)`
|
||||
args = append(args, messageID, messageID, messageID)
|
||||
}
|
||||
if recipient := normalizeEmail(r.URL.Query().Get("recipient")); recipient != "" {
|
||||
where += ` AND sq.recipients_json LIKE ?`
|
||||
args = append(args, "%"+recipient+"%")
|
||||
}
|
||||
if from := strings.TrimSpace(r.URL.Query().Get("from")); from != "" {
|
||||
t, err := parseTimeQuery(from)
|
||||
if err != nil {
|
||||
badRequest(w, errors.New("invalid from time"))
|
||||
return
|
||||
}
|
||||
where += ` AND sq.created_at>=?`
|
||||
args = append(args, t.UTC().Format(time.RFC3339Nano))
|
||||
}
|
||||
if to := strings.TrimSpace(r.URL.Query().Get("to")); to != "" {
|
||||
t, err := parseTimeQuery(to)
|
||||
if err != nil {
|
||||
badRequest(w, errors.New("invalid to time"))
|
||||
return
|
||||
}
|
||||
where += ` AND sq.created_at<=?`
|
||||
args = append(args, t.UTC().Format(time.RFC3339Nano))
|
||||
}
|
||||
if cursorCreatedAt != "" && cursorID != "" {
|
||||
where += ` AND (sq.created_at < ? OR (sq.created_at = ? AND sq.id < ?))`
|
||||
args = append(args, cursorCreatedAt, cursorCreatedAt, cursorID)
|
||||
}
|
||||
args = append(args, limit+1)
|
||||
query := `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 ?`
|
||||
if offsetCursor > 0 {
|
||||
args = append(args, offsetCursor)
|
||||
query += ` OFFSET ?`
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), query, args...)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load send queue")
|
||||
return
|
||||
@@ -961,7 +1471,8 @@ func (a *App) handleSendQueue(w http.ResponseWriter, r *http.Request) {
|
||||
next := ""
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
next = strconv.Itoa(cursor + limit)
|
||||
last := items[len(items)-1]
|
||||
next = encodeSendQueueCursor(last.CreatedAt, last.ID)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
|
||||
}
|
||||
@@ -1107,12 +1618,62 @@ func (a *App) loadSendQueueEntryForUser(ctx context.Context, id, userID string)
|
||||
return scanSendQueueEntry(row)
|
||||
}
|
||||
|
||||
type sendQueueCursor struct {
|
||||
CreatedAt string `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func encodeSendQueueCursor(createdAt time.Time, id string) string {
|
||||
payload, _ := json.Marshal(sendQueueCursor{CreatedAt: createdAt.UTC().Format(time.RFC3339Nano), ID: id})
|
||||
return base64.RawURLEncoding.EncodeToString(payload)
|
||||
}
|
||||
|
||||
func parseSendQueueCursor(raw string) (createdAt string, 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, decodeErr := base64.RawURLEncoding.DecodeString(raw)
|
||||
if decodeErr != nil {
|
||||
return "", "", 0, errors.New("invalid cursor")
|
||||
}
|
||||
var cursor sendQueueCursor
|
||||
if err := json.Unmarshal(data, &cursor); err != nil {
|
||||
return "", "", 0, errors.New("invalid cursor")
|
||||
}
|
||||
t, err := parseTimeQuery(cursor.CreatedAt)
|
||||
if err != nil || strings.TrimSpace(cursor.ID) == "" {
|
||||
return "", "", 0, errors.New("invalid cursor")
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339Nano), strings.TrimSpace(cursor.ID), 0, nil
|
||||
}
|
||||
|
||||
func (a *App) sendQueueBelongsToUser(ctx context.Context, id, userID string) bool {
|
||||
var count int
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id WHERE sq.id=? AND mb.user_id=?`, id, userID).Scan(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func parseTimeQuery(raw string) (time.Time, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}, errors.New("time is required")
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, raw); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
if t, err := time.Parse("2006-01-02", raw); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
return time.Time{}, errors.New("invalid time")
|
||||
}
|
||||
|
||||
func validSendQueueStatus(status string) bool {
|
||||
switch status {
|
||||
case sendQueueStatusQueued, sendQueueStatusSending, sendQueueStatusDelivered, sendQueueStatusFailed, sendQueueStatusCanceled:
|
||||
@@ -1429,7 +1990,12 @@ func (a *App) handleMove(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
folderID, err := a.ensureFolder(r.Context(), msg.MailboxID, req.Folder)
|
||||
folder, err := normalizeFolderNameForUser(req.Folder)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
folderID, err := a.ensureFolder(r.Context(), msg.MailboxID, folder)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load folder")
|
||||
return
|
||||
@@ -1441,6 +2007,69 @@ func (a *App) handleMove(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) folderByID(ctx context.Context, folderID, mailboxID string) (*MailFolder, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT f.id,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,
|
||||
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
|
||||
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
|
||||
WHERE f.id=? AND f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq`, folderID, mailboxID)
|
||||
var f MailFolder
|
||||
if err := row.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
func normalizeCustomFolderName(raw string) (string, error) {
|
||||
name := strings.Join(strings.Fields(raw), " ")
|
||||
if name == "" {
|
||||
return "", errors.New("folder name is required")
|
||||
}
|
||||
if len([]rune(name)) > 48 {
|
||||
return "", errors.New("folder name is too long")
|
||||
}
|
||||
if strings.ContainsAny(name, `/\:`) || strings.Contains(name, "..") {
|
||||
return "", errors.New("folder name contains invalid characters")
|
||||
}
|
||||
for _, r := range name {
|
||||
if r < 32 || r == 127 {
|
||||
return "", errors.New("folder name contains invalid characters")
|
||||
}
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func normalizeFolderNameForUser(raw string) (string, error) {
|
||||
name := strings.TrimSpace(raw)
|
||||
if isSystemFolderName(name) {
|
||||
switch strings.ToLower(name) {
|
||||
case "inbox":
|
||||
return "Inbox", nil
|
||||
case "sent":
|
||||
return "Sent", nil
|
||||
case "drafts":
|
||||
return "Drafts", nil
|
||||
case "archive":
|
||||
return "Archive", nil
|
||||
case "spam":
|
||||
return "Spam", nil
|
||||
case "trash":
|
||||
return "Trash", nil
|
||||
}
|
||||
}
|
||||
return normalizeCustomFolderName(raw)
|
||||
}
|
||||
|
||||
func isSystemFolderName(name string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(name)) {
|
||||
case "inbox", "sent", "drafts", "archive", "spam", "trash":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleDeleteMessage(w http.ResponseWriter, r *http.Request) {
|
||||
msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), false)
|
||||
if err != nil {
|
||||
@@ -1497,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")
|
||||
@@ -1605,6 +2239,9 @@ func (a *App) messageByID(ctx context.Context, id string, includeBody bool) (*Ma
|
||||
return nil, err
|
||||
}
|
||||
msg.Labels = labels
|
||||
if includeBody {
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT id,status FROM send_queue WHERE sent_message_id=? ORDER BY created_at DESC,id DESC LIMIT 1`, id).Scan(&msg.SendQueueID, &msg.SendQueueStatus)
|
||||
}
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
@@ -1708,7 +2345,7 @@ func (a *App) storeAttachmentWithDB(ctx context.Context, db dbExecutor, messageI
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Join(a.cfg.DataDir, "attachments", messageID)
|
||||
dir := filepath.Join(a.config().DataDir, "attachments", messageID)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1761,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
|
||||
@@ -1851,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 {
|
||||
@@ -1884,7 +2550,7 @@ func (a *App) deleteMessageFiles(ctx context.Context, messageID string) {
|
||||
_ = os.Remove(p)
|
||||
}
|
||||
}
|
||||
_ = os.RemoveAll(filepath.Join(a.cfg.DataDir, "attachments", messageID))
|
||||
_ = os.RemoveAll(filepath.Join(a.config().DataDir, "attachments", messageID))
|
||||
}
|
||||
|
||||
func (a *App) deleteMessage(ctx context.Context, messageID string) {
|
||||
|
||||
@@ -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.config().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
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
const googleTranslateEndpoint = "https://translate.googleapis.com/translate_a/single"
|
||||
|
||||
type translateMailMessageRequest struct {
|
||||
TargetLanguage string `json:"targetLanguage"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.config().MailTranslateEnabled {
|
||||
respondError(w, http.StatusForbidden, "mail translation is disabled")
|
||||
return
|
||||
}
|
||||
var req translateMailMessageRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
target := normalizeTranslateTarget(req.TargetLanguage)
|
||||
if target == "" {
|
||||
respondError(w, http.StatusBadRequest, "unsupported target language")
|
||||
return
|
||||
}
|
||||
msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), true)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "message not found")
|
||||
return
|
||||
}
|
||||
text := strings.TrimSpace(msg.BodyText)
|
||||
if text == "" {
|
||||
text = strings.TrimSpace(msg.Snippet)
|
||||
}
|
||||
if text == "" {
|
||||
respondError(w, http.StatusBadRequest, "message has no translatable text")
|
||||
return
|
||||
}
|
||||
maxChars := a.config().MailTranslateMaxChars
|
||||
if maxChars <= 0 {
|
||||
maxChars = 8000
|
||||
}
|
||||
text, truncated := truncateRunes(text, maxChars)
|
||||
translated, source, err := googleFreeTranslate(r.Context(), text, target)
|
||||
if err != nil {
|
||||
a.log.Warn("mail translation failed", "message_id", msg.ID, "target", target, "error", err)
|
||||
respondError(w, http.StatusBadGateway, "translation failed")
|
||||
return
|
||||
}
|
||||
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) {
|
||||
if !a.config().MailTranslateEnabled {
|
||||
respondError(w, http.StatusForbidden, "mail translation is disabled")
|
||||
return
|
||||
}
|
||||
var req translateMailMessageRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
target := normalizeTranslateTarget(req.TargetLanguage)
|
||||
if target == "" {
|
||||
respondError(w, http.StatusBadRequest, "unsupported target language")
|
||||
return
|
||||
}
|
||||
account, ok := a.externalIMAPAccountForMailRequest(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
folder, uid, ok := decodeExternalRemoteID(w, chi.URLParam(r, "remoteId"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
client, err := a.externalIMAP.openExternalIMAPClient(r.Context(), account)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "connection failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
raw, remote, err := client.FetchRaw(r.Context(), folder, uid)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "failed to load remote message")
|
||||
return
|
||||
}
|
||||
stored, _, err := a.parseMaildirMessage(raw, account.Username)
|
||||
text := ""
|
||||
if err == nil {
|
||||
text = strings.TrimSpace(stored.BodyText)
|
||||
if text == "" {
|
||||
text = strings.TrimSpace(stored.Snippet)
|
||||
}
|
||||
}
|
||||
if text == "" {
|
||||
text = strings.TrimSpace(remote.Snippet)
|
||||
}
|
||||
if text == "" {
|
||||
respondError(w, http.StatusBadRequest, "message has no translatable text")
|
||||
return
|
||||
}
|
||||
maxChars := a.config().MailTranslateMaxChars
|
||||
if maxChars <= 0 {
|
||||
maxChars = 8000
|
||||
}
|
||||
text, truncated := truncateRunes(text, maxChars)
|
||||
translated, source, err := googleFreeTranslate(r.Context(), text, target)
|
||||
if err != nil {
|
||||
a.log.Warn("external mail translation failed", "account_id", account.ID, "remote_id", chi.URLParam(r, "remoteId"), "target", target, "error", err)
|
||||
respondError(w, http.StatusBadGateway, "translation failed")
|
||||
return
|
||||
}
|
||||
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 {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "zh", "zh-cn", "zh-hans", "zh_cn":
|
||||
return "zh-CN"
|
||||
case "zh-tw", "zh-hant", "zh_hk", "zh-hk", "zh-mo":
|
||||
return "zh-TW"
|
||||
case "en", "en-us", "en-gb":
|
||||
return "en"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func truncateRunes(value string, max int) (string, bool) {
|
||||
if max <= 0 || utf8.RuneCountInString(value) <= max {
|
||||
return value, false
|
||||
}
|
||||
out := make([]rune, 0, max)
|
||||
for i, r := range value {
|
||||
if len(out) >= max {
|
||||
return string(out), i < len(value)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return string(out), false
|
||||
}
|
||||
|
||||
func googleFreeTranslate(ctx context.Context, text, target string) (string, string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 12*time.Second)
|
||||
defer cancel()
|
||||
params := url.Values{}
|
||||
params.Set("client", "gtx")
|
||||
params.Set("sl", "auto")
|
||||
params.Set("tl", target)
|
||||
params.Set("dt", "t")
|
||||
params.Set("q", text)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, googleTranslateEndpoint+"?"+params.Encode(), nil)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0")
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if res.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(res.Body, 1024))
|
||||
return "", "", fmt.Errorf("google translate status %d", res.StatusCode)
|
||||
}
|
||||
var raw any
|
||||
if err := json.NewDecoder(io.LimitReader(res.Body, 4*1024*1024)).Decode(&raw); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
translated, source := parseGoogleTranslateResponse(raw)
|
||||
translated = strings.TrimSpace(translated)
|
||||
if translated == "" {
|
||||
return "", source, errors.New("empty translation")
|
||||
}
|
||||
return translated, source, nil
|
||||
}
|
||||
|
||||
func parseGoogleTranslateResponse(raw any) (string, string) {
|
||||
root, _ := raw.([]any)
|
||||
var b strings.Builder
|
||||
if len(root) > 0 {
|
||||
if sentences, ok := root[0].([]any); ok {
|
||||
for _, item := range sentences {
|
||||
parts, ok := item.([]any)
|
||||
if !ok || len(parts) == 0 {
|
||||
continue
|
||||
}
|
||||
if s, ok := parts[0].(string); ok {
|
||||
b.WriteString(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
source := ""
|
||||
if len(root) > 2 {
|
||||
if s, ok := root[2].(string); ok {
|
||||
source = s
|
||||
}
|
||||
}
|
||||
return b.String(), source
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseGoogleTranslateResponse(t *testing.T) {
|
||||
raw := []any{
|
||||
[]any{
|
||||
[]any{"你好", "Hello", nil, nil, float64(3)},
|
||||
[]any{",世界", ", world", nil, nil, float64(3)},
|
||||
},
|
||||
nil,
|
||||
"en",
|
||||
}
|
||||
translated, source := parseGoogleTranslateResponse(raw)
|
||||
if translated != "你好,世界" {
|
||||
t.Fatalf("translated = %q", translated)
|
||||
}
|
||||
if source != "en" {
|
||||
t.Fatalf("source = %q", source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateRunes(t *testing.T) {
|
||||
got, truncated := truncateRunes("你好world", 4)
|
||||
if got != "你好wo" || !truncated {
|
||||
t.Fatalf("truncateRunes() = %q, %v", got, truncated)
|
||||
}
|
||||
got, truncated = truncateRunes("你好", 4)
|
||||
if got != "你好" || truncated {
|
||||
t.Fatalf("truncateRunes() = %q, %v", got, truncated)
|
||||
}
|
||||
}
|
||||
@@ -193,5 +193,5 @@ func cloneTimePtr(in *time.Time) *time.Time {
|
||||
}
|
||||
|
||||
func (a *App) handleMaildirSyncHealth(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, a.maildirHealth.snapshot(a.cfg))
|
||||
respondJSON(w, http.StatusOK, a.maildirHealth.snapshot(a.config()))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !unix
|
||||
|
||||
package app
|
||||
|
||||
func applyMaildirOwnership(path string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build unix
|
||||
|
||||
package app
|
||||
|
||||
import "os"
|
||||
|
||||
const (
|
||||
maildirOwnerUID = 5000
|
||||
maildirOwnerGID = 5000
|
||||
)
|
||||
|
||||
func applyMaildirOwnership(path string) error {
|
||||
if os.Geteuid() != 0 {
|
||||
return nil
|
||||
}
|
||||
return os.Chown(path, maildirOwnerUID, maildirOwnerGID)
|
||||
}
|
||||
@@ -45,13 +45,13 @@ type parsedMail struct {
|
||||
}
|
||||
|
||||
func (a *App) maildirWorker(ctx context.Context) {
|
||||
interval := time.Duration(a.cfg.MaildirScanSeconds) * time.Second
|
||||
interval := time.Duration(a.config().MaildirScanSeconds) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = 30 * time.Second
|
||||
}
|
||||
nextRunAt := a.now().UTC()
|
||||
a.maildirHealth.markWorkerStarted(&nextRunAt)
|
||||
a.log.Info("maildir sync worker started", "root", a.cfg.MaildirRoot, "interval", interval.String())
|
||||
a.log.Info("maildir sync worker started", "root", a.config().MaildirRoot, "interval", interval.String())
|
||||
if counts, err := a.syncMaildirOnceTracked(ctx, interval); err != nil {
|
||||
a.log.Warn("initial maildir sync failed", "error", err)
|
||||
} else if n := counts.total(); n > 0 {
|
||||
@@ -98,7 +98,7 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
|
||||
}
|
||||
|
||||
func (a *App) syncMaildirOnceDetailed(ctx context.Context) (maildirSyncCounts, error) {
|
||||
root := strings.TrimSpace(a.cfg.MaildirRoot)
|
||||
root := strings.TrimSpace(a.config().MaildirRoot)
|
||||
if root == "" {
|
||||
return maildirSyncCounts{}, nil
|
||||
}
|
||||
@@ -190,7 +190,7 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.cfg.CatchAllEnabled {
|
||||
if a.config().CatchAllEnabled {
|
||||
domainRows, err := a.db.QueryContext(ctx, `SELECT name FROM domains WHERE status='active' ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -216,13 +216,8 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *App) syncUnregisteredMaildir(ctx context.Context, mb maildirMailbox) (int, error) {
|
||||
counts, err := a.syncUnregisteredMaildirDetailed(ctx, mb)
|
||||
return counts.Imported, err
|
||||
}
|
||||
|
||||
func (a *App) syncUnregisteredMaildirDetailed(ctx context.Context, mb maildirMailbox) (maildirSyncCounts, error) {
|
||||
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
||||
base := filepath.Join(strings.TrimSpace(a.config().MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
||||
counts := maildirSyncCounts{}
|
||||
for _, sub := range []string{"new", "cur"} {
|
||||
select {
|
||||
@@ -371,6 +366,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
|
||||
}
|
||||
@@ -393,16 +389,6 @@ func (a *App) unregisteredMaildirMessageExists(ctx context.Context, rawPath, mes
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (a *App) attachMaildirRawPathToExisting(ctx context.Context, mailboxID, folderID, rawPath, messageID string) {
|
||||
if strings.TrimSpace(messageID) == "" || strings.TrimSpace(rawPath) == "" {
|
||||
return
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,updated_at=? WHERE mailbox_id=? AND folder_id=? AND message_id=? AND message_id <> '' AND raw_path=''`,
|
||||
rawPath, a.now().UTC().Format(time.RFC3339Nano), mailboxID, folderID, messageID); err != nil {
|
||||
a.log.Warn("failed to attach maildir raw path to existing message", "path", rawPath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) syncExistingMaildirMessageState(ctx context.Context, mailboxID, folderID, rawPath, messageID string, read, starred bool) (bool, error) {
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
var samePathID, oldFolderID string
|
||||
@@ -513,7 +499,7 @@ func (a *App) removeDuplicateMaildirMessage(ctx context.Context, rawPath, mailbo
|
||||
}
|
||||
|
||||
func (a *App) cleanupMissingMaildirMessages(ctx context.Context) (int, error) {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
if strings.TrimSpace(a.config().MaildirRoot) == "" {
|
||||
return 0, nil
|
||||
}
|
||||
cutoff := a.now().UTC().Add(-5 * time.Minute).Format(time.RFC3339Nano)
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
)
|
||||
|
||||
func (a *App) writeStoredMessageToMaildir(ctx context.Context, messageID string, msg storedMessage, attachments []AttachmentInput) error {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" || strings.TrimSpace(msg.MailboxID) == "" || strings.TrimSpace(msg.FolderID) == "" {
|
||||
if strings.TrimSpace(a.config().MaildirRoot) == "" || strings.TrimSpace(msg.MailboxID) == "" || strings.TrimSpace(msg.FolderID) == "" {
|
||||
return nil
|
||||
}
|
||||
raw, err := BuildMIME(MIMEMessage{
|
||||
@@ -37,7 +37,7 @@ func (a *App) writeStoredMessageToMaildir(ctx context.Context, messageID string,
|
||||
}
|
||||
|
||||
func (a *App) rewriteMessageMaildir(ctx context.Context, messageID string) error {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
if strings.TrimSpace(a.config().MaildirRoot) == "" {
|
||||
return nil
|
||||
}
|
||||
msg, err := a.storedMessageByID(ctx, messageID)
|
||||
@@ -76,7 +76,7 @@ func (a *App) writeRawMessageToMaildir(ctx context.Context, messageID string, ra
|
||||
}
|
||||
|
||||
func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, folderID string, raw []byte, replace bool, updateFolder bool) error {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
if strings.TrimSpace(a.config().MaildirRoot) == "" {
|
||||
return nil
|
||||
}
|
||||
state, err := a.maildirMessageState(ctx, messageID)
|
||||
@@ -114,13 +114,13 @@ func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, fol
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
||||
base := filepath.Join(strings.TrimSpace(a.config().MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
||||
folderBase := maildirFolderPath(base, folderName)
|
||||
subdir := "cur"
|
||||
if strings.EqualFold(folderName, "Inbox") && !state.IsRead {
|
||||
subdir = "new"
|
||||
}
|
||||
if err := ensureMaildirFolderDirs(folderBase); err != nil {
|
||||
if err := ensureMaildirFolderDirs(base, folderBase); err != nil {
|
||||
return err
|
||||
}
|
||||
filename := maildirFilename(messageID, state.MessageID)
|
||||
@@ -130,6 +130,10 @@ func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, fol
|
||||
if err := os.WriteFile(tmpPath, raw, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := applyMaildirOwnership(tmpPath); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpPath, finalPath); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return err
|
||||
@@ -162,7 +166,7 @@ func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, fol
|
||||
}
|
||||
|
||||
func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID string) error {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
if strings.TrimSpace(a.config().MaildirRoot) == "" {
|
||||
state, stateErr := a.maildirMessageState(ctx, messageID)
|
||||
if stateErr != nil {
|
||||
return stateErr
|
||||
@@ -211,9 +215,9 @@ func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
||||
base := filepath.Join(strings.TrimSpace(a.config().MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
||||
folderBase := maildirFolderPath(base, folderName)
|
||||
if err := ensureMaildirFolderDirs(folderBase); err != nil {
|
||||
if err := ensureMaildirFolderDirs(base, folderBase); err != nil {
|
||||
return err
|
||||
}
|
||||
subdir := "cur"
|
||||
@@ -225,6 +229,9 @@ func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID
|
||||
if err := os.Rename(state.RawPath, targetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := applyMaildirOwnership(targetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if state.FolderID != "" && state.FolderID != targetFolderID {
|
||||
if _, err := a.bumpFolderModSeq(ctx, state.FolderID); err != nil {
|
||||
@@ -277,7 +284,7 @@ func (a *App) deleteMessageMaildirFile(ctx context.Context, messageID string) {
|
||||
}
|
||||
|
||||
func (a *App) updateMessageMaildirFlags(ctx context.Context, messageID string, read, starred *bool) error {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
if strings.TrimSpace(a.config().MaildirRoot) == "" {
|
||||
return nil
|
||||
}
|
||||
state, err := a.maildirMessageState(ctx, messageID)
|
||||
@@ -318,6 +325,9 @@ func (a *App) updateMessageMaildirFlags(ctx context.Context, messageID string, r
|
||||
if err := os.Rename(state.RawPath, targetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := applyMaildirOwnership(targetPath); err != nil {
|
||||
return err
|
||||
}
|
||||
modSeq, err := a.bumpFolderModSeq(ctx, state.FolderID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -344,7 +354,7 @@ func (a *App) removeMaildirPath(ctx context.Context, rawPath string) {
|
||||
}
|
||||
|
||||
func (a *App) backfillSQLiteMessagesToMaildir(ctx context.Context) (int, error) {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
if strings.TrimSpace(a.config().MaildirRoot) == "" {
|
||||
return 0, nil
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE COALESCE(mailbox_id,'')<>'' AND COALESCE(folder_id,'')<>'' AND raw_path='' ORDER BY created_at LIMIT 100`)
|
||||
@@ -462,7 +472,7 @@ func (a *App) folderNameByID(ctx context.Context, folderID string) (string, erro
|
||||
}
|
||||
|
||||
func (a *App) pathIsUnderMaildirRoot(path string) (bool, error) {
|
||||
root := strings.TrimSpace(a.cfg.MaildirRoot)
|
||||
root := strings.TrimSpace(a.config().MaildirRoot)
|
||||
if root == "" || strings.TrimSpace(path) == "" {
|
||||
return false, nil
|
||||
}
|
||||
@@ -481,15 +491,33 @@ func (a *App) pathIsUnderMaildirRoot(path string) (bool, error) {
|
||||
return rel != "." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != "..", nil
|
||||
}
|
||||
|
||||
func ensureMaildirFolderDirs(folderBase string) error {
|
||||
func ensureMaildirFolderDirs(base, folderBase string) error {
|
||||
for _, sub := range []string{"tmp", "new", "cur"} {
|
||||
if err := os.MkdirAll(filepath.Join(folderBase, sub), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, dir := range maildirOwnershipDirs(base, folderBase) {
|
||||
if err := applyMaildirOwnership(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func maildirOwnershipDirs(base, folderBase string) []string {
|
||||
dirs := []string{
|
||||
filepath.Dir(filepath.Dir(base)),
|
||||
filepath.Dir(base),
|
||||
base,
|
||||
}
|
||||
if filepath.Clean(folderBase) != filepath.Clean(base) {
|
||||
dirs = append(dirs, folderBase)
|
||||
}
|
||||
dirs = append(dirs, filepath.Join(folderBase, "tmp"), filepath.Join(folderBase, "new"), filepath.Join(folderBase, "cur"))
|
||||
return dirs
|
||||
}
|
||||
|
||||
func maildirFilename(messageID, headerMessageID string) string {
|
||||
base := strings.TrimSpace(headerMessageID)
|
||||
if base == "" {
|
||||
|
||||
@@ -136,7 +136,7 @@ func writeBase64(w io.Writer, data []byte) {
|
||||
}
|
||||
|
||||
func (a *App) sendSMTP(from string, recipients []string, mimeBytes []byte) error {
|
||||
return sendSMTPWithConfig(a.cfg, from, recipients, mimeBytes)
|
||||
return sendSMTPWithConfig(a.config(), from, recipients, mimeBytes)
|
||||
}
|
||||
|
||||
func sendSMTPWithConfig(cfg Config, from string, recipients []string, mimeBytes []byte) error {
|
||||
|
||||
@@ -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.config().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,925 @@
|
||||
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 !hasMinimumPasswordLength(req.Password) {
|
||||
badRequest(w, errors.New("password must be at least 6 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 !hasMinimumPasswordLength(req.Password) {
|
||||
badRequest(w, errors.New("password must be at least 6 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
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestHasMinimumPasswordLength(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
password string
|
||||
want bool
|
||||
}{
|
||||
{name: "five ASCII characters", password: "abc12", want: false},
|
||||
{name: "six ASCII characters", password: "abc123", want: true},
|
||||
{name: "six Unicode characters", password: "密码测试六位", want: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := hasMinimumPasswordLength(tt.password); got != tt.want {
|
||||
t.Fatalf("hasMinimumPasswordLength(%q) = %v, want %v", tt.password, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -471,21 +490,6 @@ func regularUserDefaultPermissions() []string {
|
||||
}
|
||||
}
|
||||
|
||||
func fixedPermissionGroupIDs() map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, group := range defaultPermissionGroups() {
|
||||
out[group.ID] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assignablePermissionGroupIDs() map[string]bool {
|
||||
out := fixedPermissionGroupIDs()
|
||||
delete(out, PermissionGroupSuperAdmin)
|
||||
delete(out, PermissionGroupRegular)
|
||||
return out
|
||||
}
|
||||
|
||||
func isAssignablePermissionGroupID(groupID string) bool {
|
||||
return groupID != "" && groupID != PermissionGroupSuperAdmin && groupID != PermissionGroupRegular
|
||||
}
|
||||
@@ -498,14 +502,6 @@ func permissionGroupOrder() map[string]int {
|
||||
return out
|
||||
}
|
||||
|
||||
func permissionGroupNames() map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, group := range defaultPermissionGroups() {
|
||||
out[group.ID] = group.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) ensureDefaultPermissionGroups(ctx context.Context) error {
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
for _, item := range defaultPermissionGroups() {
|
||||
@@ -597,6 +593,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 +610,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 +771,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,7 +1034,10 @@ func (a *App) isDefaultAdminUser(u *User) bool {
|
||||
if u == nil {
|
||||
return false
|
||||
}
|
||||
adminEmail := normalizeEmail(a.cfg.AdminEmail)
|
||||
if adminUsername := normalizeLoginName(a.config().AdminUsername); adminUsername != "" && !strings.Contains(adminUsername, "@") {
|
||||
return strings.EqualFold(normalizeLoginName(u.LoginName), adminUsername)
|
||||
}
|
||||
adminEmail := normalizeEmail(a.config().AdminEmail)
|
||||
return adminEmail != "" && strings.EqualFold(normalizeEmail(u.Email), adminEmail)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -26,14 +27,14 @@ func (a *App) handleMailboxApplyOptions(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, MailboxApplyOptions{
|
||||
Enabled: a.cfg.UserMailboxApplyEnabled,
|
||||
Enabled: a.config().UserMailboxApplyEnabled,
|
||||
Domains: domains,
|
||||
ReservedPrefixes: parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes),
|
||||
ReservedPrefixes: parseReservedPrefixes(a.config().ReservedMailboxPrefixes),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.cfg.UserMailboxApplyEnabled {
|
||||
if !a.config().UserMailboxApplyEnabled {
|
||||
respondError(w, http.StatusForbidden, "当前未开放邮箱申请")
|
||||
return
|
||||
}
|
||||
@@ -72,7 +73,7 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
reserved := map[string]bool{}
|
||||
for _, item := range parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes) {
|
||||
for _, item := range parseReservedPrefixes(a.config().ReservedMailboxPrefixes) {
|
||||
reserved[item] = true
|
||||
}
|
||||
if reserved[localPart] {
|
||||
@@ -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 {
|
||||
@@ -117,10 +129,10 @@ func (a *App) handleApplyMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) mailboxApplyDomains(ctx context.Context) ([]Domain, error) {
|
||||
if !a.cfg.UserMailboxApplyEnabled {
|
||||
if !a.config().UserMailboxApplyEnabled {
|
||||
return []Domain{}, nil
|
||||
}
|
||||
ids := cleanIDList(strings.Split(a.cfg.UserMailboxDomainIDs, ","))
|
||||
ids := cleanIDList(strings.Split(a.config().UserMailboxDomainIDs, ","))
|
||||
if len(ids) == 0 {
|
||||
return []Domain{}, 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,24 +65,54 @@ 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)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailBlocked)).Delete("/me/blocked-senders/{id}", a.handleDeleteBlockedSender)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailStats)).Get("/me/stats", a.handleMailStats)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailOrganize)).Post("/me/cleanup", a.handleMailCleanup)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Get("/me/external-imap-accounts", a.handleListExternalIMAPAccounts)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-accounts", a.handleCreateExternalIMAPAccount)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-accounts/{id}", a.handleUpdateExternalIMAPAccount)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Delete("/me/external-imap-accounts/{id}", a.handleDeleteExternalIMAPAccount)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-accounts/{id}/test", a.handleTestExternalIMAPAccount)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Get("/me/external-imap-accounts/{id}/runs", a.handleExternalIMAPSyncRuns)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-accounts/{id}/sync", a.handleSyncExternalIMAPAccount)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-accounts/{id}/sync-folder", a.handleSyncExternalIMAPFolder)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-oauth/{provider}/start", a.handleStartExternalIMAPOAuth)
|
||||
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)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/folders", a.handleMailFolders)
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/folders", a.handleCreateMailFolder)
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/folders/reorder", a.handleReorderMailFolders)
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Delete("/mail/folders/{id}", a.handleDeleteMailFolder)
|
||||
r.With(a.requireAnyPermission(PermissionMailRead, PermissionMailLabels)).Get("/mail/labels", a.handleMailLabels)
|
||||
r.With(a.requirePermission(PermissionMailLabels)).Post("/mail/labels", a.handleCreateMailLabel)
|
||||
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)
|
||||
r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Get("/mail/external-accounts/{id}/folders", a.handleExternalIMAPFolders)
|
||||
r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Get("/mail/external-accounts/{id}/messages", a.handleExternalIMAPMessages)
|
||||
r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Get("/mail/external-accounts/{id}/messages/{remoteId}", a.handleExternalIMAPMessage)
|
||||
r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Post("/mail/external-accounts/{id}/messages/{remoteId}/translate", a.handleTranslateExternalIMAPMessage)
|
||||
r.With(a.requirePermission(PermissionMailAttachments), a.requireExternalIMAPEnabled).Get("/mail/external-accounts/{id}/attachments/{remoteId}/{partId}", a.handleExternalIMAPAttachment)
|
||||
r.With(a.requirePermission(PermissionMailOrganize), a.requireExternalIMAPEnabled).Post("/mail/external-accounts/{id}/messages/{remoteId}/mark-read", a.handleExternalIMAPMarkRead)
|
||||
r.With(a.requirePermission(PermissionMailSend)).Post("/mail/send", a.handleMailSend)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/send-queue", a.handleSendQueue)
|
||||
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/send-queue/{id}/audit", a.handleSendQueueAudit)
|
||||
@@ -94,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)
|
||||
@@ -137,14 +181,45 @@ 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")
|
||||
if origin != "" && (strings.HasPrefix(origin, "http://localhost:") || strings.HasPrefix(origin, "http://127.0.0.1:") || origin == a.cfg.PublicBaseURL) {
|
||||
if origin != "" && (strings.HasPrefix(origin, "http://localhost:") || strings.HasPrefix(origin, "http://127.0.0.1:") || origin == a.config().PublicBaseURL) {
|
||||
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 {
|
||||
@@ -166,27 +241,55 @@ 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
|
||||
}
|
||||
|
||||
func (a *App) authenticateRequest(r *http.Request) (*User, error) {
|
||||
cookie, err := r.Cookie(a.cfg.CookieName)
|
||||
cookie, err := r.Cookie(a.config().CookieName)
|
||||
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")
|
||||
@@ -197,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
|
||||
}
|
||||
@@ -211,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
|
||||
@@ -219,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
|
||||
}
|
||||
@@ -231,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
|
||||
@@ -63,7 +67,7 @@ type sendQueueItem struct {
|
||||
}
|
||||
|
||||
func (a *App) enqueueSend(ctx context.Context, in sendQueueInput) (string, error) {
|
||||
if strings.TrimSpace(a.cfg.SMTPHost) == "" {
|
||||
if strings.TrimSpace(a.config().SMTPHost) == "" {
|
||||
return "", nil
|
||||
}
|
||||
now := in.Now.UTC()
|
||||
@@ -126,6 +130,12 @@ func (a *App) sendQueueWorker(ctx context.Context) {
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
a.log.Info("send queue worker stopped")
|
||||
return
|
||||
default:
|
||||
}
|
||||
if err := a.processDueSendQueue(ctx); err != nil {
|
||||
a.log.Warn("send queue worker failed", "error", err)
|
||||
}
|
||||
@@ -139,7 +149,7 @@ func (a *App) sendQueueWorker(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (a *App) processDueSendQueue(ctx context.Context) error {
|
||||
if strings.TrimSpace(a.cfg.SMTPHost) == "" {
|
||||
if strings.TrimSpace(a.config().SMTPHost) == "" {
|
||||
return nil
|
||||
}
|
||||
if err := a.recoverStaleSendQueueItems(ctx); err != nil {
|
||||
@@ -232,12 +242,12 @@ func (a *App) recoverStaleSendQueueItems(ctx context.Context) error {
|
||||
}
|
||||
continue
|
||||
}
|
||||
res, err := a.db.ExecContext(ctx, `UPDATE send_queue SET status=?,next_attempt_at=?,last_error=?,updated_at=? WHERE id=? AND status=?`, sendQueueStatusFailed, now, "send attempt interrupted", now, item.ID, sendQueueStatusSending)
|
||||
res, err := a.db.ExecContext(ctx, `UPDATE send_queue SET status=?,next_attempt_at=?,last_error=?,updated_at=? WHERE id=? AND status=?`, sendQueueStatusQueued, now, "send attempt interrupted", now, item.ID, sendQueueStatusSending)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
a.recordSendAudit(ctx, sendAuditRetry, sendQueueStatusFailed, sendAuditInputFromQueue(item, "send attempt interrupted"))
|
||||
a.recordSendAudit(ctx, sendAuditRetry, sendQueueStatusQueued, sendAuditInputFromQueue(item, "send attempt interrupted"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -358,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,7 +396,7 @@ func (a *App) sendQueueDeliveredMarkerPath(id string) string {
|
||||
if safeID == "" || safeID == "." {
|
||||
safeID = "unknown"
|
||||
}
|
||||
return filepath.Join(a.cfg.DataDir, sendQueueDeliveredMarkerDir, safeID+".marker")
|
||||
return filepath.Join(a.config().DataDir, sendQueueDeliveredMarkerDir, safeID+".marker")
|
||||
}
|
||||
|
||||
func (a *App) writeSendQueueDeliveredMarker(id string) error {
|
||||
|
||||
@@ -8,20 +8,20 @@ import (
|
||||
func (a *App) issueSession(w http.ResponseWriter, r *http.Request, userID string) error {
|
||||
token := randomToken()
|
||||
sessionID := newID("ses")
|
||||
expires := a.now().UTC().Add(time.Duration(a.cfg.SessionTTLHours) * time.Hour)
|
||||
expires := a.now().UTC().Add(time.Duration(a.config().SessionTTLHours) * time.Hour)
|
||||
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO sessions(id,user_id,token_hash,expires_at,created_at) VALUES(?,?,?,?,?)`,
|
||||
sessionID, userID, hashToken(token), expires.Format(time.RFC3339Nano), a.now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||
return err
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: a.cfg.CookieName,
|
||||
Name: a.config().CookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
Expires: expires,
|
||||
MaxAge: int(time.Until(expires).Seconds()),
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: !a.cfg.AllowInsecureHTTP,
|
||||
Secure: !a.config().AllowInsecureHTTP,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -10,63 +10,80 @@ import (
|
||||
)
|
||||
|
||||
type SystemSettings struct {
|
||||
PublicHostname string `json:"publicHostname"`
|
||||
PublicBaseURL string `json:"publicBaseUrl"`
|
||||
SMTPHost string `json:"smtpHost"`
|
||||
SMTPPort string `json:"smtpPort"`
|
||||
SMTPUsername string `json:"smtpUsername"`
|
||||
SMTPPasswordSet bool `json:"smtpPasswordSet"`
|
||||
SMTPRequireTLS bool `json:"smtpRequireTls"`
|
||||
MaildirRoot string `json:"maildirRoot"`
|
||||
MaildirScanSeconds int `json:"maildirScanSeconds"`
|
||||
SessionTTLHours int `json:"sessionTtlHours"`
|
||||
AllowInsecureHTTP bool `json:"allowInsecureHttp"`
|
||||
OpenRegistration bool `json:"openRegistration"`
|
||||
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
||||
TurnstileEnabled bool `json:"turnstileEnabled"`
|
||||
TurnstileSiteKey string `json:"turnstileSiteKey"`
|
||||
TurnstileSecretSet bool `json:"turnstileSecretSet"`
|
||||
CatchAllEnabled bool `json:"catchAllEnabled"`
|
||||
MailAutoRefresh bool `json:"mailAutoRefresh"`
|
||||
MailRefreshSeconds int `json:"mailRefreshSeconds"`
|
||||
UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"`
|
||||
UserMailboxDomainIDs []string `json:"userMailboxDomainIds"`
|
||||
ReservedMailboxPrefixes string `json:"reservedMailboxPrefixes"`
|
||||
PublicHostname string `json:"publicHostname"`
|
||||
PublicBaseURL string `json:"publicBaseUrl"`
|
||||
SMTPHost string `json:"smtpHost"`
|
||||
SMTPPort string `json:"smtpPort"`
|
||||
SMTPUsername string `json:"smtpUsername"`
|
||||
SMTPPasswordSet bool `json:"smtpPasswordSet"`
|
||||
SMTPRequireTLS bool `json:"smtpRequireTls"`
|
||||
MaildirRoot string `json:"maildirRoot"`
|
||||
MaildirScanSeconds int `json:"maildirScanSeconds"`
|
||||
SessionTTLHours int `json:"sessionTtlHours"`
|
||||
AllowInsecureHTTP bool `json:"allowInsecureHttp"`
|
||||
OpenRegistration bool `json:"openRegistration"`
|
||||
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
||||
TurnstileEnabled bool `json:"turnstileEnabled"`
|
||||
TurnstileSiteKey string `json:"turnstileSiteKey"`
|
||||
TurnstileSecretSet bool `json:"turnstileSecretSet"`
|
||||
CatchAllEnabled bool `json:"catchAllEnabled"`
|
||||
MailAutoRefresh bool `json:"mailAutoRefresh"`
|
||||
MailRefreshSeconds int `json:"mailRefreshSeconds"`
|
||||
UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"`
|
||||
UserMailboxDomainIDs []string `json:"userMailboxDomainIds"`
|
||||
ReservedMailboxPrefixes string `json:"reservedMailboxPrefixes"`
|
||||
ExternalIMAPEnabled bool `json:"externalImapEnabled"`
|
||||
ExternalIMAPSecretSet bool `json:"externalImapSecretSet"`
|
||||
ExternalIMAPSyncSeconds int `json:"externalImapSyncSeconds"`
|
||||
ExternalIMAPAllowPrivateHosts bool `json:"externalImapAllowPrivateHosts"`
|
||||
ExternalIMAPGmailClientID string `json:"externalImapGmailClientId"`
|
||||
ExternalIMAPGmailClientSecretSet bool `json:"externalImapGmailClientSecretSet"`
|
||||
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
|
||||
ExternalIMAPOutlookClientSecretSet bool `json:"externalImapOutlookClientSecretSet"`
|
||||
}
|
||||
|
||||
type systemSettingsUpdate struct {
|
||||
PublicHostname string `json:"publicHostname"`
|
||||
PublicBaseURL string `json:"publicBaseUrl"`
|
||||
SMTPHost string `json:"smtpHost"`
|
||||
SMTPPort string `json:"smtpPort"`
|
||||
SMTPUsername string `json:"smtpUsername"`
|
||||
SMTPPassword string `json:"smtpPassword"`
|
||||
SMTPRequireTLS bool `json:"smtpRequireTls"`
|
||||
MaildirRoot string `json:"maildirRoot"`
|
||||
MaildirScanSeconds int `json:"maildirScanSeconds"`
|
||||
SessionTTLHours int `json:"sessionTtlHours"`
|
||||
AllowInsecureHTTP bool `json:"allowInsecureHttp"`
|
||||
OpenRegistration bool `json:"openRegistration"`
|
||||
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
||||
TurnstileEnabled bool `json:"turnstileEnabled"`
|
||||
TurnstileSiteKey string `json:"turnstileSiteKey"`
|
||||
TurnstileSecretKey string `json:"turnstileSecretKey"`
|
||||
CatchAllEnabled bool `json:"catchAllEnabled"`
|
||||
MailAutoRefresh bool `json:"mailAutoRefresh"`
|
||||
MailRefreshSeconds int `json:"mailRefreshSeconds"`
|
||||
UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"`
|
||||
UserMailboxDomainIDs []string `json:"userMailboxDomainIds"`
|
||||
ReservedMailboxPrefixes string `json:"reservedMailboxPrefixes"`
|
||||
PublicHostname string `json:"publicHostname"`
|
||||
PublicBaseURL string `json:"publicBaseUrl"`
|
||||
SMTPHost string `json:"smtpHost"`
|
||||
SMTPPort string `json:"smtpPort"`
|
||||
SMTPUsername string `json:"smtpUsername"`
|
||||
SMTPPassword string `json:"smtpPassword"`
|
||||
SMTPRequireTLS bool `json:"smtpRequireTls"`
|
||||
MaildirRoot string `json:"maildirRoot"`
|
||||
MaildirScanSeconds int `json:"maildirScanSeconds"`
|
||||
SessionTTLHours int `json:"sessionTtlHours"`
|
||||
AllowInsecureHTTP bool `json:"allowInsecureHttp"`
|
||||
OpenRegistration bool `json:"openRegistration"`
|
||||
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
||||
TurnstileEnabled bool `json:"turnstileEnabled"`
|
||||
TurnstileSiteKey string `json:"turnstileSiteKey"`
|
||||
TurnstileSecretKey string `json:"turnstileSecretKey"`
|
||||
CatchAllEnabled bool `json:"catchAllEnabled"`
|
||||
MailAutoRefresh bool `json:"mailAutoRefresh"`
|
||||
MailRefreshSeconds int `json:"mailRefreshSeconds"`
|
||||
UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"`
|
||||
UserMailboxDomainIDs []string `json:"userMailboxDomainIds"`
|
||||
ReservedMailboxPrefixes string `json:"reservedMailboxPrefixes"`
|
||||
ExternalIMAPEnabled bool `json:"externalImapEnabled"`
|
||||
ExternalIMAPSecretKey string `json:"externalImapSecretKey"`
|
||||
ExternalIMAPSyncSeconds int `json:"externalImapSyncSeconds"`
|
||||
ExternalIMAPAllowPrivateHosts bool `json:"externalImapAllowPrivateHosts"`
|
||||
ExternalIMAPGmailClientID string `json:"externalImapGmailClientId"`
|
||||
ExternalIMAPGmailClientSecret string `json:"externalImapGmailClientSecret"`
|
||||
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
|
||||
ExternalIMAPOutlookClientSecret string `json:"externalImapOutlookClientSecret"`
|
||||
}
|
||||
|
||||
type PublicSettings struct {
|
||||
OpenRegistration bool `json:"openRegistration"`
|
||||
TurnstileEnabled bool `json:"turnstileEnabled"`
|
||||
TurnstileSiteKey string `json:"turnstileSiteKey"`
|
||||
PublicHostname string `json:"publicHostname"`
|
||||
MailAutoRefresh bool `json:"mailAutoRefresh"`
|
||||
MailRefreshMs int `json:"mailRefreshMs"`
|
||||
MailboxDomains []PublicDomain `json:"mailboxDomains,omitempty"`
|
||||
OpenRegistration bool `json:"openRegistration"`
|
||||
TurnstileEnabled bool `json:"turnstileEnabled"`
|
||||
TurnstileSiteKey string `json:"turnstileSiteKey"`
|
||||
PublicHostname string `json:"publicHostname"`
|
||||
MailAutoRefresh bool `json:"mailAutoRefresh"`
|
||||
MailRefreshMs int `json:"mailRefreshMs"`
|
||||
ExternalIMAPEnabled bool `json:"externalImapEnabled"`
|
||||
MailboxDomains []PublicDomain `json:"mailboxDomains,omitempty"`
|
||||
}
|
||||
|
||||
type PublicDomain struct {
|
||||
@@ -83,15 +100,16 @@ func (a *App) handleGetSystemSettings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) {
|
||||
enabled := a.cfg.TurnstileEnabled && strings.TrimSpace(a.cfg.TurnstileSiteKey) != "" && strings.TrimSpace(a.cfg.TurnstileSecretKey) != ""
|
||||
refreshSeconds := a.cfg.MailRefreshSeconds
|
||||
cfg := a.config()
|
||||
enabled := cfg.TurnstileEnabled && strings.TrimSpace(cfg.TurnstileSiteKey) != "" && strings.TrimSpace(cfg.TurnstileSecretKey) != ""
|
||||
refreshSeconds := cfg.MailRefreshSeconds
|
||||
if refreshSeconds <= 0 {
|
||||
refreshSeconds = 30
|
||||
}
|
||||
settings := PublicSettings{OpenRegistration: a.cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: a.cfg.TurnstileSiteKey, PublicHostname: a.cfg.PublicHostname, MailAutoRefresh: a.cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000}
|
||||
settings := PublicSettings{OpenRegistration: cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: cfg.TurnstileSiteKey, PublicHostname: cfg.PublicHostname, MailAutoRefresh: cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000, ExternalIMAPEnabled: cfg.ExternalIMAPEnabled}
|
||||
|
||||
// Include available domains for mailbox creation during registration
|
||||
if a.cfg.OpenRegistration {
|
||||
if cfg.OpenRegistration {
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id, name FROM domains WHERE status='active' ORDER BY name`)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
@@ -114,7 +132,7 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request)
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
next := a.cfg
|
||||
next := a.config()
|
||||
next.PublicHostname = normalizeHostname(req.PublicHostname)
|
||||
if next.PublicHostname == "" {
|
||||
badRequest(w, errors.New("publicHostname is required"))
|
||||
@@ -165,12 +183,33 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request)
|
||||
next.UserMailboxApplyEnabled = req.UserMailboxApplyEnabled
|
||||
next.UserMailboxDomainIDs = strings.Join(cleanIDList(req.UserMailboxDomainIDs), ",")
|
||||
next.ReservedMailboxPrefixes = strings.Join(parseReservedPrefixes(req.ReservedMailboxPrefixes), ",")
|
||||
next.ExternalIMAPEnabled = req.ExternalIMAPEnabled
|
||||
if strings.TrimSpace(req.ExternalIMAPSecretKey) != "" {
|
||||
next.ExternalIMAPSecretKey = strings.TrimSpace(req.ExternalIMAPSecretKey)
|
||||
}
|
||||
if req.ExternalIMAPSyncSeconds <= 0 {
|
||||
req.ExternalIMAPSyncSeconds = 300
|
||||
}
|
||||
next.ExternalIMAPSyncSeconds = req.ExternalIMAPSyncSeconds
|
||||
next.ExternalIMAPAllowPrivateHosts = req.ExternalIMAPAllowPrivateHosts
|
||||
next.ExternalIMAPGmailClientID = strings.TrimSpace(req.ExternalIMAPGmailClientID)
|
||||
if strings.TrimSpace(req.ExternalIMAPGmailClientSecret) != "" {
|
||||
next.ExternalIMAPGmailClientSecret = strings.TrimSpace(req.ExternalIMAPGmailClientSecret)
|
||||
}
|
||||
next.ExternalIMAPOutlookClientID = strings.TrimSpace(req.ExternalIMAPOutlookClientID)
|
||||
if strings.TrimSpace(req.ExternalIMAPOutlookClientSecret) != "" {
|
||||
next.ExternalIMAPOutlookClientSecret = strings.TrimSpace(req.ExternalIMAPOutlookClientSecret)
|
||||
}
|
||||
if next.ExternalIMAPEnabled && strings.TrimSpace(next.ExternalIMAPSecretKey) == "" {
|
||||
badRequest(w, errors.New("外部 IMAP 加密密钥未设置"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := a.saveSystemSettings(r.Context(), next); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to save settings")
|
||||
return
|
||||
}
|
||||
a.cfg = next
|
||||
a.setConfig(next)
|
||||
respondJSON(w, http.StatusOK, a.systemSettingsSnapshot())
|
||||
}
|
||||
|
||||
@@ -180,7 +219,7 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
cfg := a.cfg
|
||||
cfg := a.config()
|
||||
if strings.TrimSpace(cfg.SMTPHost) == "" {
|
||||
badRequest(w, errors.New("SMTP 主机未设置"))
|
||||
return
|
||||
@@ -213,7 +252,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 {
|
||||
@@ -247,33 +286,43 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) systemSettingsSnapshot() SystemSettings {
|
||||
cfg := a.config()
|
||||
return SystemSettings{
|
||||
PublicHostname: a.cfg.PublicHostname,
|
||||
PublicBaseURL: a.cfg.PublicBaseURL,
|
||||
SMTPHost: a.cfg.SMTPHost,
|
||||
SMTPPort: a.cfg.SMTPPort,
|
||||
SMTPUsername: a.cfg.SMTPUsername,
|
||||
SMTPPasswordSet: strings.TrimSpace(a.cfg.SMTPPassword) != "",
|
||||
SMTPRequireTLS: a.cfg.SMTPRequireTLS,
|
||||
MaildirRoot: a.cfg.MaildirRoot,
|
||||
MaildirScanSeconds: a.cfg.MaildirScanSeconds,
|
||||
SessionTTLHours: a.cfg.SessionTTLHours,
|
||||
AllowInsecureHTTP: a.cfg.AllowInsecureHTTP,
|
||||
OpenRegistration: a.cfg.OpenRegistration,
|
||||
TwoFactorEnabled: a.cfg.TwoFactorEnabled,
|
||||
TurnstileEnabled: a.cfg.TurnstileEnabled,
|
||||
TurnstileSiteKey: a.cfg.TurnstileSiteKey,
|
||||
TurnstileSecretSet: strings.TrimSpace(a.cfg.TurnstileSecretKey) != "",
|
||||
CatchAllEnabled: a.cfg.CatchAllEnabled,
|
||||
MailAutoRefresh: a.cfg.MailAutoRefresh,
|
||||
MailRefreshSeconds: a.cfg.MailRefreshSeconds,
|
||||
UserMailboxApplyEnabled: a.cfg.UserMailboxApplyEnabled,
|
||||
UserMailboxDomainIDs: cleanIDList(strings.Split(a.cfg.UserMailboxDomainIDs, ",")),
|
||||
ReservedMailboxPrefixes: strings.Join(parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes), "\n"),
|
||||
PublicHostname: cfg.PublicHostname,
|
||||
PublicBaseURL: cfg.PublicBaseURL,
|
||||
SMTPHost: cfg.SMTPHost,
|
||||
SMTPPort: cfg.SMTPPort,
|
||||
SMTPUsername: cfg.SMTPUsername,
|
||||
SMTPPasswordSet: strings.TrimSpace(cfg.SMTPPassword) != "",
|
||||
SMTPRequireTLS: cfg.SMTPRequireTLS,
|
||||
MaildirRoot: cfg.MaildirRoot,
|
||||
MaildirScanSeconds: cfg.MaildirScanSeconds,
|
||||
SessionTTLHours: cfg.SessionTTLHours,
|
||||
AllowInsecureHTTP: cfg.AllowInsecureHTTP,
|
||||
OpenRegistration: cfg.OpenRegistration,
|
||||
TwoFactorEnabled: cfg.TwoFactorEnabled,
|
||||
TurnstileEnabled: cfg.TurnstileEnabled,
|
||||
TurnstileSiteKey: cfg.TurnstileSiteKey,
|
||||
TurnstileSecretSet: strings.TrimSpace(cfg.TurnstileSecretKey) != "",
|
||||
CatchAllEnabled: cfg.CatchAllEnabled,
|
||||
MailAutoRefresh: cfg.MailAutoRefresh,
|
||||
MailRefreshSeconds: cfg.MailRefreshSeconds,
|
||||
UserMailboxApplyEnabled: cfg.UserMailboxApplyEnabled,
|
||||
UserMailboxDomainIDs: cleanIDList(strings.Split(cfg.UserMailboxDomainIDs, ",")),
|
||||
ReservedMailboxPrefixes: strings.Join(parseReservedPrefixes(cfg.ReservedMailboxPrefixes), "\n"),
|
||||
ExternalIMAPEnabled: cfg.ExternalIMAPEnabled,
|
||||
ExternalIMAPSecretSet: strings.TrimSpace(cfg.ExternalIMAPSecretKey) != "",
|
||||
ExternalIMAPSyncSeconds: cfg.ExternalIMAPSyncSeconds,
|
||||
ExternalIMAPAllowPrivateHosts: cfg.ExternalIMAPAllowPrivateHosts,
|
||||
ExternalIMAPGmailClientID: cfg.ExternalIMAPGmailClientID,
|
||||
ExternalIMAPGmailClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPGmailClientSecret) != "",
|
||||
ExternalIMAPOutlookClientID: cfg.ExternalIMAPOutlookClientID,
|
||||
ExternalIMAPOutlookClientSecretSet: strings.TrimSpace(cfg.ExternalIMAPOutlookClientSecret) != "",
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
|
||||
cfg := a.config()
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT key,value FROM system_settings`)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -286,84 +335,114 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
|
||||
}
|
||||
switch key {
|
||||
case "publicHostname":
|
||||
a.cfg.PublicHostname = value
|
||||
cfg.PublicHostname = value
|
||||
case "publicBaseUrl":
|
||||
a.cfg.PublicBaseURL = value
|
||||
cfg.PublicBaseURL = value
|
||||
case "smtpHost":
|
||||
a.cfg.SMTPHost = value
|
||||
cfg.SMTPHost = value
|
||||
case "smtpPort":
|
||||
a.cfg.SMTPPort = value
|
||||
cfg.SMTPPort = value
|
||||
case "smtpUsername":
|
||||
a.cfg.SMTPUsername = value
|
||||
cfg.SMTPUsername = value
|
||||
case "smtpPassword":
|
||||
a.cfg.SMTPPassword = value
|
||||
cfg.SMTPPassword = value
|
||||
case "smtpRequireTls":
|
||||
a.cfg.SMTPRequireTLS = value == "true"
|
||||
cfg.SMTPRequireTLS = value == "true"
|
||||
case "maildirRoot":
|
||||
a.cfg.MaildirRoot = value
|
||||
cfg.MaildirRoot = value
|
||||
case "maildirScanSeconds":
|
||||
if n, err := strconv.Atoi(value); err == nil && n > 0 {
|
||||
a.cfg.MaildirScanSeconds = n
|
||||
cfg.MaildirScanSeconds = n
|
||||
}
|
||||
case "sessionTtlHours":
|
||||
if n, err := strconv.Atoi(value); err == nil && n > 0 {
|
||||
a.cfg.SessionTTLHours = n
|
||||
cfg.SessionTTLHours = n
|
||||
}
|
||||
case "allowInsecureHttp":
|
||||
a.cfg.AllowInsecureHTTP = value == "true"
|
||||
cfg.AllowInsecureHTTP = value == "true"
|
||||
case "openRegistration":
|
||||
a.cfg.OpenRegistration = value == "true"
|
||||
cfg.OpenRegistration = value == "true"
|
||||
case "twoFactorEnabled":
|
||||
a.cfg.TwoFactorEnabled = value == "true"
|
||||
cfg.TwoFactorEnabled = value == "true"
|
||||
case "turnstileEnabled":
|
||||
a.cfg.TurnstileEnabled = value == "true"
|
||||
cfg.TurnstileEnabled = value == "true"
|
||||
case "turnstileSiteKey":
|
||||
a.cfg.TurnstileSiteKey = value
|
||||
cfg.TurnstileSiteKey = value
|
||||
case "turnstileSecretKey":
|
||||
a.cfg.TurnstileSecretKey = value
|
||||
cfg.TurnstileSecretKey = value
|
||||
case "catchAllEnabled":
|
||||
a.cfg.CatchAllEnabled = value == "true"
|
||||
cfg.CatchAllEnabled = value == "true"
|
||||
case "mailAutoRefresh":
|
||||
a.cfg.MailAutoRefresh = value == "true"
|
||||
cfg.MailAutoRefresh = value == "true"
|
||||
case "mailRefreshSeconds":
|
||||
if n, err := strconv.Atoi(value); err == nil && n > 0 {
|
||||
a.cfg.MailRefreshSeconds = n
|
||||
cfg.MailRefreshSeconds = n
|
||||
}
|
||||
case "userMailboxApplyEnabled":
|
||||
a.cfg.UserMailboxApplyEnabled = value == "true"
|
||||
cfg.UserMailboxApplyEnabled = value == "true"
|
||||
case "userMailboxDomainIds":
|
||||
a.cfg.UserMailboxDomainIDs = value
|
||||
cfg.UserMailboxDomainIDs = value
|
||||
case "reservedMailboxPrefixes":
|
||||
a.cfg.ReservedMailboxPrefixes = value
|
||||
cfg.ReservedMailboxPrefixes = value
|
||||
case "externalImapEnabled":
|
||||
cfg.ExternalIMAPEnabled = value == "true"
|
||||
case "externalImapSecretKey":
|
||||
cfg.ExternalIMAPSecretKey = value
|
||||
case "externalImapSyncSeconds":
|
||||
if n, err := strconv.Atoi(value); err == nil && n > 0 {
|
||||
cfg.ExternalIMAPSyncSeconds = n
|
||||
}
|
||||
case "externalImapAllowPrivateHosts":
|
||||
cfg.ExternalIMAPAllowPrivateHosts = value == "true"
|
||||
case "externalImapGmailClientId":
|
||||
cfg.ExternalIMAPGmailClientID = value
|
||||
case "externalImapGmailClientSecret":
|
||||
cfg.ExternalIMAPGmailClientSecret = value
|
||||
case "externalImapOutlookClientId":
|
||||
cfg.ExternalIMAPOutlookClientID = value
|
||||
case "externalImapOutlookClientSecret":
|
||||
cfg.ExternalIMAPOutlookClientSecret = value
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
a.setConfig(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
|
||||
values := map[string]string{
|
||||
"publicHostname": cfg.PublicHostname,
|
||||
"publicBaseUrl": cfg.PublicBaseURL,
|
||||
"smtpHost": cfg.SMTPHost,
|
||||
"smtpPort": cfg.SMTPPort,
|
||||
"smtpUsername": cfg.SMTPUsername,
|
||||
"smtpPassword": cfg.SMTPPassword,
|
||||
"smtpRequireTls": strconv.FormatBool(cfg.SMTPRequireTLS),
|
||||
"maildirRoot": cfg.MaildirRoot,
|
||||
"maildirScanSeconds": strconv.Itoa(cfg.MaildirScanSeconds),
|
||||
"sessionTtlHours": strconv.Itoa(cfg.SessionTTLHours),
|
||||
"allowInsecureHttp": strconv.FormatBool(cfg.AllowInsecureHTTP),
|
||||
"openRegistration": strconv.FormatBool(cfg.OpenRegistration),
|
||||
"twoFactorEnabled": strconv.FormatBool(cfg.TwoFactorEnabled),
|
||||
"turnstileEnabled": strconv.FormatBool(cfg.TurnstileEnabled),
|
||||
"turnstileSiteKey": cfg.TurnstileSiteKey,
|
||||
"turnstileSecretKey": cfg.TurnstileSecretKey,
|
||||
"catchAllEnabled": strconv.FormatBool(cfg.CatchAllEnabled),
|
||||
"mailAutoRefresh": strconv.FormatBool(cfg.MailAutoRefresh),
|
||||
"mailRefreshSeconds": strconv.Itoa(cfg.MailRefreshSeconds),
|
||||
"userMailboxApplyEnabled": strconv.FormatBool(cfg.UserMailboxApplyEnabled),
|
||||
"userMailboxDomainIds": strings.Join(cleanIDList(strings.Split(cfg.UserMailboxDomainIDs, ",")), ","),
|
||||
"reservedMailboxPrefixes": strings.Join(parseReservedPrefixes(cfg.ReservedMailboxPrefixes), ","),
|
||||
"publicHostname": cfg.PublicHostname,
|
||||
"publicBaseUrl": cfg.PublicBaseURL,
|
||||
"smtpHost": cfg.SMTPHost,
|
||||
"smtpPort": cfg.SMTPPort,
|
||||
"smtpUsername": cfg.SMTPUsername,
|
||||
"smtpPassword": cfg.SMTPPassword,
|
||||
"smtpRequireTls": strconv.FormatBool(cfg.SMTPRequireTLS),
|
||||
"maildirRoot": cfg.MaildirRoot,
|
||||
"maildirScanSeconds": strconv.Itoa(cfg.MaildirScanSeconds),
|
||||
"sessionTtlHours": strconv.Itoa(cfg.SessionTTLHours),
|
||||
"allowInsecureHttp": strconv.FormatBool(cfg.AllowInsecureHTTP),
|
||||
"openRegistration": strconv.FormatBool(cfg.OpenRegistration),
|
||||
"twoFactorEnabled": strconv.FormatBool(cfg.TwoFactorEnabled),
|
||||
"turnstileEnabled": strconv.FormatBool(cfg.TurnstileEnabled),
|
||||
"turnstileSiteKey": cfg.TurnstileSiteKey,
|
||||
"turnstileSecretKey": cfg.TurnstileSecretKey,
|
||||
"catchAllEnabled": strconv.FormatBool(cfg.CatchAllEnabled),
|
||||
"mailAutoRefresh": strconv.FormatBool(cfg.MailAutoRefresh),
|
||||
"mailRefreshSeconds": strconv.Itoa(cfg.MailRefreshSeconds),
|
||||
"userMailboxApplyEnabled": strconv.FormatBool(cfg.UserMailboxApplyEnabled),
|
||||
"userMailboxDomainIds": strings.Join(cleanIDList(strings.Split(cfg.UserMailboxDomainIDs, ",")), ","),
|
||||
"reservedMailboxPrefixes": strings.Join(parseReservedPrefixes(cfg.ReservedMailboxPrefixes), ","),
|
||||
"externalImapEnabled": strconv.FormatBool(cfg.ExternalIMAPEnabled),
|
||||
"externalImapSecretKey": cfg.ExternalIMAPSecretKey,
|
||||
"externalImapSyncSeconds": strconv.Itoa(cfg.ExternalIMAPSyncSeconds),
|
||||
"externalImapAllowPrivateHosts": strconv.FormatBool(cfg.ExternalIMAPAllowPrivateHosts),
|
||||
"externalImapGmailClientId": cfg.ExternalIMAPGmailClientID,
|
||||
"externalImapGmailClientSecret": cfg.ExternalIMAPGmailClientSecret,
|
||||
"externalImapOutlookClientId": cfg.ExternalIMAPOutlookClientID,
|
||||
"externalImapOutlookClientSecret": cfg.ExternalIMAPOutlookClientSecret,
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
tx, err := a.db.BeginTx(ctx, 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.config().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.config().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.config().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.config().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.config().StatusWebhookSecret) == "" {
|
||||
return nil, errors.New("LANQIN_STATUS_WEBHOOK_SECRET is required")
|
||||
}
|
||||
target, err := url.Parse(strings.TrimSpace(a.config().StatusWebhookURL))
|
||||
if err != nil || target.Hostname() == "" || target.User != nil || target.Fragment != "" {
|
||||
return nil, errors.New("invalid status webhook URL")
|
||||
}
|
||||
if target.Scheme != "https" && !(a.config().StatusWebhookAllowPrivateHosts && target.Scheme == "http") {
|
||||
return nil, errors.New("status webhook URL must use HTTPS")
|
||||
}
|
||||
if !a.config().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.config().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
|
||||
}
|
||||
@@ -49,8 +49,8 @@ func (s *SubmissionServers) Shutdown(ctx context.Context) error {
|
||||
|
||||
func (a *App) NewSubmissionServers(tlsConfig *tls.Config) *SubmissionServers {
|
||||
return &SubmissionServers{
|
||||
Plain: a.newSubmissionServer(a.cfg.SubmissionAddr, tlsConfig),
|
||||
TLS: a.newSubmissionServer(a.cfg.SubmissionTLSAddr, tlsConfig),
|
||||
Plain: a.newSubmissionServer(a.config().SubmissionAddr, tlsConfig),
|
||||
TLS: a.newSubmissionServer(a.config().SubmissionTLSAddr, tlsConfig),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,11 +61,11 @@ func (a *App) newSubmissionServer(addr string, tlsConfig *tls.Config) *smtpserve
|
||||
}
|
||||
s := smtpserver.NewServer(submissionBackend{app: a})
|
||||
s.Addr = addr
|
||||
s.Domain = a.cfg.PublicHostname
|
||||
s.Domain = a.config().PublicHostname
|
||||
s.TLSConfig = tlsConfig
|
||||
s.AllowInsecureAuth = false
|
||||
s.MaxRecipients = defaultSubmissionMaxRecipients
|
||||
s.MaxMessageBytes = int64(a.cfg.SubmissionMaxMessageMB) * 1024 * 1024
|
||||
s.MaxMessageBytes = int64(a.config().SubmissionMaxMessageMB) * 1024 * 1024
|
||||
s.ReadTimeout = smtpSessionTimeout
|
||||
s.WriteTimeout = smtpSessionTimeout
|
||||
s.ErrorLog = log.New(submissionLogWriter{log: a.log}, "smtp/submission ", 0)
|
||||
|
||||
@@ -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.config().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.config().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.config().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.config().UpdateServiceURL) != "" && strings.TrimSpace(a.config().UpdateServiceToken) != ""
|
||||
}
|
||||
|
||||
func (a *App) triggerUpdateService(ctx context.Context) error {
|
||||
parsed, err := url.Parse(strings.TrimSpace(a.config().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.config().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.config().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,
|
||||
|
||||
@@ -17,11 +17,11 @@ type turnstileVerifyResponse struct {
|
||||
}
|
||||
|
||||
func (a *App) verifyTurnstile(ctx context.Context, token, remoteIP string) error {
|
||||
if !a.cfg.TurnstileEnabled {
|
||||
if !a.config().TurnstileEnabled {
|
||||
return nil
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
secret := strings.TrimSpace(a.cfg.TurnstileSecretKey)
|
||||
secret := strings.TrimSpace(a.config().TurnstileSecretKey)
|
||||
if secret == "" || token == "" {
|
||||
return errors.New("turnstile verification required")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -142,7 +144,7 @@ func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, e
|
||||
}
|
||||
|
||||
func (a *App) handleTwoFactorSetup(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.cfg.TwoFactorEnabled {
|
||||
if !a.config().TwoFactorEnabled {
|
||||
respondError(w, http.StatusBadRequest, "双因素认证已关闭")
|
||||
return
|
||||
}
|
||||
@@ -172,12 +174,12 @@ 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),
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) handleTwoFactorEnable(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.cfg.TwoFactorEnabled {
|
||||
if !a.config().TwoFactorEnabled {
|
||||
respondError(w, http.StatusBadRequest, "双因素认证已关闭")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -60,6 +88,7 @@ type MailFolder struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
UnreadCount int `json:"unreadCount"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
UIDValidity int64 `json:"uidValidity"`
|
||||
@@ -76,35 +105,38 @@ type MailLabel struct {
|
||||
}
|
||||
|
||||
type MailMessage struct {
|
||||
ID string `json:"id"`
|
||||
MailboxID string `json:"mailboxId,omitempty"`
|
||||
MailboxAddress string `json:"mailboxAddress,omitempty"`
|
||||
OwnerEmail string `json:"ownerEmail,omitempty"`
|
||||
RecipientAddr string `json:"recipientAddress,omitempty"`
|
||||
FolderID string `json:"folderId"`
|
||||
Folder string `json:"folder"`
|
||||
MessageUID string `json:"messageUid"`
|
||||
IMAPUID int64 `json:"imapUid"`
|
||||
IMAPModSeq int64 `json:"imapModseq"`
|
||||
MessageID string `json:"messageId"`
|
||||
Subject string `json:"subject"`
|
||||
From string `json:"from"`
|
||||
FromName string `json:"fromName,omitempty"`
|
||||
To []string `json:"to"`
|
||||
CC []string `json:"cc"`
|
||||
BCC []string `json:"bcc,omitempty"`
|
||||
SentAt time.Time `json:"sentAt"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
Snippet string `json:"snippet"`
|
||||
BodyText string `json:"bodyText,omitempty"`
|
||||
BodyHTML string `json:"bodyHtml,omitempty"`
|
||||
IsRead bool `json:"isRead"`
|
||||
IsStarred bool `json:"isStarred"`
|
||||
HasAttachments bool `json:"hasAttachments"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Labels []MailLabel `json:"labels,omitempty"`
|
||||
Attachments []Attachment `json:"attachments,omitempty"`
|
||||
Authentication MailAuthentication `json:"authentication"`
|
||||
ID string `json:"id"`
|
||||
MailboxID string `json:"mailboxId,omitempty"`
|
||||
MailboxAddress string `json:"mailboxAddress,omitempty"`
|
||||
OwnerEmail string `json:"ownerEmail,omitempty"`
|
||||
RecipientAddr string `json:"recipientAddress,omitempty"`
|
||||
FolderID string `json:"folderId"`
|
||||
Folder string `json:"folder"`
|
||||
MessageUID string `json:"messageUid"`
|
||||
IMAPUID int64 `json:"imapUid"`
|
||||
IMAPModSeq int64 `json:"imapModseq"`
|
||||
MessageID string `json:"messageId"`
|
||||
Subject string `json:"subject"`
|
||||
From string `json:"from"`
|
||||
FromName string `json:"fromName,omitempty"`
|
||||
To []string `json:"to"`
|
||||
CC []string `json:"cc"`
|
||||
BCC []string `json:"bcc,omitempty"`
|
||||
SentAt time.Time `json:"sentAt"`
|
||||
ReceivedAt time.Time `json:"receivedAt"`
|
||||
Snippet string `json:"snippet"`
|
||||
BodyText string `json:"bodyText,omitempty"`
|
||||
BodyHTML string `json:"bodyHtml,omitempty"`
|
||||
IsRead bool `json:"isRead"`
|
||||
IsStarred bool `json:"isStarred"`
|
||||
HasAttachments bool `json:"hasAttachments"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Labels []MailLabel `json:"labels,omitempty"`
|
||||
Attachments []Attachment `json:"attachments,omitempty"`
|
||||
Authentication MailAuthentication `json:"authentication"`
|
||||
SendQueueID string `json:"sendQueueId,omitempty"`
|
||||
SendQueueStatus string `json:"sendQueueStatus,omitempty"`
|
||||
ExternalAccountID string `json:"externalAccountId,omitempty"`
|
||||
}
|
||||
|
||||
type MailAuthentication struct {
|
||||
@@ -205,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 {
|
||||
@@ -224,6 +265,66 @@ 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"`
|
||||
MailboxID string `json:"mailboxId"`
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
TLSMode string `json:"tlsMode"`
|
||||
Username string `json:"username"`
|
||||
AuthMode string `json:"authMode"`
|
||||
OAuthProvider string `json:"oauthProvider,omitempty"`
|
||||
OAuthEmail string `json:"oauthEmail,omitempty"`
|
||||
OAuthConfigured bool `json:"oauthConfigured,omitempty"`
|
||||
StorageMode string `json:"storageMode"`
|
||||
SyncReadState bool `json:"syncReadState"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LastSyncAt *time.Time `json:"lastSyncAt,omitempty"`
|
||||
LastStatus string `json:"lastStatus"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type ExternalIMAPFolder struct {
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
UnreadCount int `json:"unreadCount"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
}
|
||||
|
||||
type ExternalIMAPSyncRun struct {
|
||||
ID string `json:"id"`
|
||||
AccountID string `json:"accountId"`
|
||||
Folder string `json:"folder,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
Error string `json:"error,omitempty"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
}
|
||||
|
||||
type SendQueueEntry struct {
|
||||
ID string `json:"id"`
|
||||
MailboxID string `json:"mailboxId"`
|
||||
|
||||
@@ -14,12 +14,19 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
)
|
||||
|
||||
type HTMLPolicy struct{ policy *bluemonday.Policy }
|
||||
|
||||
const minimumPasswordLength = 6
|
||||
|
||||
func hasMinimumPasswordLength(password string) bool {
|
||||
return utf8.RuneCountInString(password) >= minimumPasswordLength
|
||||
}
|
||||
|
||||
func NewHTMLPolicy() *HTMLPolicy {
|
||||
p := bluemonday.UGCPolicy()
|
||||
p.AllowElements("html", "head", "body", "center", "font")
|
||||
@@ -53,6 +60,7 @@ func (p *HTMLPolicy) Sanitize(s string) string {
|
||||
}
|
||||
|
||||
var emailStyleTagRe = regexp.MustCompile(`(?is)<style\b([^>]*)>(.*?)</style>`)
|
||||
var htmlNonContentTagRe = regexp.MustCompile(`(?is)<(style|script|head|title|noscript)\b[^>]*>.*?</\s*(style|script|head|title|noscript)\s*>`)
|
||||
|
||||
func extractSafeEmailStyles(value string) ([]string, string) {
|
||||
styles := []string{}
|
||||
@@ -148,6 +156,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))
|
||||
@@ -214,6 +271,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
|
||||
@@ -241,6 +313,7 @@ func snippetFrom(text, html string) string {
|
||||
}
|
||||
|
||||
func stripTags(s string) string {
|
||||
s = htmlNonContentTagRe.ReplaceAllString(s, " ")
|
||||
var b strings.Builder
|
||||
inTag := false
|
||||
for _, r := range s {
|
||||
|
||||
@@ -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",
|
||||
@@ -26,27 +26,22 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.9",
|
||||
"@tanstack/react-query": "5.59.16",
|
||||
"@tiptap/core": "^3.27.0",
|
||||
"@tiptap/extension-color": "^3.27.0",
|
||||
"@tiptap/extension-font-family": "^3.27.0",
|
||||
"@tiptap/extension-highlight": "^3.27.0",
|
||||
"@tiptap/extension-image": "^3.27.0",
|
||||
"@tiptap/extension-link": "^3.27.0",
|
||||
"@tiptap/extension-placeholder": "^3.27.0",
|
||||
"@tiptap/extension-text-align": "^3.27.0",
|
||||
"@tiptap/extension-text-style": "^3.27.0",
|
||||
"@tiptap/extension-underline": "^3.27.0",
|
||||
"@tiptap/pm": "^3.27.0",
|
||||
"@tiptap/react": "^3.27.0",
|
||||
"@tiptap/starter-kit": "^3.27.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "2.1.1",
|
||||
"dompurify": "3.4.10",
|
||||
"dompurify": "3.4.12",
|
||||
"lucide-react": "^0.468.0",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-router-dom": "6.30.4",
|
||||
"react-router-dom": "7.18.2",
|
||||
"tailwind-merge": "2.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -55,7 +50,7 @@
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
"autoprefixer": "10.4.20",
|
||||
"postcss": "8.5.15",
|
||||
"postcss": "8.5.25",
|
||||
"tailwindcss": "3.4.15",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "5.6.3",
|
||||
|
||||
@@ -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,4 +1,3 @@
|
||||
import * as React from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
|
||||
|
||||
@@ -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-3">
|
||||
<div className="border-b pb-3 text-center">
|
||||
<div className="text-xs text-muted-foreground">当前版本</div>
|
||||
<div className="mt-1 text-3xl font-semibold tabular-nums">{currentVersion}</div>
|
||||
{version.data?.latestVersion && <div className="mt-1 text-xs 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="h-[clamp(12rem,30svh,18rem)] overflow-y-auto whitespace-pre-wrap rounded-md border bg-muted/20 p-4 text-sm leading-6 text-foreground/80">
|
||||
{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))
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { GripVertical } from "lucide-react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ResizablePanelGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
const ResizablePanel = ResizablePrimitive.Panel
|
||||
|
||||
const ResizableHandle = ({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
)
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
@@ -48,7 +48,7 @@ const Toast = React.forwardRef<
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
className={cn(toastVariants({ variant }), props.onClick && "cursor-pointer", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -76,6 +76,10 @@ const ToastClose = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onClick?.(event)
|
||||
}}
|
||||
className={cn(
|
||||
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
|
||||
@@ -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,27 +47,30 @@ 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; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number }
|
||||
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 }
|
||||
export type MailLabel = { id: string; mailboxId?: string; name: string; color: string; messageCount?: number }
|
||||
export type MailAuthentication = { authenticationResults: string; receivedSpf: string; spf: string; dkim: string; dmarc: string }
|
||||
export type MailMessage = {
|
||||
id: string; mailboxId?: string; mailboxAddress?: string; ownerEmail?: string; recipientAddress?: string; folderId: string; folder: string; messageUid: string; imapUid: number; imapModseq: number; messageId: string; subject: string; from: string; fromName?: string; to: string[]; cc: string[]; bcc?: string[]; sentAt: string; receivedAt: string; snippet: string; bodyText?: string; bodyHtml?: string; isRead: boolean; isStarred: boolean; hasAttachments: boolean; sizeBytes: number; attachments?: Attachment[]
|
||||
labels?: MailLabel[]
|
||||
authentication?: MailAuthentication
|
||||
sendQueueId?: string
|
||||
sendQueueStatus?: SendQueueStatus
|
||||
externalAccountId?: string
|
||||
}
|
||||
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; 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 }
|
||||
@@ -118,10 +121,51 @@ 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"
|
||||
export type ExternalImapAccount = { id: string; mailboxId: string; name: string; host: string; port: number; tlsMode: ExternalImapTlsMode; username: string; authMode: ExternalImapAuthMode; oauthProvider?: ExternalImapOAuthProvider; oauthEmail?: string; oauthConfigured?: boolean; storageMode: ExternalImapStorageMode; syncReadState: boolean; enabled: boolean; lastSyncAt?: string; lastStatus: string; lastError?: string; createdAt: string; updatedAt: string }
|
||||
export type ExternalImapAccountPayload = { mailboxId: string; name: string; host: string; port: number; tlsMode: ExternalImapTlsMode; username: string; password?: string; storageMode: ExternalImapStorageMode; syncReadState: boolean; enabled: boolean }
|
||||
export type ExternalImapOAuthProvider = "gmail" | "outlook"
|
||||
export type ExternalImapOAuthStartPayload = { mailboxId: string; name?: string; email?: string; storageMode: ExternalImapStorageMode; syncReadState: boolean; enabled: boolean }
|
||||
export type ExternalImapFolder = { name: string; role: string; unreadCount: number; totalCount: number }
|
||||
export type ExternalImapSyncRun = { id: string; accountId: string; folder?: string; status: string; imported: number; skipped: number; failed: number; error?: string; startedAt: string; finishedAt?: string }
|
||||
export type MailTemplate = { key: string; name: string; subject: string; bodyText: string; bodyHtml: string; updatedAt: string }
|
||||
export type MailboxApplyOptions = { enabled: boolean; domains: Domain[]; reservedPrefixes?: string[] }
|
||||
export type MaildirSyncCounts = { filesScanned: number; imported: number; backfilled: number; cleaned: number; fileErrors: number }
|
||||
@@ -139,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
|
||||
@@ -162,10 +225,18 @@ export type SystemSettings = {
|
||||
userMailboxApplyEnabled: boolean
|
||||
userMailboxDomainIds: string[]
|
||||
reservedMailboxPrefixes: string
|
||||
externalImapEnabled: boolean
|
||||
externalImapSecretSet: boolean
|
||||
externalImapSyncSeconds: number
|
||||
externalImapAllowPrivateHosts: boolean
|
||||
externalImapGmailClientId: string
|
||||
externalImapGmailClientSecretSet: boolean
|
||||
externalImapOutlookClientId: string
|
||||
externalImapOutlookClientSecretSet: boolean
|
||||
}
|
||||
export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet"> & { smtpPassword: string; turnstileSecretKey: string }
|
||||
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; mailboxDomains?: PublicDomain[] }
|
||||
export type LoginPayload = { email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string }
|
||||
export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; publicHostname: string; mailAutoRefresh: boolean; mailRefreshMs: number; externalImapEnabled: boolean; mailboxDomains?: PublicDomain[] }
|
||||
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, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, 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, 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,38 @@ 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) }),
|
||||
deleteExternalImapAccount: (id: string) => request<{ ok: boolean }>(`/api/me/external-imap-accounts/${id}`, { method: "DELETE" }),
|
||||
startExternalImapOAuth: (provider: ExternalImapOAuthProvider, payload: ExternalImapOAuthStartPayload) => request<{ url: string }>(`/api/me/external-imap-oauth/${provider}/start`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
testExternalImapAccount: (id: string) => request<{ ok: boolean; folders: number }>(`/api/me/external-imap-accounts/${id}/test`, { method: "POST", timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||
externalImapSyncRuns: (id: string) => request<ListResponse<ExternalImapSyncRun>>(`/api/me/external-imap-accounts/${id}/runs`),
|
||||
syncExternalImapAccount: (id: string) => request<ExternalImapSyncRun>(`/api/me/external-imap-accounts/${id}/sync`, { method: "POST", timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||
syncExternalImapFolder: (id: string, folder: string) => request<ExternalImapSyncRun>(`/api/me/external-imap-accounts/${id}/sync-folder`, { method: "POST", body: JSON.stringify({ folder }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||
adminOverview: () => request<AdminOverview>("/api/admin/overview"),
|
||||
users: () => request<ListResponse<AdminUser>>("/api/admin/users"),
|
||||
permissionGroups: () => request<ListResponse<PermissionGroup> & { catalog: PermissionInfo[] }>("/api/admin/permission-groups"),
|
||||
@@ -68,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"),
|
||||
@@ -77,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"),
|
||||
@@ -105,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) }),
|
||||
@@ -115,38 +212,78 @@ export const api = {
|
||||
dnsRecords: (domainId: string) => request<{ items: DNSRecord[] }>(`/api/admin/domains/${domainId}/dns-records`),
|
||||
checkDns: (domainId: string) => request<DNSCheckResult>(`/api/admin/domains/${domainId}/check-dns`, { method: "POST" }),
|
||||
myMailboxes: () => request<ListResponse<Mailbox>>("/api/mail/mailboxes"),
|
||||
externalMailAccounts: () => request<ListResponse<ExternalImapAccount>>("/api/mail/external-accounts"),
|
||||
externalFolders: (id: string) => request<ListResponse<ExternalImapFolder>>(`/api/mail/external-accounts/${id}/folders`),
|
||||
externalMessages: (id: string, folder: string, cursor = "", q = "") => {
|
||||
const params = new URLSearchParams({ folder, cursor, q })
|
||||
return request<ListResponse<MailMessage>>(`/api/mail/external-accounts/${id}/messages?${params.toString()}`)
|
||||
},
|
||||
externalMessage: (id: string, remoteId: string) => request<MailMessage>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}`),
|
||||
markExternalRead: (id: string, remoteId: string, read: boolean) => request<{ ok: boolean }>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}/mark-read`, { method: "POST", body: JSON.stringify({ read }) }),
|
||||
folders: (mailboxId?: string) => request<ListResponse<MailFolder>>(`/api/mail/folders${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
|
||||
createFolder: (payload: { mailboxId?: string; name: string }) => {
|
||||
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
|
||||
return request<MailFolder>(`/api/mail/folders${query}`, { method: "POST", body: JSON.stringify({ name: payload.name }) })
|
||||
},
|
||||
reorderFolders: (payload: { mailboxId?: string; folderIds: string[]; folders?: { id: string; sortOrder: number }[] }) => {
|
||||
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
|
||||
return request<{ ok: boolean }>(`/api/mail/folders/reorder${query}`, { method: "POST", body: JSON.stringify(payload.folders ? { folders: payload.folders } : { folderIds: payload.folderIds }) })
|
||||
},
|
||||
deleteFolder: (id: string, mailboxId?: string) => request<{ ok: boolean; moved: number }>(`/api/mail/folders/${id}${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`, { method: "DELETE" }),
|
||||
labels: (mailboxId?: string) => request<ListResponse<MailLabel>>(`/api/mail/labels${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
|
||||
createLabel: (payload: { mailboxId?: string; name: string; color?: string }) => {
|
||||
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
|
||||
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 }),
|
||||
send: (payload: SendPayload) => request<MailMessage>("/api/mail/send", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||
scheduledSends: (mailboxId?: string) => request<ListResponse<ScheduledSend>>(`/api/mail/scheduled-sends${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
|
||||
scheduleSend: (payload: ScheduleSendPayload) => request<ScheduledSend>("/api/mail/schedule-send", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||
cancelScheduledSend: (id: string) => request<{ ok: boolean }>(`/api/mail/schedule-send/${id}`, { method: "DELETE" }),
|
||||
sendQueue: (params: { mailboxId?: string; status?: SendQueueStatus | "all"; cursor?: string } = {}) => {
|
||||
sendQueue: (params: { mailboxId?: string; status?: SendQueueStatus | "all"; cursor?: string; messageId?: string; recipient?: string; from?: string; to?: string } = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params.mailboxId) query.set("mailboxId", params.mailboxId)
|
||||
if (params.status && params.status !== "all") query.set("status", params.status)
|
||||
if (params.cursor) query.set("cursor", params.cursor)
|
||||
if (params.messageId) query.set("messageId", params.messageId)
|
||||
if (params.recipient) query.set("recipient", params.recipient)
|
||||
if (params.from) query.set("from", params.from)
|
||||
if (params.to) query.set("to", params.to)
|
||||
const suffix = query.toString()
|
||||
return request<ListResponse<SendQueueItem>>(`/api/mail/send-queue${suffix ? `?${suffix}` : ""}`)
|
||||
},
|
||||
@@ -162,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" }),
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
export type Language = "zh-CN" | "zh-TW" | "en"
|
||||
|
||||
export const LANGUAGE_STORAGE_KEY = "lanqin:language"
|
||||
|
||||
export const languageOptions: { value: Language; label: string; shortLabel: string; htmlLang: string }[] = [
|
||||
{ value: "zh-CN", label: "简体中文", shortLabel: "简", htmlLang: "zh-CN" },
|
||||
{ value: "zh-TW", label: "繁體中文", shortLabel: "繁", htmlLang: "zh-TW" },
|
||||
{ value: "en", label: "English", shortLabel: "EN", htmlLang: "en" },
|
||||
]
|
||||
|
||||
const languageValues = new Set(languageOptions.map((item) => item.value))
|
||||
|
||||
export function getInitialLanguage(): Language {
|
||||
if (typeof window === "undefined") return "zh-CN"
|
||||
const stored = window.localStorage.getItem(LANGUAGE_STORAGE_KEY)
|
||||
if (stored && languageValues.has(stored as Language)) return stored as Language
|
||||
const browserLanguage = window.navigator.language.toLowerCase()
|
||||
if (browserLanguage.startsWith("zh-tw") || browserLanguage.startsWith("zh-hk") || browserLanguage.startsWith("zh-mo")) return "zh-TW"
|
||||
if (browserLanguage.startsWith("en")) return "en"
|
||||
return "zh-CN"
|
||||
}
|
||||
|
||||
export function setStoredLanguage(language: Language) {
|
||||
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, language)
|
||||
window.dispatchEvent(new CustomEvent("lanqin:language", { detail: language }))
|
||||
}
|
||||
|
||||
export function useLanguage() {
|
||||
const [language, setLanguageState] = React.useState<Language>(getInitialLanguage)
|
||||
React.useEffect(() => {
|
||||
function sync() {
|
||||
setLanguageState(getInitialLanguage())
|
||||
}
|
||||
window.addEventListener("storage", sync)
|
||||
window.addEventListener("lanqin:language", sync)
|
||||
return () => {
|
||||
window.removeEventListener("storage", sync)
|
||||
window.removeEventListener("lanqin:language", sync)
|
||||
}
|
||||
}, [])
|
||||
const setLanguage = React.useCallback((nextLanguage: Language) => {
|
||||
setStoredLanguage(nextLanguage)
|
||||
setLanguageState(nextLanguage)
|
||||
}, [])
|
||||
return [language, setLanguage] as const
|
||||
}
|
||||
|
||||
type Translation = { "zh-TW": string; en: string }
|
||||
|
||||
const exactTranslations: Record<string, Translation> = {
|
||||
"收件箱": { "zh-TW": "收件匣", en: "Inbox" },
|
||||
"已发送": { "zh-TW": "已傳送", en: "Sent" },
|
||||
"草稿箱": { "zh-TW": "草稿匣", en: "Drafts" },
|
||||
"归档": { "zh-TW": "封存", en: "Archive" },
|
||||
"垃圾邮件": { "zh-TW": "垃圾郵件", en: "Spam" },
|
||||
"回收站": { "zh-TW": "垃圾桶", en: "Trash" },
|
||||
"全部邮件": { "zh-TW": "全部郵件", en: "All mail" },
|
||||
"未读邮件": { "zh-TW": "未讀郵件", en: "Unread" },
|
||||
"星标邮件": { "zh-TW": "星號郵件", en: "Starred" },
|
||||
"有附件": { "zh-TW": "有附件", en: "Has attachments" },
|
||||
"切换语言": { "zh-TW": "切換語言", en: "Switch language" },
|
||||
"写邮件": { "zh-TW": "寫郵件", en: "Compose" },
|
||||
"邮件夹": { "zh-TW": "郵件匣", en: "Folders" },
|
||||
"外部邮箱": { "zh-TW": "外部信箱", en: "External mailbox" },
|
||||
"标签": { "zh-TW": "標籤", en: "Labels" },
|
||||
"暂无标签": { "zh-TW": "暫無標籤", en: "No labels" },
|
||||
"收起侧栏": { "zh-TW": "收合側欄", en: "Collapse sidebar" },
|
||||
"选择邮箱": { "zh-TW": "選擇信箱", en: "Select mailbox" },
|
||||
"加载邮箱...": { "zh-TW": "載入信箱...", en: "Loading mailboxes..." },
|
||||
"未创建邮箱": { "zh-TW": "尚未建立信箱", en: "No mailbox created" },
|
||||
"没有可用邮箱": { "zh-TW": "沒有可用信箱", en: "No mailboxes available" },
|
||||
"邮箱地址已复制": { "zh-TW": "信箱地址已複製", en: "Mailbox address copied" },
|
||||
"打开导航": { "zh-TW": "開啟導覽", en: "Open navigation" },
|
||||
"邮箱导航": { "zh-TW": "信箱導覽", en: "Mailbox navigation" },
|
||||
"刷新邮件": { "zh-TW": "重新整理郵件", en: "Refresh mail" },
|
||||
"自动刷新中": { "zh-TW": "自動重新整理中", en: "Auto-refreshing" },
|
||||
"自动刷新中...": { "zh-TW": "自動重新整理中...", en: "Auto-refreshing..." },
|
||||
"自动刷新已开启": { "zh-TW": "自動重新整理已開啟", en: "Auto-refresh enabled" },
|
||||
"全部已读": { "zh-TW": "全部已讀", en: "Mark all read" },
|
||||
"搜索邮件": { "zh-TW": "搜尋郵件", en: "Search mail" },
|
||||
"搜索远端邮件": { "zh-TW": "搜尋遠端郵件", en: "Search remote mail" },
|
||||
"搜索发送队列": { "zh-TW": "搜尋傳送佇列", en: "Search send queue" },
|
||||
"搜索待发送": { "zh-TW": "搜尋待傳送", en: "Search scheduled mail" },
|
||||
"选择当前页邮件": { "zh-TW": "選擇目前頁面的郵件", en: "Select messages on this page" },
|
||||
"加载中...": { "zh-TW": "載入中...", en: "Loading..." },
|
||||
"加载更多": { "zh-TW": "載入更多", en: "Load more" },
|
||||
"选择一封邮件阅读": { "zh-TW": "選擇一封郵件閱讀", en: "Select a message to read" },
|
||||
"回复": { "zh-TW": "回覆", en: "Reply" },
|
||||
"转发": { "zh-TW": "轉寄", en: "Forward" },
|
||||
"投递时间线": { "zh-TW": "投遞時間軸", en: "Delivery timeline" },
|
||||
"取消归档": { "zh-TW": "取消封存", en: "Unarchive" },
|
||||
"删除": { "zh-TW": "刪除", en: "Delete" },
|
||||
"附件": { "zh-TW": "附件", en: "Attachments" },
|
||||
"确认": { "zh-TW": "確認", en: "Confirm" },
|
||||
"取消": { "zh-TW": "取消", en: "Cancel" },
|
||||
"关闭": { "zh-TW": "關閉", en: "Close" },
|
||||
"打开": { "zh-TW": "開啟", en: "Open" },
|
||||
"刷新": { "zh-TW": "重新整理", en: "Refresh" },
|
||||
"新建文件夹": { "zh-TW": "新增資料夾", en: "New folder" },
|
||||
"文件夹名称": { "zh-TW": "資料夾名稱", en: "Folder name" },
|
||||
"创建": { "zh-TW": "建立", en: "Create" },
|
||||
"创建中...": { "zh-TW": "建立中...", en: "Creating..." },
|
||||
"例如:客户、账单、项目归档": { "zh-TW": "例如:客戶、帳單、專案封存", en: "e.g. Clients, invoices, project archive" },
|
||||
"新建标签": { "zh-TW": "新增標籤", en: "New label" },
|
||||
"删除标签失败": { "zh-TW": "刪除標籤失敗", en: "Failed to delete label" },
|
||||
"添加标签失败": { "zh-TW": "新增標籤失敗", en: "Failed to add label" },
|
||||
"移除标签失败": { "zh-TW": "移除標籤失敗", en: "Failed to remove label" },
|
||||
"创建标签失败": { "zh-TW": "建立標籤失敗", en: "Failed to create label" },
|
||||
"添加标签": { "zh-TW": "新增標籤", en: "Add label" },
|
||||
"请先在侧栏新建标签": { "zh-TW": "請先在側欄新增標籤", en: "Create a label in the sidebar first" },
|
||||
"无": { "zh-TW": "無", en: "None" },
|
||||
"无主题": { "zh-TW": "無主旨", en: "No subject" },
|
||||
"(无主题)": { "zh-TW": "(無主旨)", en: "(No subject)" },
|
||||
"未知发件人": { "zh-TW": "未知寄件者", en: "Unknown sender" },
|
||||
"未知发件人地址": { "zh-TW": "未知寄件者地址", en: "Unknown sender address" },
|
||||
"发件人": { "zh-TW": "寄件者", en: "Sender" },
|
||||
"发件人地址": { "zh-TW": "寄件者地址", en: "Sender address" },
|
||||
"收件人": { "zh-TW": "收件者", en: "Recipients" },
|
||||
"抄送": { "zh-TW": "副本", en: "Cc" },
|
||||
"密送": { "zh-TW": "密件副本", en: "Bcc" },
|
||||
"投递邮箱": { "zh-TW": "投遞信箱", en: "Delivered mailbox" },
|
||||
"发送时间": { "zh-TW": "傳送時間", en: "Send time" },
|
||||
"接收时间": { "zh-TW": "接收時間", en: "Received time" },
|
||||
"未填写收件人": { "zh-TW": "未填寫收件者", en: "No recipients" },
|
||||
"邮件正文": { "zh-TW": "郵件內文", en: "Message body" },
|
||||
"邮件详情": { "zh-TW": "郵件詳情", en: "Message details" },
|
||||
"上一封": { "zh-TW": "上一封", en: "Previous" },
|
||||
"下一封": { "zh-TW": "下一封", en: "Next" },
|
||||
"更多操作": { "zh-TW": "更多操作", en: "More actions" },
|
||||
"邮件不存在": { "zh-TW": "郵件不存在", en: "Message not found" },
|
||||
"标为已读": { "zh-TW": "標為已讀", en: "Mark as read" },
|
||||
"标为未读": { "zh-TW": "標為未讀", en: "Mark as unread" },
|
||||
"添加星标": { "zh-TW": "加上星號", en: "Add star" },
|
||||
"取消星标": { "zh-TW": "移除星號", en: "Remove star" },
|
||||
"移入回收站": { "zh-TW": "移至垃圾桶", en: "Move to trash" },
|
||||
"移入垃圾邮件": { "zh-TW": "移至垃圾郵件", en: "Move to spam" },
|
||||
"批量操作": { "zh-TW": "批次操作", en: "Bulk actions" },
|
||||
"移动到": { "zh-TW": "移動到", en: "Move to" },
|
||||
"移到最上": { "zh-TW": "移到最上方", en: "Move to top" },
|
||||
"上移一位": { "zh-TW": "上移一位", en: "Move up" },
|
||||
"下移一位": { "zh-TW": "下移一位", en: "Move down" },
|
||||
"移到最下": { "zh-TW": "移到最下方", en: "Move to bottom" },
|
||||
"删除文件夹": { "zh-TW": "刪除資料夾", en: "Delete folder" },
|
||||
"远端文件夹没有邮件": { "zh-TW": "遠端資料夾沒有郵件", en: "No messages in remote folder" },
|
||||
"当前筛选条件下没有远端邮件": { "zh-TW": "目前篩選條件下沒有遠端郵件", en: "No remote messages match the current filters" },
|
||||
"没有待发送邮件": { "zh-TW": "沒有待傳送郵件", en: "No scheduled mail" },
|
||||
"当前搜索没有匹配的定时邮件": { "zh-TW": "目前搜尋沒有符合的定時郵件", en: "No scheduled messages match your search" },
|
||||
"发送队列为空": { "zh-TW": "傳送佇列為空", en: "Send queue is empty" },
|
||||
"当前搜索没有匹配的发送任务": { "zh-TW": "目前搜尋沒有符合的傳送任務", en: "No send tasks match your search" },
|
||||
"当前筛选条件下没有邮件": { "zh-TW": "目前篩選條件下沒有郵件", en: "No messages match the current filters" },
|
||||
"暂无星标邮件": { "zh-TW": "暫無星號郵件", en: "No starred mail" },
|
||||
"当前标签没有邮件": { "zh-TW": "目前標籤沒有郵件", en: "No messages with this label" },
|
||||
"收件箱暂时为空": { "zh-TW": "收件匣暫時為空", en: "Inbox is empty" },
|
||||
"还没有草稿": { "zh-TW": "還沒有草稿", en: "No drafts yet" },
|
||||
"还没有已发送邮件": { "zh-TW": "還沒有已傳送郵件", en: "No sent mail yet" },
|
||||
"回收站是空的": { "zh-TW": "垃圾桶是空的", en: "Trash is empty" },
|
||||
"暂无垃圾邮件": { "zh-TW": "暫無垃圾郵件", en: "No spam" },
|
||||
"当前文件夹没有邮件": { "zh-TW": "目前資料夾沒有郵件", en: "No messages in this folder" },
|
||||
"待发送": { "zh-TW": "待傳送", en: "Scheduled" },
|
||||
"发送队列": { "zh-TW": "傳送佇列", en: "Send queue" },
|
||||
"全部状态": { "zh-TW": "全部狀態", en: "All statuses" },
|
||||
"排队中": { "zh-TW": "佇列中", en: "Queued" },
|
||||
"发送中": { "zh-TW": "傳送中", en: "Sending" },
|
||||
"发送失败": { "zh-TW": "傳送失敗", en: "Failed" },
|
||||
"已投递": { "zh-TW": "已投遞", en: "Delivered" },
|
||||
"已取消": { "zh-TW": "已取消", en: "Canceled" },
|
||||
"等待发送": { "zh-TW": "等待傳送", en: "Pending" },
|
||||
"清除": { "zh-TW": "清除", en: "Clear" },
|
||||
"未记录收件人": { "zh-TW": "未記錄收件者", en: "No recorded recipients" },
|
||||
"时间线": { "zh-TW": "時間軸", en: "Timeline" },
|
||||
"重试": { "zh-TW": "重試", en: "Retry" },
|
||||
"暂无投递事件": { "zh-TW": "暫無投遞事件", en: "No delivery events" },
|
||||
"队列事件": { "zh-TW": "佇列事件", en: "Queue event" },
|
||||
"定时发送": { "zh-TW": "定時傳送", en: "Scheduled send" },
|
||||
"未知": { "zh-TW": "未知", en: "Unknown" },
|
||||
"同步": { "zh-TW": "同步", en: "Sync" },
|
||||
"直连": { "zh-TW": "直連", en: "Direct" },
|
||||
"还没有可用邮箱": { "zh-TW": "還沒有可用信箱", en: "No mailbox available" },
|
||||
"请在个人中心申请邮箱,或联系管理员为当前账号分配邮箱。": { "zh-TW": "請在個人中心申請信箱,或聯絡管理員為目前帳號分配信箱。", en: "Apply for a mailbox in Profile, or contact an administrator to assign one to this account." },
|
||||
"前往个人中心": { "zh-TW": "前往個人中心", en: "Go to profile" },
|
||||
"请前往邮箱管理,创建、申请或联系管理员分配邮箱。": { "zh-TW": "請前往信箱管理,建立、申請或聯絡管理員分配信箱。", en: "Open mailbox management to create, request, or ask an administrator to assign a mailbox." },
|
||||
"前往邮箱管理": { "zh-TW": "前往信箱管理", en: "Go to mailbox management" },
|
||||
"提示:尚未选择开放域名。请在“后台管理 → 系统设置 → 邮件”中至少勾选一个已启用域名。": { "zh-TW": "提示:尚未選擇開放網域。請在「後台管理 → 系統設定 → 郵件」中至少勾選一個已啟用網域。", en: "No domain is open for mailbox requests. Open Admin → System settings → Mail and select at least one active domain." },
|
||||
"提示:账号自助申请邮箱未开启。请在“后台管理 → 系统设置 → 邮件”中开启,并勾选开放域名。": { "zh-TW": "提示:帳號自助申請信箱尚未開啟。請在「後台管理 → 系統設定 → 郵件」中開啟,並勾選開放網域。", en: "Mailbox self-service is disabled. Enable it under Admin → System settings → Mail, then select the available domains." },
|
||||
"提示:当前账号暂不可创建新邮箱,请联系管理员开启账号自助申请邮箱。": { "zh-TW": "提示:目前帳號暫時無法建立新信箱,請聯絡管理員開啟帳號自助申請信箱。", en: "This account cannot create a mailbox. Ask an administrator to enable mailbox self-service." },
|
||||
"前往设置": { "zh-TW": "前往設定", en: "Open settings" },
|
||||
"无邮箱前台权限": { "zh-TW": "無信箱前台權限", en: "No mailbox access" },
|
||||
"当前账号未开启邮箱前台访问权限。": { "zh-TW": "目前帳號未開啟信箱前台存取權限。", en: "Mailbox access is not enabled for this account." },
|
||||
"无邮件查看权限": { "zh-TW": "無郵件檢視權限", en: "No mail read permission" },
|
||||
"当前账号可以访问邮箱前台,但未开启邮件查看权限。": { "zh-TW": "目前帳號可以存取信箱前台,但未開啟郵件檢視權限。", en: "This account can access mailbox UI, but mail reading is not enabled." },
|
||||
"无定时发送权限": { "zh-TW": "無定時傳送權限", en: "No scheduled send permission" },
|
||||
"当前账号不能查看或管理定时发送任务。": { "zh-TW": "目前帳號不能檢視或管理定時傳送任務。", en: "This account cannot view or manage scheduled send tasks." },
|
||||
"无发送队列权限": { "zh-TW": "無傳送佇列權限", en: "No send queue permission" },
|
||||
"当前账号不能查看发送队列。": { "zh-TW": "目前帳號不能檢視傳送佇列。", en: "This account cannot view the send queue." },
|
||||
"操作失败": { "zh-TW": "操作失敗", en: "Operation failed" },
|
||||
"请稍后重试": { "zh-TW": "請稍後重試", en: "Please try again later" },
|
||||
"已取消定时发送": { "zh-TW": "已取消定時傳送", en: "Scheduled send canceled" },
|
||||
"已移除失败记录": { "zh-TW": "已移除失敗記錄", en: "Failed record removed" },
|
||||
"文件夹已创建": { "zh-TW": "資料夾已建立", en: "Folder created" },
|
||||
"创建文件夹失败": { "zh-TW": "建立資料夾失敗", en: "Failed to create folder" },
|
||||
"文件夹排序失败": { "zh-TW": "資料夾排序失敗", en: "Failed to reorder folders" },
|
||||
"文件夹已删除": { "zh-TW": "資料夾已刪除", en: "Folder deleted" },
|
||||
"删除文件夹失败": { "zh-TW": "刪除資料夾失敗", en: "Failed to delete folder" },
|
||||
"已重新加入发送队列": { "zh-TW": "已重新加入傳送佇列", en: "Added back to send queue" },
|
||||
"重试失败": { "zh-TW": "重試失敗", en: "Retry failed" },
|
||||
"已取消发送任务": { "zh-TW": "已取消傳送任務", en: "Send task canceled" },
|
||||
"取消失败": { "zh-TW": "取消失敗", en: "Cancel failed" },
|
||||
"当前没有未读邮件": { "zh-TW": "目前沒有未讀郵件", en: "No unread messages" },
|
||||
"删除所选邮件?": { "zh-TW": "刪除所選郵件?", en: "Delete selected messages?" },
|
||||
"删除邮件": { "zh-TW": "刪除郵件", en: "Delete messages" },
|
||||
"批量操作失败": { "zh-TW": "批次操作失敗", en: "Bulk action failed" },
|
||||
"删除这封邮件?": { "zh-TW": "刪除這封郵件?", en: "Delete this message?" },
|
||||
"这封草稿已在待发送队列中": { "zh-TW": "這封草稿已在待傳送佇列中", en: "This draft is already scheduled" },
|
||||
"请先取消定时发送,再继续编辑。": { "zh-TW": "請先取消定時傳送,再繼續編輯。", en: "Cancel the scheduled send before editing." },
|
||||
"打开草稿失败": { "zh-TW": "開啟草稿失敗", en: "Failed to open draft" },
|
||||
"文件夹内的邮件会移回收件箱,不会被删除。": { "zh-TW": "資料夾內的郵件會移回收件匣,不會被刪除。", en: "Messages in this folder will be moved back to Inbox, not deleted." },
|
||||
"编辑草稿": { "zh-TW": "編輯草稿", en: "Edit draft" },
|
||||
"打开邮件": { "zh-TW": "開啟郵件", en: "Open message" },
|
||||
"发件邮箱": { "zh-TW": "寄件信箱", en: "From mailbox" },
|
||||
"未选择": { "zh-TW": "未選擇", en: "Not selected" },
|
||||
"分别发送": { "zh-TW": "分別傳送", en: "Send separately" },
|
||||
"主 题": { "zh-TW": "主 旨", en: "Subject" },
|
||||
"输入主题": { "zh-TW": "輸入主旨", en: "Enter subject" },
|
||||
"写信": { "zh-TW": "寫信", en: "Compose" },
|
||||
"正在保存草稿...": { "zh-TW": "正在儲存草稿...", en: "Saving draft..." },
|
||||
"草稿保存失败": { "zh-TW": "草稿儲存失敗", en: "Failed to save draft" },
|
||||
"发送": { "zh-TW": "傳送", en: "Send" },
|
||||
"发送中...": { "zh-TW": "傳送中...", en: "Sending..." },
|
||||
"发送成功": { "zh-TW": "傳送成功", en: "Sent" },
|
||||
"定时": { "zh-TW": "定時", en: "Schedule" },
|
||||
"继续": { "zh-TW": "繼續", en: "Continue" },
|
||||
"继续发送": { "zh-TW": "繼續傳送", en: "Continue sending" },
|
||||
"继续分别发送": { "zh-TW": "繼續分別傳送", en: "Continue sending separately" },
|
||||
"继续定时发送": { "zh-TW": "繼續定時傳送", en: "Continue scheduling" },
|
||||
"请选择发件邮箱": { "zh-TW": "請選擇寄件信箱", en: "Select a from mailbox" },
|
||||
"确认发送这封邮件?": { "zh-TW": "確認傳送這封郵件?", en: "Send this message?" },
|
||||
"确认定时发送?": { "zh-TW": "確認定時傳送?", en: "Schedule this message?" },
|
||||
"请选择发送时间": { "zh-TW": "請選擇傳送時間", en: "Select a send time" },
|
||||
"发送时间需要晚于当前时间": { "zh-TW": "傳送時間需要晚於目前時間", en: "Send time must be in the future" },
|
||||
"确认定时": { "zh-TW": "確認定時", en: "Confirm schedule" },
|
||||
"正在设置...": { "zh-TW": "正在設定...", en: "Scheduling..." },
|
||||
"输入正文": { "zh-TW": "輸入內文", en: "Write your message" },
|
||||
"正文": { "zh-TW": "內文", en: "Body" },
|
||||
"撤销": { "zh-TW": "復原", en: "Undo" },
|
||||
"重做": { "zh-TW": "重做", en: "Redo" },
|
||||
"插入": { "zh-TW": "插入", en: "Insert" },
|
||||
"链接": { "zh-TW": "連結", en: "Link" },
|
||||
"图片链接": { "zh-TW": "圖片連結", en: "Image link" },
|
||||
"分隔线": { "zh-TW": "分隔線", en: "Divider" },
|
||||
"日程": { "zh-TW": "行程", en: "Event" },
|
||||
"表情": { "zh-TW": "表情", en: "Emoji" },
|
||||
"格式": { "zh-TW": "格式", en: "Format" },
|
||||
"预览": { "zh-TW": "預覽", en: "Preview" },
|
||||
"签名": { "zh-TW": "簽名", en: "Signature" },
|
||||
"清除格式": { "zh-TW": "清除格式", en: "Clear formatting" },
|
||||
"加粗": { "zh-TW": "粗體", en: "Bold" },
|
||||
"斜体": { "zh-TW": "斜體", en: "Italic" },
|
||||
"下划线": { "zh-TW": "底線", en: "Underline" },
|
||||
"删除线": { "zh-TW": "刪除線", en: "Strikethrough" },
|
||||
"文字颜色": { "zh-TW": "文字顏色", en: "Text color" },
|
||||
"高亮": { "zh-TW": "醒目提示", en: "Highlight" },
|
||||
"无序列表": { "zh-TW": "項目符號清單", en: "Bulleted list" },
|
||||
"有序列表": { "zh-TW": "編號清單", en: "Numbered list" },
|
||||
"减少缩进": { "zh-TW": "減少縮排", en: "Decrease indent" },
|
||||
"增加缩进": { "zh-TW": "增加縮排", en: "Increase indent" },
|
||||
"左对齐": { "zh-TW": "靠左對齊", en: "Align left" },
|
||||
"居中": { "zh-TW": "置中", en: "Center" },
|
||||
"右对齐": { "zh-TW": "靠右對齊", en: "Align right" },
|
||||
"引用": { "zh-TW": "引用", en: "Quote" },
|
||||
"代码块": { "zh-TW": "程式碼區塊", en: "Code block" },
|
||||
"邮件预览": { "zh-TW": "郵件預覽", en: "Message preview" },
|
||||
"编辑链接": { "zh-TW": "編輯連結", en: "Edit link" },
|
||||
"插入链接": { "zh-TW": "插入連結", en: "Insert link" },
|
||||
"编辑图片": { "zh-TW": "編輯圖片", en: "Edit image" },
|
||||
"插入图片": { "zh-TW": "插入圖片", en: "Insert image" },
|
||||
"链接地址": { "zh-TW": "連結地址", en: "Link URL" },
|
||||
"图片地址": { "zh-TW": "圖片地址", en: "Image URL" },
|
||||
"显示文字": { "zh-TW": "顯示文字", en: "Display text" },
|
||||
"默认使用链接地址": { "zh-TW": "預設使用連結地址", en: "Defaults to the URL" },
|
||||
"替代文字": { "zh-TW": "替代文字", en: "Alt text" },
|
||||
"图片说明": { "zh-TW": "圖片說明", en: "Image description" },
|
||||
"更新": { "zh-TW": "更新", en: "Update" },
|
||||
"默认字体": { "zh-TW": "預設字體", en: "Default font" },
|
||||
"微软雅黑": { "zh-TW": "微軟正黑體", en: "Microsoft YaHei" },
|
||||
"小号": { "zh-TW": "小號", en: "Small" },
|
||||
"中号": { "zh-TW": "中號", en: "Medium" },
|
||||
"大号": { "zh-TW": "大號", en: "Large" },
|
||||
"默认": { "zh-TW": "預設", en: "Default" },
|
||||
"红色": { "zh-TW": "紅色", en: "Red" },
|
||||
"蓝色": { "zh-TW": "藍色", en: "Blue" },
|
||||
"绿色": { "zh-TW": "綠色", en: "Green" },
|
||||
"紫色": { "zh-TW": "紫色", en: "Purple" },
|
||||
"黄色": { "zh-TW": "黃色", en: "Yellow" },
|
||||
"粉色": { "zh-TW": "粉色", en: "Pink" },
|
||||
"无高亮": { "zh-TW": "無醒目提示", en: "No highlight" },
|
||||
"新建日程": { "zh-TW": "新增行程", en: "New event" },
|
||||
"输入日程主题": { "zh-TW": "輸入行程主旨", en: "Enter event title" },
|
||||
"请输入日程主题": { "zh-TW": "請輸入行程主旨", en: "Enter an event title" },
|
||||
"开始": { "zh-TW": "開始", en: "Start" },
|
||||
"全天": { "zh-TW": "全天", en: "All day" },
|
||||
"持续": { "zh-TW": "持續", en: "Duration" },
|
||||
"自定义": { "zh-TW": "自訂", en: "Custom" },
|
||||
"提醒": { "zh-TW": "提醒", en: "Reminder" },
|
||||
"重复": { "zh-TW": "重複", en: "Repeat" },
|
||||
"农历": { "zh-TW": "農曆", en: "Lunar" },
|
||||
"位置": { "zh-TW": "位置", en: "Location" },
|
||||
"请输入位置": { "zh-TW": "請輸入位置", en: "Enter location" },
|
||||
"描述": { "zh-TW": "描述", en: "Description" },
|
||||
"输入描述": { "zh-TW": "輸入描述", en: "Enter description" },
|
||||
"确定": { "zh-TW": "確定", en: "OK" },
|
||||
"准时": { "zh-TW": "準時", en: "On time" },
|
||||
"永不": { "zh-TW": "永不", en: "Never" },
|
||||
"每天": { "zh-TW": "每天", en: "Daily" },
|
||||
"每周": { "zh-TW": "每週", en: "Weekly" },
|
||||
"每月": { "zh-TW": "每月", en: "Monthly" },
|
||||
"每年": { "zh-TW": "每年", en: "Yearly" },
|
||||
"明早 9 点": { "zh-TW": "明早 9 點", en: "Tomorrow 9 AM" },
|
||||
"下周一 9 点": { "zh-TW": "下週一 9 點", en: "Next Monday 9 AM" },
|
||||
"时间": { "zh-TW": "時間", en: "Time" },
|
||||
}
|
||||
|
||||
const templateTranslations: { pattern: RegExp; "zh-TW": string; en: string }[] = [
|
||||
{ pattern: /^已刷新 (.+)$/, "zh-TW": "已重新整理 $1", en: "Refreshed $1" },
|
||||
{ pattern: /^已选 (\d+) 封$/, "zh-TW": "已選 $1 封", en: "$1 selected" },
|
||||
{ pattern: /^(\d+) \/ (\d+) 封邮件$/, "zh-TW": "$1 / $2 封郵件", en: "$1 / $2 messages" },
|
||||
{ pattern: /^(\d+) \/ (\d+) 封$/, "zh-TW": "$1 / $2 封", en: "$1 / $2 messages" },
|
||||
{ pattern: /^(\d+) \/ (\d+) 封定时邮件$/, "zh-TW": "$1 / $2 封定時郵件", en: "$1 / $2 scheduled messages" },
|
||||
{ pattern: /^(\d+) \/ (\d+) 个发送任务$/, "zh-TW": "$1 / $2 個傳送任務", en: "$1 / $2 send tasks" },
|
||||
{ pattern: /^收到 (\d+) 封新邮件$/, "zh-TW": "收到 $1 封新郵件", en: "$1 new messages" },
|
||||
{ pattern: /^新邮件:(.+)$/, "zh-TW": "新郵件:$1", en: "New message: $1" },
|
||||
{ pattern: /^(.+) 等发来新邮件$/, "zh-TW": "$1 等寄來新郵件", en: "$1 and others sent new mail" },
|
||||
{ pattern: /^已标记 (\d+) 封邮件为已读$/, "zh-TW": "已標記 $1 封郵件為已讀", en: "Marked $1 messages as read" },
|
||||
{ pattern: /^已处理 (\d+) 封邮件$/, "zh-TW": "已處理 $1 封郵件", en: "Processed $1 messages" },
|
||||
{ pattern: /^将删除当前选中的 (\d+) 封邮件,此操作无法从邮件列表中恢复。$/, "zh-TW": "將刪除目前選取的 $1 封郵件,此操作無法從郵件清單中復原。", en: "This will delete the $1 selected messages. This cannot be undone from the message list." },
|
||||
{ pattern: /^邮件“(.+)”将被删除。$/, "zh-TW": "郵件「$1」將被刪除。", en: "Message “$1” will be deleted." },
|
||||
{ pattern: /^删除文件夹“(.+)”?$/, "zh-TW": "刪除資料夾「$1」?", en: "Delete folder “$1”?" },
|
||||
{ pattern: /^删除标签 (.+)$/, "zh-TW": "刪除標籤 $1", en: "Delete label $1" },
|
||||
{ pattern: /^移除标签 (.+)$/, "zh-TW": "移除標籤 $1", en: "Remove label $1" },
|
||||
{ pattern: /^移除 (.+)$/, "zh-TW": "移除 $1", en: "Remove $1" },
|
||||
{ pattern: /^已将 (\d+) 封邮件移回收件箱$/, "zh-TW": "已將 $1 封郵件移回收件匣", en: "Moved $1 messages back to Inbox" },
|
||||
{ pattern: /^发给 (.+)$/, "zh-TW": "寄給 $1", en: "To $1" },
|
||||
{ pattern: /^来源:(.+)$/, "zh-TW": "來源:$1", en: "Source: $1" },
|
||||
{ pattern: /^尝试:(\d+)\/(\d+)$/, "zh-TW": "嘗試:$1/$2", en: "Attempts: $1/$2" },
|
||||
{ pattern: /^下次:(.+)$/, "zh-TW": "下次:$1", en: "Next: $1" },
|
||||
{ pattern: /^投递于 (.+)$/, "zh-TW": "投遞於 $1", en: "Delivered at $1" },
|
||||
{ pattern: /^尝试次数:(\d+)$/, "zh-TW": "嘗試次數:$1", en: "Attempts: $1" },
|
||||
{ pattern: /^已分别发送 (\d+) 封邮件$/, "zh-TW": "已分別傳送 $1 封郵件", en: "Sent $1 messages separately" },
|
||||
{ pattern: /^已定时发送 (.+)$/, "zh-TW": "已定時傳送 $1", en: "Scheduled for $1" },
|
||||
{ pattern: /^草稿已保存 (.+)$/, "zh-TW": "草稿已儲存 $1", en: "Draft saved $1" },
|
||||
{ pattern: /^发送时间:(.+)$/, "zh-TW": "傳送時間:$1", en: "Send time: $1" },
|
||||
{ pattern: /^当前单个附件上限 (.+)$/, "zh-TW": "目前單個附件上限 $1", en: "Current per-attachment limit: $1" },
|
||||
{ pattern: /^(\d+)分钟$/, "zh-TW": "$1分鐘", en: "$1 min" },
|
||||
{ pattern: /^(\d+)分钟前$/, "zh-TW": "$1分鐘前", en: "$1 min before" },
|
||||
{ pattern: /^(\d+) 分钟后$/, "zh-TW": "$1 分鐘後", en: "In $1 min" },
|
||||
{ pattern: /^(\d+)小时$/, "zh-TW": "$1小時", en: "$1 hr" },
|
||||
{ pattern: /^(\d+)小时前$/, "zh-TW": "$1小時前", en: "$1 hr before" },
|
||||
{ pattern: /^(\d+) 小时后$/, "zh-TW": "$1 小時後", en: "In $1 hr" },
|
||||
{ pattern: /^(\d+)天$/, "zh-TW": "$1天", en: "$1 day" },
|
||||
{ pattern: /^(\d+)天前$/, "zh-TW": "$1天前", en: "$1 day before" },
|
||||
{ pattern: /^(.+)前$/, "zh-TW": "$1前", en: "$1 before" },
|
||||
]
|
||||
|
||||
export function translateUiText(value: string, language: Language): string {
|
||||
if (language === "zh-CN" || !value) return value
|
||||
const match = value.match(/^(\s*)([\s\S]*?)(\s*)$/)
|
||||
const leading = match?.[1] || ""
|
||||
const text = match?.[2] || value
|
||||
const trailing = match?.[3] || ""
|
||||
if (!text) return value
|
||||
const translated = translateCore(text, language)
|
||||
return `${leading}${translated}${trailing}`
|
||||
}
|
||||
|
||||
function translateCore(text: string, language: Exclude<Language, "zh-CN">): string {
|
||||
const exact = exactTranslations[text]
|
||||
if (exact) return exact[language]
|
||||
for (const rule of templateTranslations) {
|
||||
if (rule.pattern.test(text)) return text.replace(rule.pattern, rule[language])
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
const textSources = new WeakMap<Text, string>()
|
||||
const textLastApplied = new WeakMap<Text, string>()
|
||||
const attrSources = new WeakMap<Element, Partial<Record<string, string>>>()
|
||||
const attrLastApplied = new WeakMap<Element, Partial<Record<string, string>>>()
|
||||
const translatableAttributes = ["placeholder", "title", "aria-label"] as const
|
||||
let translateTimer: number | undefined
|
||||
|
||||
export function LanguageDomSync() {
|
||||
const [language] = useLanguage()
|
||||
React.useEffect(() => {
|
||||
document.documentElement.lang = languageOptions.find((item) => item.value === language)?.htmlLang || language
|
||||
scheduleLocalize(language)
|
||||
const observer = new MutationObserver(() => scheduleLocalize(language))
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: [...translatableAttributes] })
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
if (translateTimer) window.clearTimeout(translateTimer)
|
||||
}
|
||||
}, [language])
|
||||
return null
|
||||
}
|
||||
|
||||
function scheduleLocalize(language: Language) {
|
||||
if (typeof window === "undefined") return
|
||||
if (translateTimer) window.clearTimeout(translateTimer)
|
||||
translateTimer = window.setTimeout(() => localizeDocument(language), 0)
|
||||
}
|
||||
|
||||
function localizeDocument(language: Language) {
|
||||
if (!document.body) return
|
||||
localizeElement(document.body, language)
|
||||
}
|
||||
|
||||
function localizeElement(root: Element, language: Language) {
|
||||
if (shouldSkipElement(root)) return
|
||||
localizeAttributes(root, language)
|
||||
for (const child of Array.from(root.childNodes)) {
|
||||
if (child.nodeType === Node.TEXT_NODE) localizeTextNode(child as Text, language)
|
||||
else if (child.nodeType === Node.ELEMENT_NODE) localizeElement(child as Element, language)
|
||||
}
|
||||
}
|
||||
|
||||
function localizeTextNode(node: Text, language: Language) {
|
||||
if (!node.parentElement || shouldSkipElement(node.parentElement)) return
|
||||
const current = node.textContent || ""
|
||||
if (!current.trim()) return
|
||||
let source = textSources.get(node)
|
||||
const last = textLastApplied.get(node)
|
||||
if (!source || (last !== undefined && current !== last && shouldTranslate(current))) {
|
||||
source = current
|
||||
textSources.set(node, source)
|
||||
}
|
||||
if (!source || !shouldTranslate(source)) return
|
||||
const next = translateUiText(source, language)
|
||||
textLastApplied.set(node, next)
|
||||
if (current !== next) node.textContent = next
|
||||
}
|
||||
|
||||
function localizeAttributes(element: Element, language: Language) {
|
||||
if (shouldSkipElement(element)) return
|
||||
for (const attr of translatableAttributes) {
|
||||
const current = element.getAttribute(attr)
|
||||
if (!current || !current.trim()) continue
|
||||
let sources = attrSources.get(element)
|
||||
if (!sources) {
|
||||
sources = {}
|
||||
attrSources.set(element, sources)
|
||||
}
|
||||
let applied = attrLastApplied.get(element)
|
||||
if (!applied) {
|
||||
applied = {}
|
||||
attrLastApplied.set(element, applied)
|
||||
}
|
||||
if (!sources[attr] || (applied[attr] !== undefined && current !== applied[attr] && shouldTranslate(current))) sources[attr] = current
|
||||
const source = sources[attr]
|
||||
if (!source || !shouldTranslate(source)) continue
|
||||
const next = translateUiText(source, language)
|
||||
applied[attr] = next
|
||||
if (current !== next) element.setAttribute(attr, next)
|
||||
}
|
||||
}
|
||||
|
||||
function shouldSkipElement(element: Element) {
|
||||
const tag = element.tagName.toLowerCase()
|
||||
if (["script", "style", "code", "pre", "textarea", "option"].includes(tag)) return true
|
||||
return Boolean(element.closest("[data-lanqin-i18n-ignore], [contenteditable='true'], .ProseMirror"))
|
||||
}
|
||||
|
||||
function shouldTranslate(value: string) {
|
||||
const text = value.trim()
|
||||
if (!text) return false
|
||||
return Boolean(exactTranslations[text]) || templateTranslations.some((rule) => rule.pattern.test(text))
|
||||
}
|
||||
@@ -3,16 +3,18 @@ import ReactDOM from "react-dom/client"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { Navigate, RouterProvider, createBrowserRouter } from "react-router-dom"
|
||||
import { Toaster } from "@/components/ui/toaster"
|
||||
import { LanguageDomSync } from "@/lib/language"
|
||||
import { ProtectedLayout } from "@/components/protected-layout"
|
||||
import { AdminOnly } from "@/components/admin-only"
|
||||
import { LoginPage } from "@/pages/login"
|
||||
import { RegisterPage } from "@/pages/register"
|
||||
import { MailPage } from "@/pages/mail"
|
||||
import { AdminPage } from "@/pages/admin"
|
||||
import { ProfilePage } from "@/pages/profile"
|
||||
import { NotFoundPage } from "@/pages/not-found"
|
||||
import "./index.css"
|
||||
|
||||
const LoginPage = React.lazy(() => import("@/pages/login").then((module) => ({ default: module.LoginPage })))
|
||||
const RegisterPage = React.lazy(() => import("@/pages/register").then((module) => ({ default: module.RegisterPage })))
|
||||
const MailPage = React.lazy(() => import("@/pages/mail").then((module) => ({ default: module.MailPage })))
|
||||
const AdminPage = React.lazy(() => import("@/pages/admin").then((module) => ({ default: module.AdminPage })))
|
||||
const ProfilePage = React.lazy(() => import("@/pages/profile").then((module) => ({ default: module.ProfilePage })))
|
||||
const NotFoundPage = React.lazy(() => import("@/pages/not-found").then((module) => ({ default: module.NotFoundPage })))
|
||||
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 10_000 } } })
|
||||
const router = createBrowserRouter([
|
||||
{ path: "/login", element: <LoginPage /> },
|
||||
@@ -30,8 +32,11 @@ const router = createBrowserRouter([
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
<React.Suspense fallback={<div className="grid h-svh place-items-center text-sm text-muted-foreground">加载中...</div>}>
|
||||
<RouterProvider router={router} />
|
||||
</React.Suspense>
|
||||
<Toaster />
|
||||
<LanguageDomSync />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
@@ -113,11 +113,11 @@ export function RegisterPage() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-sm font-medium">密码</Label>
|
||||
<PasswordInput id="password" name="password" autoComplete="new-password" minLength={8} required className="h-11 text-base" />
|
||||
<PasswordInput id="password" name="password" autoComplete="new-password" minLength={6} required className="h-11 text-base" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword" className="text-sm font-medium">确认密码</Label>
|
||||
<PasswordInput id="confirmPassword" name="confirmPassword" autoComplete="new-password" minLength={8} required className="h-11 text-base" />
|
||||
<PasswordInput id="confirmPassword" name="confirmPassword" autoComplete="new-password" minLength={6} required className="h-11 text-base" />
|
||||
</div>
|
||||
{turnstileRequired && <TurnstileBox siteKey={publicSettings.data?.turnstileSiteKey || ""} onToken={setTurnstileToken} />}
|
||||
<Button className="h-11 w-full text-base" disabled={register.isPending || publicSettings.isLoading}>
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
|
||||
@@ -7,14 +7,30 @@
|
||||
# =========================
|
||||
# 单容器部署镜像。默认使用你的 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=
|
||||
|
||||
# 一键安装器记录的部署方式。手动部署时可以留空。
|
||||
LANQIN_INSTALL_FIREWALL_MODE=
|
||||
LANQIN_INSTALL_WEB_MODE=
|
||||
|
||||
# 可选端口绑定。自动 Nginx 或宝塔反代模式使用 127.0.0.1:8088。
|
||||
LANQIN_HTTP_BIND=80
|
||||
LANQIN_SMTP_BIND=25
|
||||
LANQIN_SMTPS_BIND=465
|
||||
LANQIN_SUBMISSION_BIND=587
|
||||
LANQIN_IMAPS_BIND=993
|
||||
LANQIN_POP3S_BIND=995
|
||||
|
||||
# =========================
|
||||
# 对外访问地址
|
||||
@@ -35,8 +51,9 @@ LANQIN_TLS_KEY_FILE=
|
||||
# =========================
|
||||
# 初始管理员
|
||||
# =========================
|
||||
# 第一次启动时会创建这个管理员账号。
|
||||
LANQIN_ADMIN_EMAIL=admin@example.com
|
||||
# 第一次启动时只创建管理员账号,不会自动创建同名邮箱或域名。
|
||||
# 登录名不能使用邮箱地址,之后可在后台“账号”中修改。
|
||||
LANQIN_ADMIN_USERNAME=admin
|
||||
|
||||
# 生产环境必须改掉默认密码。
|
||||
LANQIN_ADMIN_PASSWORD=ChangeMe123!
|
||||
@@ -97,6 +114,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=
|
||||
@@ -132,9 +159,28 @@ LANQIN_MAIL_AUTO_REFRESH=true
|
||||
# 自动刷新间隔,单位:秒。
|
||||
LANQIN_MAIL_REFRESH_SECONDS=30
|
||||
|
||||
# 是否启用外部 IMAP 接入。也可在后台“系统设置 > 外部 IMAP”里开启,默认关闭。
|
||||
LANQIN_EXTERNAL_IMAP_ENABLED=false
|
||||
|
||||
# 外部 IMAP 密码加密密钥。启用外部 IMAP 接入前必须设置为足够长的随机字符串;也可在后台“系统设置 > 外部 IMAP”里配置。
|
||||
LANQIN_EXTERNAL_IMAP_SECRET_KEY=
|
||||
|
||||
# 外部 IMAP 本地存储模式的后台同步间隔,单位:秒;也可在后台配置。
|
||||
LANQIN_EXTERNAL_IMAP_SYNC_SECONDS=300
|
||||
|
||||
# 是否允许用户配置 localhost / 内网 / link-local IMAP 主机。默认 false,避免 SSRF 风险;也可在后台配置。
|
||||
LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS=false
|
||||
|
||||
# Gmail 外部 IMAP OAuth2。也可在后台配置。回调地址:${LANQIN_PUBLIC_BASE_URL}/api/external-imap-oauth/gmail/callback
|
||||
LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_ID=
|
||||
LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET=
|
||||
|
||||
# Microsoft 365 / Outlook 外部 IMAP OAuth2。也可在后台配置。回调地址:${LANQIN_PUBLIC_BASE_URL}/api/external-imap-oauth/outlook/callback
|
||||
LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID=
|
||||
LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET=
|
||||
|
||||
# =========================
|
||||
# 系统
|
||||
# =========================
|
||||
# 容器时区。
|
||||
TZ=Asia/Shanghai
|
||||
|
||||
|
||||
@@ -1,4 +1,25 @@
|
||||
# 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 更新服务。该服务不映射公网端口,仅接受带随机令牌的容器内请求;后台“立即更新”也只允许超级管理员执行。
|
||||
|
||||
首次安装会依次询问防火墙模式、邮件服务器域名、管理员用户名/密码和 Web 部署方式。自动 Web 模式会把容器绑定到 `127.0.0.1:8088`,配置宿主机 Nginx,并使用官方 `acme.sh` 申请和续期证书。自定义管理员密码最少 6 位,留空则生成 12 位密码。
|
||||
|
||||
## 最简单部署:单容器镜像版
|
||||
|
||||
@@ -7,7 +28,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 +76,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
|
||||
```
|
||||
|
||||
如果拉取时报:
|
||||
@@ -129,11 +150,19 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up
|
||||
- Go API 是 Webmail 和管理后台入口;浏览器不直接连接 SMTP/IMAP/POP3。
|
||||
- Go API 会读取 `LANQIN_MAILDIR_ROOT=/var/mail/vhosts`,周期扫描 Maildir,把 Postfix/Dovecot 入站邮件同步成 Webmail 索引。
|
||||
- 第三方客户端可通过 LanQin API 提供的 SMTP `465/587` 发信;Webmail/API 和第三方客户端的“已发送”都由 API 写入,外发投递进入发送队列并由 API worker relay/retry,客户端后续 IMAP APPEND 到 Sent 会按 `Message-ID` 去重。
|
||||
- 用户可在个人邮箱管理中接入外部 IMAP 账号;默认关闭,可在后台“系统设置 > 外部 IMAP”开启并配置密钥/OAuth。本地存储模式会同步到 LanQin,远端直连模式每次从远端读取。启用前必须配置外部 IMAP 密码加密密钥,默认不允许连接 localhost / 内网 / link-local IMAP 主机。Gmail / Microsoft 365 / Outlook OAuth2 需要在对应控制台配置回调地址:`/api/external-imap-oauth/gmail/callback` 或 `/api/external-imap-oauth/outlook/callback`。
|
||||
- send-as v1 支持本人邮箱、启用的别名转发 source 指向本人邮箱,或数据库表 `send_as_grants` 中显式授权的地址。
|
||||
|
||||
## 邮件客户端 TLS 证书
|
||||
|
||||
Web 站点可以由宿主机 Nginx / 宝塔反代到容器 `80`,但 SMTP/IMAP/POP3 端口不会使用 Web 反代的证书。
|
||||
此时可在 `.env` 调整 Web 端口绑定,避免与宿主机 Nginx 的 `80/443` 冲突:
|
||||
|
||||
```dotenv
|
||||
LANQIN_HTTP_BIND=127.0.0.1:8088
|
||||
```
|
||||
|
||||
宿主机 Nginx 再反向代理到 `http://127.0.0.1:8088`。容器内 Web 服务只监听 HTTP,公网 HTTPS 由宿主机 Nginx 或宝塔终止。
|
||||
如果第三方客户端连接 `993/995` 时提示证书是 `localhost`,说明 Dovecot 仍在使用容器自带的测试证书。LanQin API 的 SMTP `465/587` submission 不会使用自签测试证书;启用前必须配置可读的真实证书。
|
||||
|
||||
生产环境请把域名证书挂载进容器,并在 `.env` 指向证书文件:
|
||||
@@ -154,7 +183,7 @@ services:
|
||||
- ./data:/data
|
||||
- ./mail:/var/mail/vhosts
|
||||
- ./dkim:/var/lib/rspamd/dkim
|
||||
- /etc/letsencrypt:/etc/letsencrypt:ro
|
||||
- ./certs:/certs:ro
|
||||
```
|
||||
|
||||
证书域名必须覆盖 `LANQIN_PUBLIC_HOSTNAME`。更新后执行:
|
||||
@@ -173,6 +202,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 配置错误。优先检查:
|
||||
@@ -193,4 +240,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,36 @@
|
||||
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_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
|
||||
- ./certs:/certs: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 |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 254 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,820 @@
|
||||
#!/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:-menu}"
|
||||
ROLLBACK_FILE="${INSTALL_DIR}/.rollback-image"
|
||||
NGINX_CONFIG="/etc/nginx/conf.d/newszxcn-email.conf"
|
||||
ACME_WEBROOT="/var/www/newszxcn-acme"
|
||||
CERT_DIR="${INSTALL_DIR}/certs"
|
||||
|
||||
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>
|
||||
|
||||
menu 显示安装与运维菜单
|
||||
install 首次安装;已有安装会先完整备份再重新安装
|
||||
update 备份数据库并更新到最新版
|
||||
status 查看容器与健康状态
|
||||
logs 持续查看运行日志
|
||||
restart 重启服务并重载 Nginx
|
||||
certificate 申请或续期自动模式的 SSL 证书
|
||||
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。"
|
||||
}
|
||||
|
||||
install_packages() {
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
DEBIAN_FRONTEND=noninteractive apt-get update -y
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y "$@"
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
dnf install -y "$@"
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
yum install -y "$@"
|
||||
else
|
||||
fail "暂不支持当前系统的软件包管理器,请使用 Ubuntu、Debian、CentOS、Rocky Linux 或 AlmaLinux。"
|
||||
fi
|
||||
}
|
||||
|
||||
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 local_source="false"
|
||||
source_dir="$(script_dir || true)"
|
||||
if [[ -n "${BASH_SOURCE[0]:-}" && -f "${BASH_SOURCE[0]}" && "${BASH_SOURCE[0]}" != /dev/fd/* ]]; then
|
||||
local_source="true"
|
||||
fi
|
||||
install -d -m 0755 "${INSTALL_DIR}"
|
||||
if [[ "${local_source}" == "true" && -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
|
||||
}
|
||||
|
||||
random_admin_password() {
|
||||
local value
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
value="$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9')"
|
||||
else
|
||||
value="$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')"
|
||||
fi
|
||||
printf '%.12s' "${value}"
|
||||
}
|
||||
|
||||
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}" ]] && has_tty; then
|
||||
if [[ "${secret}" == "true" ]]; then
|
||||
read -r -s -p "${prompt}${default_value:+ [${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}"
|
||||
}
|
||||
|
||||
prompt_choice() {
|
||||
local variable="$1" prompt="$2" default_value="$3" max_value="${4:-3}" value
|
||||
value="${!variable:-}"
|
||||
while true; do
|
||||
if [[ -z "${value}" ]] && has_tty; then
|
||||
read -r -p "${prompt}" value </dev/tty
|
||||
fi
|
||||
value="${value:-${default_value}}"
|
||||
if [[ "${value}" =~ ^[0-9]+$ ]] && (( value >= 1 && value <= max_value )); then
|
||||
printf '%s' "${value}"
|
||||
return
|
||||
fi
|
||||
prompt_text "[提示] 请输入 1 至 ${max_value}。\n"
|
||||
value=""
|
||||
has_tty || fail "${variable} 必须设置为 1 至 ${max_value}。"
|
||||
done
|
||||
}
|
||||
|
||||
prompt_menu_choice() {
|
||||
local default_value="$1" value="${LANQIN_MENU_ACTION:-}"
|
||||
if [[ -z "${value}" ]] && ! has_tty; then
|
||||
fail "非交互环境请直接使用 install、update、status 等子命令。"
|
||||
fi
|
||||
while true; do
|
||||
if [[ -z "${value}" ]] && has_tty; then
|
||||
read -r -p "请选择 [${default_value}]: " value </dev/tty
|
||||
fi
|
||||
value="${value:-${default_value}}"
|
||||
if [[ "${value}" =~ ^[0-9]$ ]]; then
|
||||
printf '%s' "${value}"
|
||||
return
|
||||
fi
|
||||
prompt_text "[提示] 请输入 0 至 9。\n"
|
||||
value=""
|
||||
has_tty || fail "LANQIN_MENU_ACTION 必须设置为 0 至 9。"
|
||||
done
|
||||
}
|
||||
|
||||
has_tty() {
|
||||
[[ -e /dev/tty ]] && (: </dev/tty) 2>/dev/null
|
||||
}
|
||||
|
||||
prompt_text() {
|
||||
if has_tty; then
|
||||
printf '%b' "$1" >/dev/tty
|
||||
else
|
||||
printf '%b' "$1" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
valid_hostname() {
|
||||
local hostname="$1" label tld
|
||||
local -a labels
|
||||
[[ ${#hostname} -le 253 && "${hostname}" == *.* ]] || return 1
|
||||
IFS='.' read -r -a labels <<<"${hostname}"
|
||||
for label in "${labels[@]}"; do
|
||||
[[ ${#label} -ge 1 && ${#label} -le 63 ]] || return 1
|
||||
[[ "${label}" =~ ^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$ ]] || return 1
|
||||
done
|
||||
tld="${labels[${#labels[@]}-1]}"
|
||||
[[ "${tld}" =~ ^[A-Za-z]{2,63}$ ]]
|
||||
}
|
||||
|
||||
prompt_admin_password() {
|
||||
local password="${LANQIN_ADMIN_PASSWORD:-}" confirm=""
|
||||
local safe_password_re='^[A-Za-z0-9][A-Za-z0-9._!@#%+,=:;?*/()^-]*$'
|
||||
if [[ -n "${password}" ]]; then
|
||||
[[ ${#password} -ge 6 ]] || fail "管理员密码至少需要 6 个字符。"
|
||||
[[ "${password}" =~ ${safe_password_re} ]] || fail "管理员密码包含安装配置不支持的字符。"
|
||||
printf '%s' "${password}"
|
||||
return
|
||||
fi
|
||||
if ! has_tty; then
|
||||
password="$(random_admin_password)"
|
||||
prompt_text "[提示] 已自动生成管理员密码:${password}\n"
|
||||
printf '%s' "${password}"
|
||||
return
|
||||
fi
|
||||
while true; do
|
||||
read -r -s -p "管理员密码(回车自动生成 12 位,或输入至少 6 位): " password </dev/tty
|
||||
printf '\n' >/dev/tty
|
||||
if [[ -z "${password}" ]]; then
|
||||
password="$(random_admin_password)"
|
||||
prompt_text "[提示] 已自动生成管理员密码:${password}\n"
|
||||
printf '%s' "${password}"
|
||||
return
|
||||
fi
|
||||
if [[ ${#password} -lt 6 ]]; then
|
||||
prompt_text "[提示] 管理员密码至少需要 6 个字符。\n"
|
||||
continue
|
||||
fi
|
||||
if [[ ! "${password}" =~ ${safe_password_re} ]]; then
|
||||
prompt_text "[提示] 密码必须以字母或数字开头,只能使用字母、数字和常用符号。\n"
|
||||
continue
|
||||
fi
|
||||
read -r -s -p "再次输入管理员密码: " confirm </dev/tty
|
||||
printf '\n' >/dev/tty
|
||||
if [[ "${password}" != "${confirm}" ]]; then
|
||||
prompt_text "[提示] 两次输入的密码不一致,请重新输入。\n"
|
||||
continue
|
||||
fi
|
||||
printf '%s' "${password}"
|
||||
return
|
||||
done
|
||||
}
|
||||
|
||||
configure_first_install() {
|
||||
if [[ -f "${INSTALL_DIR}/.env" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local firewall_mode hostname admin_username admin_password web_mode public_url update_token
|
||||
prompt_text '\n防火墙配置 [1]:\n1. 仅开放邮局必要端口(推荐)\n2. 保留现有防火墙,由用户自行配置\n3. 开放全部端口(不推荐)\n'
|
||||
firewall_mode="$(prompt_choice LANQIN_INSTALL_FIREWALL_MODE "请选择 [1]: " "1")"
|
||||
|
||||
hostname="$(prompt_value LANQIN_PUBLIC_HOSTNAME "邮件服务器域名,例如 mail.example.com" "")"
|
||||
valid_hostname "${hostname}" || fail "邮件服务器域名格式不正确。"
|
||||
|
||||
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_admin_password)"
|
||||
|
||||
prompt_text '\nWeb 部署方式 [1]:\n1. 自动配置 Nginx + SSL\n2. 宝塔/已有 Nginx 反代\n3. 仅 HTTP 测试\n'
|
||||
web_mode="$(prompt_choice LANQIN_INSTALL_WEB_MODE "请选择 [1]: " "1")"
|
||||
if [[ "${web_mode}" == "3" ]]; then
|
||||
public_url="http://${hostname}"
|
||||
else
|
||||
public_url="https://${hostname}"
|
||||
fi
|
||||
|
||||
update_token="$(random_secret)"
|
||||
install -m 0600 "${INSTALL_DIR}/.env.example" "${INSTALL_DIR}/.env"
|
||||
set_env LANQIN_INSTALL_FIREWALL_MODE "${firewall_mode}"
|
||||
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_INSTALL_WEB_MODE "${web_mode}"
|
||||
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" "${CERT_DIR}"
|
||||
install -d -m 0700 "${INSTALL_DIR}/data/backups"
|
||||
}
|
||||
|
||||
configure_runtime_bindings() {
|
||||
local web_mode
|
||||
web_mode="$(env_value LANQIN_INSTALL_WEB_MODE || true)"
|
||||
case "${web_mode}" in
|
||||
1|2)
|
||||
set_env LANQIN_HTTP_BIND "127.0.0.1:8088"
|
||||
set_env LANQIN_ALLOW_INSECURE_HTTP "false"
|
||||
;;
|
||||
3)
|
||||
set_env LANQIN_HTTP_BIND "80"
|
||||
set_env LANQIN_ALLOW_INSECURE_HTTP "true"
|
||||
;;
|
||||
"")
|
||||
warn "这是旧版安装配置,保留现有 Web 端口和反向代理设置。"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
detect_ssh_ports() {
|
||||
local ports=""
|
||||
if command -v sshd >/dev/null 2>&1; then
|
||||
ports="$(sshd -T 2>/dev/null | awk '$1 == "port" {print $2}' | sort -nu || true)"
|
||||
fi
|
||||
if [[ -z "${ports}" ]] && command -v ss >/dev/null 2>&1; then
|
||||
ports="$(ss -lntp 2>/dev/null | awk '/sshd/ {sub(/.*:/, "", $4); print $4}' | sort -nu || true)"
|
||||
fi
|
||||
printf '%s\n' "${ports:-22}"
|
||||
}
|
||||
|
||||
configure_restricted_firewall() {
|
||||
local ports=(25 80 443 465 587 993 995) ssh_port
|
||||
while IFS= read -r ssh_port; do
|
||||
[[ "${ssh_port}" =~ ^[0-9]+$ ]] && ports+=("${ssh_port}")
|
||||
done < <(detect_ssh_ports)
|
||||
|
||||
if command -v firewall-cmd >/dev/null 2>&1; then
|
||||
systemctl enable --now firewalld >/dev/null 2>&1 || fail "firewalld 启动失败。"
|
||||
for ssh_port in "${ports[@]}"; do
|
||||
firewall-cmd --permanent --add-port="${ssh_port}/tcp" >/dev/null
|
||||
done
|
||||
firewall-cmd --reload >/dev/null
|
||||
success "firewalld 已仅开放 SSH 和邮局必要端口。"
|
||||
return
|
||||
fi
|
||||
|
||||
if ! command -v ufw >/dev/null 2>&1; then
|
||||
install_packages ufw
|
||||
fi
|
||||
if command -v ufw >/dev/null 2>&1; then
|
||||
for ssh_port in "${ports[@]}"; do
|
||||
ufw allow "${ssh_port}/tcp" >/dev/null
|
||||
done
|
||||
ufw --force enable >/dev/null
|
||||
success "UFW 已开放 SSH 和邮局必要端口。"
|
||||
return
|
||||
fi
|
||||
fail "没有找到可管理的 UFW 或 firewalld。"
|
||||
}
|
||||
|
||||
configure_open_firewall() {
|
||||
warn "正在按选择开放全部端口,请同时检查云厂商安全组。"
|
||||
if command -v ufw >/dev/null 2>&1; then
|
||||
ufw --force disable >/dev/null 2>&1 || true
|
||||
fi
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl disable --now firewalld >/dev/null 2>&1 || true
|
||||
fi
|
||||
if command -v iptables >/dev/null 2>&1; then
|
||||
iptables -P INPUT ACCEPT
|
||||
iptables -F INPUT
|
||||
fi
|
||||
if command -v ip6tables >/dev/null 2>&1; then
|
||||
ip6tables -P INPUT ACCEPT
|
||||
ip6tables -F INPUT
|
||||
fi
|
||||
success "主机防火墙已调整为开放入站;云厂商安全组仍需单独配置。"
|
||||
}
|
||||
|
||||
configure_firewall() {
|
||||
case "$(env_value LANQIN_INSTALL_FIREWALL_MODE || true)" in
|
||||
1) configure_restricted_firewall ;;
|
||||
2) warn "已保留现有防火墙,请自行开放 SSH、25、80、443、465、587、993、995/TCP。" ;;
|
||||
3) configure_open_firewall ;;
|
||||
"") warn "旧版安装未记录防火墙模式,本次不修改防火墙。" ;;
|
||||
*) fail "防火墙模式配置无效。" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
ensure_nginx() {
|
||||
if ! command -v nginx >/dev/null 2>&1; then
|
||||
log "正在安装宿主机 Nginx..."
|
||||
install_packages nginx
|
||||
fi
|
||||
install -d -m 0755 "$(dirname "${NGINX_CONFIG}")" "${ACME_WEBROOT}/.well-known/acme-challenge"
|
||||
if command -v getenforce >/dev/null 2>&1 && [[ "$(getenforce)" == "Enforcing" ]] && command -v setsebool >/dev/null 2>&1; then
|
||||
setsebool -P httpd_can_network_connect 1
|
||||
fi
|
||||
}
|
||||
|
||||
write_nginx_http_config() {
|
||||
local hostname tmp
|
||||
hostname="$(env_value LANQIN_PUBLIC_HOSTNAME)"
|
||||
tmp="$(mktemp)"
|
||||
cat >"${tmp}" <<EOF
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name ${hostname};
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root ${ACME_WEBROOT};
|
||||
default_type text/plain;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8088;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
install -m 0644 "${tmp}" "${NGINX_CONFIG}"
|
||||
rm -f "${tmp}"
|
||||
nginx -t || fail "Nginx 配置检查失败,请检查 ${NGINX_CONFIG}。"
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl enable --now nginx
|
||||
systemctl reload nginx
|
||||
else
|
||||
nginx -s reload 2>/dev/null || nginx
|
||||
fi
|
||||
}
|
||||
|
||||
write_nginx_https_config() {
|
||||
local hostname tmp
|
||||
hostname="$(env_value LANQIN_PUBLIC_HOSTNAME)"
|
||||
tmp="$(mktemp)"
|
||||
cat >"${tmp}" <<EOF
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name ${hostname};
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root ${ACME_WEBROOT};
|
||||
default_type text/plain;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://\$host\$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name ${hostname};
|
||||
|
||||
ssl_certificate ${CERT_DIR}/fullchain.pem;
|
||||
ssl_certificate_key ${CERT_DIR}/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_session_cache shared:NewSzxcnSSL:10m;
|
||||
ssl_session_timeout 1d;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8088;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
install -m 0644 "${tmp}" "${NGINX_CONFIG}"
|
||||
rm -f "${tmp}"
|
||||
nginx -t || fail "HTTPS 配置检查失败,请检查 ${NGINX_CONFIG}。"
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl reload nginx
|
||||
else
|
||||
nginx -s reload
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_acme() {
|
||||
if [[ ! -x /root/.acme.sh/acme.sh ]]; then
|
||||
local hostname
|
||||
hostname="$(env_value LANQIN_PUBLIC_HOSTNAME)"
|
||||
log "正在安装官方 acme.sh..."
|
||||
curl -fsSL https://get.acme.sh | sh -s email="hostmaster@${hostname}"
|
||||
fi
|
||||
[[ -x /root/.acme.sh/acme.sh ]] || fail "acme.sh 安装失败。"
|
||||
}
|
||||
|
||||
install_certificate() {
|
||||
local hostname
|
||||
hostname="$(env_value LANQIN_PUBLIC_HOSTNAME)"
|
||||
ensure_acme
|
||||
log "正在为 ${hostname} 申请或检查 Let's Encrypt 证书..."
|
||||
if ! /root/.acme.sh/acme.sh --issue \
|
||||
--server letsencrypt \
|
||||
--keylength ec-256 \
|
||||
--domain "${hostname}" \
|
||||
--webroot "${ACME_WEBROOT}"; then
|
||||
warn "证书签发命令未创建新证书,将尝试安装已有的有效证书。"
|
||||
fi
|
||||
/root/.acme.sh/acme.sh --install-cert \
|
||||
--ecc \
|
||||
--domain "${hostname}" \
|
||||
--fullchain-file "${CERT_DIR}/fullchain.pem" \
|
||||
--key-file "${CERT_DIR}/privkey.pem" \
|
||||
--reloadcmd "/usr/local/bin/newszxcn-email reload" || fail "证书安装失败。请确认域名已解析到本机、80 端口可从公网访问,然后执行 newszxcn-email certificate 重试。"
|
||||
chmod 0644 "${CERT_DIR}/fullchain.pem"
|
||||
chmod 0600 "${CERT_DIR}/privkey.pem"
|
||||
set_env LANQIN_TLS_CERT_FILE "/certs/fullchain.pem"
|
||||
set_env LANQIN_TLS_KEY_FILE "/certs/privkey.pem"
|
||||
set_env LANQIN_SUBMISSION_ADDR ":587"
|
||||
set_env LANQIN_SUBMISSION_TLS_ADDR ":465"
|
||||
}
|
||||
|
||||
configure_web_mode() {
|
||||
local web_mode
|
||||
web_mode="$(env_value LANQIN_INSTALL_WEB_MODE || true)"
|
||||
case "${web_mode}" in
|
||||
1)
|
||||
ensure_nginx
|
||||
write_nginx_http_config
|
||||
install_certificate
|
||||
write_nginx_https_config
|
||||
compose up -d --remove-orphans --force-recreate lanqin-email
|
||||
wait_for_health 90 || fail "启用证书后服务未通过健康检查,请执行 newszxcn-email logs。"
|
||||
;;
|
||||
2)
|
||||
warn "请在宝塔或现有 Nginx 中把域名反代到 http://127.0.0.1:8088。"
|
||||
warn "邮件客户端证书仍需放入 ${CERT_DIR} 并配置 LANQIN_TLS_CERT_FILE/LANQIN_TLS_KEY_FILE。"
|
||||
;;
|
||||
3)
|
||||
warn "当前为 HTTP 测试模式,不适合正式公网运行。"
|
||||
;;
|
||||
"") ;;
|
||||
*) fail "Web 部署模式配置无效。" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
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_repair_install() {
|
||||
refresh_assets
|
||||
ensure_update_token
|
||||
configure_runtime_bindings
|
||||
ensure_docker
|
||||
backup_database
|
||||
remember_current_image
|
||||
configure_firewall
|
||||
prepare_directories
|
||||
log "正在拉取并修复 NewSzxcn Email 服务..."
|
||||
compose pull
|
||||
log "正在启动服务..."
|
||||
if ! compose up -d --remove-orphans; then
|
||||
warn "修复后容器启动失败,正在自动回滚。"
|
||||
do_rollback
|
||||
fail "修复失败,已回滚到原镜像。"
|
||||
fi
|
||||
if ! wait_for_health 90; then
|
||||
warn "修复后健康检查失败,正在自动回滚。"
|
||||
do_rollback
|
||||
fail "修复失败,已回滚到原镜像。"
|
||||
fi
|
||||
configure_web_mode
|
||||
success "安装完成:$(env_value LANQIN_PUBLIC_BASE_URL)"
|
||||
warn "下一步请配置 MX、SPF、DKIM、DMARC,并确认 25/465/587/993/995 端口可访问。"
|
||||
}
|
||||
|
||||
do_install() {
|
||||
if [[ -f "${INSTALL_DIR}/.env" ]]; then
|
||||
do_backup_reinstall
|
||||
return
|
||||
fi
|
||||
|
||||
refresh_assets
|
||||
configure_first_install
|
||||
ensure_update_token
|
||||
configure_runtime_bindings
|
||||
ensure_docker
|
||||
configure_firewall
|
||||
prepare_directories
|
||||
log "正在拉取 NewSzxcn Email 镜像..."
|
||||
compose pull
|
||||
log "正在启动服务..."
|
||||
compose up -d --remove-orphans
|
||||
wait_for_health 90 || fail "服务未能通过健康检查,请执行 newszxcn-email logs 查看日志。"
|
||||
configure_web_mode
|
||||
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}。"
|
||||
}
|
||||
|
||||
reload_services() {
|
||||
[[ -f "${INSTALL_DIR}/docker-compose.yml" ]] || return 0
|
||||
ensure_docker
|
||||
compose restart lanqin-email >/dev/null
|
||||
if command -v nginx >/dev/null 2>&1 && [[ -f "${NGINX_CONFIG}" ]]; then
|
||||
nginx -t >/dev/null
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl reload nginx
|
||||
else
|
||||
nginx -s reload
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
do_restart() {
|
||||
reload_services
|
||||
wait_for_health 90 || fail "重启后服务未通过健康检查。"
|
||||
success "邮局服务已重启。"
|
||||
}
|
||||
|
||||
do_certificate() {
|
||||
[[ -f "${INSTALL_DIR}/.env" ]] || fail "尚未安装。"
|
||||
[[ "$(env_value LANQIN_INSTALL_WEB_MODE || true)" == "1" ]] || fail "只有自动 Nginx + SSL 模式可使用此命令。"
|
||||
ensure_nginx
|
||||
write_nginx_http_config
|
||||
install_certificate
|
||||
write_nginx_https_config
|
||||
reload_services
|
||||
success "SSL 证书已安装并应用。"
|
||||
}
|
||||
|
||||
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
|
||||
if [[ -f "${NGINX_CONFIG}" ]]; then
|
||||
rm -f "${NGINX_CONFIG}"
|
||||
if command -v nginx >/dev/null 2>&1 && nginx -t >/dev/null 2>&1; then
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl reload nginx
|
||||
else
|
||||
nginx -s reload
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
success "容器和自动生成的 Nginx 配置已移除,${INSTALL_DIR} 中的邮件、证书、配置和数据库仍然保留。"
|
||||
}
|
||||
|
||||
do_backup_reinstall() {
|
||||
local backup_dir
|
||||
backup_dir="${INSTALL_DIR}.backup-$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
|
||||
if [[ -f "${INSTALL_DIR}/docker-compose.yml" ]] && command -v docker >/dev/null 2>&1; then
|
||||
ensure_docker
|
||||
compose down --remove-orphans
|
||||
fi
|
||||
if [[ -f "${NGINX_CONFIG}" ]]; then
|
||||
rm -f "${NGINX_CONFIG}"
|
||||
if command -v nginx >/dev/null 2>&1 && nginx -t >/dev/null 2>&1; then
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl reload nginx
|
||||
else
|
||||
nginx -s reload
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
mv "${INSTALL_DIR}" "${backup_dir}"
|
||||
success "旧安装已完整备份到 ${backup_dir}。"
|
||||
log "现在开始全新安装。"
|
||||
do_install
|
||||
}
|
||||
|
||||
do_menu() {
|
||||
local installed="false" default_choice="1" public_url="" choice
|
||||
if [[ -f "${INSTALL_DIR}/.env" ]]; then
|
||||
installed="true"
|
||||
default_choice="2"
|
||||
public_url="$(env_value LANQIN_PUBLIC_BASE_URL || true)"
|
||||
fi
|
||||
|
||||
prompt_text '\n==================================================\n'
|
||||
prompt_text ' NewSzxcn Email 一键安装与管理\n'
|
||||
prompt_text '==================================================\n'
|
||||
if [[ "${installed}" == "true" ]]; then
|
||||
prompt_text " 状态:已安装\n 路径:${INSTALL_DIR}\n"
|
||||
[[ -n "${public_url}" ]] && prompt_text " 地址:${public_url}\n"
|
||||
else
|
||||
prompt_text ' 状态:未安装\n'
|
||||
fi
|
||||
prompt_text '--------------------------------------------------\n'
|
||||
prompt_text ' 1. 安装 / 重新安装(旧数据自动备份)\n'
|
||||
prompt_text ' 2. 更新系统(数据库自动备份)\n'
|
||||
prompt_text ' 3. 修复现有安装\n'
|
||||
prompt_text ' 4. 查看运行状态\n'
|
||||
prompt_text ' 5. 重启服务\n'
|
||||
prompt_text ' 6. 查看实时日志\n'
|
||||
prompt_text ' 7. 申请或续期 SSL 证书\n'
|
||||
prompt_text ' 8. 回滚上个命令行版本\n'
|
||||
prompt_text ' 9. 卸载服务(保留数据)\n'
|
||||
prompt_text ' 0. 退出\n'
|
||||
prompt_text '==================================================\n'
|
||||
|
||||
choice="$(prompt_menu_choice "${default_choice}")"
|
||||
if [[ "${choice}" != "0" && "${choice}" != "1" && "${installed}" != "true" ]]; then
|
||||
fail "尚未安装,请先选择 1。"
|
||||
fi
|
||||
|
||||
case "${choice}" in
|
||||
0) success "已退出,未作任何修改。" ;;
|
||||
1) do_install ;;
|
||||
2) do_update ;;
|
||||
3) do_repair_install ;;
|
||||
4) ensure_docker; do_status ;;
|
||||
5) do_restart ;;
|
||||
6) ensure_docker; compose logs -f --tail=200 lanqin-email updater ;;
|
||||
7) do_certificate ;;
|
||||
8) ensure_docker; do_rollback ;;
|
||||
9) ensure_docker; do_uninstall ;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [[ "${LANQIN_SOURCE_ONLY:-false}" == "true" ]]; then
|
||||
if [[ "${BASH_SOURCE[0]}" != "$0" ]]; then
|
||||
return 0
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "${COMMAND}" in
|
||||
help|-h|--help) usage ;;
|
||||
menu) require_root; require_curl; do_menu ;;
|
||||
install) require_root; require_curl; do_install ;;
|
||||
update) require_root; require_curl; do_update ;;
|
||||
status) require_root; require_curl; ensure_docker; do_status ;;
|
||||
logs) require_root; require_curl; ensure_docker; compose logs -f --tail=200 lanqin-email updater ;;
|
||||
restart) require_root; require_curl; do_restart ;;
|
||||
reload) require_root; require_curl; reload_services ;;
|
||||
certificate) require_root; require_curl; do_certificate ;;
|
||||
rollback) require_root; require_curl; ensure_docker; do_rollback ;;
|
||||
uninstall) require_root; require_curl; ensure_docker; do_uninstall ;;
|
||||
*) usage; fail "未知命令:${COMMAND}" ;;
|
||||
esac
|
||||