Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bce259ca8a | |||
| 707f11687d | |||
| cc75382869 | |||
| efcdc691de | |||
| 081cfad02d | |||
| 39f5249008 | |||
| 11734bf119 | |||
| 2558aa96ed | |||
| 98e7190512 | |||
| 4ab7815886 | |||
| 361daf8693 | |||
| d2a762a426 | |||
| 7799d5d5b2 | |||
| 07054ca342 | |||
| 84657f91f9 | |||
| a5f5adf752 | |||
| 947e3ad248 | |||
| 2a572bf13a | |||
| 2b846ac671 | |||
| b3669f189e | |||
| d28ed4adcc | |||
| b36adfdac2 | |||
| 632a8a4896 | |||
| f8d058f7e4 | |||
| 1788d49a59 | |||
| adcc822c9a | |||
| 18db36d937 | |||
| 295a34881d | |||
| 65e4c4f6b5 | |||
| d3bee62acd | |||
| d1876691dd | |||
| 2476fe0c19 | |||
| 7b0717412c | |||
| 5c9bea075a | |||
| fa450c9f9b | |||
| f2457c63ee | |||
| 8d434b9ca8 | |||
| ce9d705df0 | |||
| b309368225 | |||
| 1373479973 | |||
| 23b04bd343 | |||
| 36b4c4c60f | |||
| 34d35e8f04 | |||
| 2d7625a751 | |||
| 3eebc1c358 | |||
| b320fee622 | |||
| 67ced8b3f8 | |||
| 0b4b4441c0 | |||
| c8dff253cc | |||
| 77187e76ce | |||
| 84cc47e3cf | |||
| 8d29dbd126 | |||
| 94ab94eceb | |||
| 74f456ef7d | |||
| 7720b6b404 | |||
| 0b982a5ff4 | |||
| 5571dcc511 | |||
| acdf298811 | |||
| 8cbcc22a74 | |||
| 31435c1e97 | |||
| e35b0985ee | |||
| 002c097def |
@@ -0,0 +1,3 @@
|
||||
*.sh text eol=lf
|
||||
deploy/**/entrypoint.sh text eol=lf
|
||||
deploy/**/sync-dkim.sh text eol=lf
|
||||
@@ -0,0 +1,34 @@
|
||||
changelog:
|
||||
exclude:
|
||||
labels:
|
||||
- skip-changelog
|
||||
- ignore-for-release
|
||||
categories:
|
||||
- title: 🚨 破坏性变更
|
||||
labels:
|
||||
- breaking-change
|
||||
- breaking change
|
||||
- title: 🔒 安全更新
|
||||
labels:
|
||||
- security
|
||||
- title: ✨ 新功能
|
||||
labels:
|
||||
- feature
|
||||
- enhancement
|
||||
- title: 🐛 问题修复
|
||||
labels:
|
||||
- bug
|
||||
- fix
|
||||
- title: 📚 文档
|
||||
labels:
|
||||
- documentation
|
||||
- docs
|
||||
- title: 🧰 工程维护
|
||||
labels:
|
||||
- chore
|
||||
- ci
|
||||
- dependencies
|
||||
- refactor
|
||||
- title: 其他更新
|
||||
labels:
|
||||
- "*"
|
||||
@@ -5,18 +5,42 @@ on:
|
||||
branches:
|
||||
- main
|
||||
types: [opened, synchronize, reopened]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to review'
|
||||
required: true
|
||||
type: number
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: ai-pr-review-${{ github.event.pull_request.number }}
|
||||
group: ai-pr-review-${{ github.event_name == 'issue_comment' && github.event.issue.number || github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
ai-review:
|
||||
name: AI PR Review
|
||||
if: >
|
||||
(
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
) ||
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request != null &&
|
||||
(
|
||||
contains(github.event.comment.body, '/ai-review') ||
|
||||
contains(github.event.comment.body, '@ai-reviewer review')
|
||||
) &&
|
||||
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
@@ -50,8 +74,8 @@ jobs:
|
||||
OPENAI_RETRIES: ${{ vars.OPENAI_RETRIES || '2' }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_NUMBER: ${{ github.event_name == 'issue_comment' && github.event.issue.number || github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }}
|
||||
PR_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }}
|
||||
REVIEW_RULES: .github/ai-review.md,AGENTS.md
|
||||
REVIEW_IGNORE: .ai-reviewignore
|
||||
REVIEW_SEVERITY_THRESHOLD: P3
|
||||
|
||||
@@ -6,8 +6,10 @@ on:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
packages: write
|
||||
pull-requests: read
|
||||
issues: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -161,3 +163,94 @@ jobs:
|
||||
VITE_RELEASE_URL=${{ needs.release.outputs.release_url }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
github-release:
|
||||
name: Create GitHub release
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- release
|
||||
- docker
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
shell: bash
|
||||
run: |
|
||||
tag="${{ needs.release.outputs.tag }}"
|
||||
version="${{ needs.release.outputs.version }}"
|
||||
repo="${GITHUB_REPOSITORY}"
|
||||
repo_url="https://github.com/${repo}"
|
||||
image_base="${REGISTRY}/${repo}"
|
||||
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
|
||||
|
||||
if ! gh api -X POST "repos/${repo}/releases/generate-notes" "${generate_args[@]}" --jq '.body' > generated-release-notes.md; then
|
||||
echo "GitHub 自动生成更新日志失败,已回退到提交列表。" > generated-release-notes.md
|
||||
echo >> generated-release-notes.md
|
||||
if [[ -n "${previous_tag}" ]]; then
|
||||
git log --reverse --pretty=format:"- %s ([%h](${repo_url}/commit/%H))" "${previous_tag}..${tag}" >> generated-release-notes.md
|
||||
echo >> generated-release-notes.md
|
||||
echo >> generated-release-notes.md
|
||||
echo "完整更新日志: [${previous_tag}...${tag}](${repo_url}/compare/${previous_tag}...${tag})" >> generated-release-notes.md
|
||||
else
|
||||
echo "- 首个公开版本。" >> generated-release-notes.md
|
||||
echo >> generated-release-notes.md
|
||||
echo "当前提交: [${GITHUB_SHA:0:7}](${repo_url}/commit/${GITHUB_SHA})" >> generated-release-notes.md
|
||||
fi
|
||||
fi
|
||||
|
||||
cat > release-notes.md <<EOF
|
||||
# LanQin Email ${tag}
|
||||
|
||||
自建邮箱 Webmail 全栈方案,包含 Web、API、Postfix、Dovecot、Rspamd 等组件。
|
||||
|
||||
## 注意
|
||||
|
||||
如果需要公网正常收发邮件,请确保已正确配置 MX、SPF、DKIM、DMARC 以及 25 / 587 / 993 等端口。
|
||||
|
||||
## 使用文档
|
||||
|
||||
- [项目文档](${repo_url}#readme)
|
||||
- [开源协议](${repo_url}/blob/main/LICENSE)
|
||||
|
||||
## Docker 镜像
|
||||
|
||||
| 组件 | 镜像 |
|
||||
|------|------|
|
||||
| All-in-one | \`${image_base}:${tag}\` |
|
||||
| API | \`${image_base}-api:${tag}\` |
|
||||
| Web | \`${image_base}-web:${tag}\` |
|
||||
| Postfix | \`${image_base}-postfix:${tag}\` |
|
||||
| Dovecot | \`${image_base}-dovecot:${tag}\` |
|
||||
| Rspamd | \`${image_base}-rspamd:${tag}\` |
|
||||
|
||||
同时也会发布 \`${version}\`、\`latest\` 和 \`sha-*\` 标签。
|
||||
EOF
|
||||
|
||||
{
|
||||
echo
|
||||
cat generated-release-notes.md
|
||||
} >> release-notes.md
|
||||
|
||||
- name: Create or update GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
shell: bash
|
||||
run: |
|
||||
tag="${{ needs.release.outputs.tag }}"
|
||||
title="LanQin 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
|
||||
gh release create "${tag}" --verify-tag --title "${title}" --notes-file release-notes.md --latest
|
||||
fi
|
||||
|
||||
@@ -1,107 +1,239 @@
|
||||
# LanQin Email
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
自建邮箱 Webmail 全栈方案。前端 React + shadcn/ui,后端 Go + SQLite,默认单容器集成 Postfix、Dovecot、Rspamd。
|
||||
LanQin Email 是一个自建邮箱 Webmail 全栈方案:前端使用 React + TypeScript + shadcn/ui,后端使用 Go + SQLite,部署时可用单容器集成 API、Web、Nginx、Postfix、Dovecot、Rspamd。
|
||||
|
||||
## 特性
|
||||
交流群组:[Telegram 群组](https://t.me/+EhII7MSyi3QwNDQ5)
|
||||
|
||||
- **Webmail 客户端** — 文件夹管理、邮件读写、附件、搜索、标签、星标、规则过滤
|
||||
- **多域名/多邮箱** — 域名管理、DKIM 签名、DNS 记录检测、邮箱别名转发
|
||||
- **双因素认证** — TOTP 两步登录(兼容 Google Authenticator / Authy)
|
||||
- **管理员面板** — 用户/域名/邮箱/别名/邮件管理、系统设置、邮件模板
|
||||
- **用户自助** — 开放注册、自助申请邮箱、黑名单、收件规则、联系人
|
||||
- **单容器部署** — 一个容器跑通 API + Web + Nginx + Postfix + Dovecot + Rspamd
|
||||
- **本地投递** — 开发环境系统内邮箱互发直接写入 Inbox,无需公网邮件栈
|
||||
## 功能特性
|
||||
|
||||
- **Webmail 客户端**:多邮箱切换、文件夹、邮件读写、草稿、定时发送、附件、搜索、标签、星标、移动/删除、已读/未读。
|
||||
- **邮箱增强**:联系人、签名、收件规则、发件人黑名单、邮件统计、归档已读、清空回收站/垃圾邮件。
|
||||
- **多域名/多邮箱**:域名管理、DKIM 密钥生成、DNS 记录展示与检测、邮箱账号、别名转发、无人收件开关。
|
||||
- **账号与权限**:登录/注册、会话管理、TOTP 两步验证、Cloudflare Turnstile、用户自助申请邮箱、权限组/RBAC。
|
||||
- **管理员面板**:概览清单、用户/权限组/域名/邮箱/别名/全部邮件管理、系统设置、邮件模板、SMTP 测试。
|
||||
- **邮件服务栈**:Postfix 投递、Dovecot IMAP/POP3、Rspamd 反垃圾与 DKIM 签名、Maildir 到 SQLite 同步。
|
||||
- **部署友好**:默认 all-in-one 单容器,也提供多容器 stack 方便调试 Postfix/Dovecot/Rspamd。
|
||||
|
||||
## 界面预览
|
||||
|
||||
| Webmail 邮件阅读与列表 | 写邮件 · 富文本编辑工具栏 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
| 多邮箱切换、文件夹、搜索、标签、星标与邮件阅读面板。 | 富文本工具栏支持字体、标题、加粗、斜体、下划线、颜色、高亮、列表、对齐、引用、代码块、附件、表情与定时发送。 |
|
||||
| 管理后台 · 系统概览 | 第三方客户端配置 |
|
||||
|  |  |
|
||||
| 管理用户、权限组、域名、邮箱、别名、系统设置与发送审计。 | 一键查看 IMAP / POP3 / SMTP 服务器、端口、安全方式与账号信息。 |
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
.
|
||||
├── apps/api # Go API、SQLite schema、邮件同步与业务逻辑
|
||||
├── apps/web # React/Vite Webmail 与管理后台
|
||||
├── deploy # Docker Compose、镜像构建、Postfix/Dovecot/Rspamd 配置
|
||||
└── .github/workflows # Docker 镜像发布流水线
|
||||
```
|
||||
|
||||
## 环境要求
|
||||
|
||||
### 开发环境
|
||||
|
||||
- Go 1.25+
|
||||
- Node.js 20+
|
||||
- pnpm 10.28.2(可通过 corepack 启用)
|
||||
|
||||
### 部署环境
|
||||
|
||||
- Docker Engine
|
||||
- Docker Compose v2
|
||||
- 可解析的邮件域名,以及可用的 25 / 465 / 587 / 993 / 995 等端口
|
||||
|
||||
> 公网收发邮件还需要正确配置 MX、SPF、DKIM、DMARC,并确认云厂商未封禁 SMTP 端口。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 开发
|
||||
### 本地开发
|
||||
|
||||
后端:
|
||||
|
||||
```bash
|
||||
# 后端
|
||||
cd apps/api
|
||||
go mod tidy
|
||||
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
|
||||
```
|
||||
|
||||
默认管理员:`admin@lanqin.local`,密码通过 `LANQIN_ADMIN_PASSWORD` 设置(不设置则启动时随机生成并输出到日志)。
|
||||
访问:
|
||||
|
||||
### 部署
|
||||
- Web:`http://localhost:5173`
|
||||
- API:`http://localhost:8080`
|
||||
|
||||
服务器只需要 Compose 文件和配置,不需要源码构建:
|
||||
默认管理员邮箱为 `admin@lanqin.local`。建议开发时显式设置 `LANQIN_ADMIN_PASSWORD`;如果未设置,后端首次启动会随机生成密码并输出到日志。
|
||||
|
||||
### Docker 部署(单容器)
|
||||
|
||||
服务器只需要 `deploy/` 下的 Compose 文件和配置,不需要源码构建:
|
||||
|
||||
```bash
|
||||
cd deploy
|
||||
cp .env.example .env
|
||||
# 修改 .env 里的域名和管理员密码
|
||||
# 修改 .env:域名、访问地址、管理员邮箱、管理员密码等
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
单容器内部集成:API、Web、Nginx、Postfix、Dovecot、Rspamd。
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
# 查看日志
|
||||
docker compose logs -f lanqin-email
|
||||
|
||||
# 更新镜像并重启
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
|
||||
# 停止服务
|
||||
docker compose down
|
||||
```
|
||||
|
||||
如需在完整源码仓库中本地构建镜像:
|
||||
|
||||
```bash
|
||||
cd deploy
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
```
|
||||
|
||||
更多部署细节见 [`deploy/README.md`](./deploy/README.md)。
|
||||
|
||||
## 首次部署清单
|
||||
|
||||
1. 编辑 `deploy/.env`:至少修改 `LANQIN_PUBLIC_HOSTNAME`、`LANQIN_PUBLIC_BASE_URL`、`LANQIN_ADMIN_EMAIL`、`LANQIN_ADMIN_PASSWORD`。
|
||||
2. 生产环境建议挂载真实 TLS 证书,并设置 `LANQIN_TLS_CERT_FILE` / `LANQIN_TLS_KEY_FILE`。
|
||||
3. 登录管理后台,添加邮件域名。
|
||||
4. 在域名管理中复制并配置 MX、SPF、DKIM、DMARC 记录,然后点击 DNS 检测。
|
||||
5. 创建邮箱账号、别名转发或权限组,按需开启注册、2FA、Turnstile、自助申请邮箱。
|
||||
6. 使用后台 SMTP 测试与 Webmail 收发测试确认链路正常。
|
||||
|
||||
## 关键环境变量
|
||||
|
||||
完整配置见 [`deploy/.env.example`](./deploy/.env.example)。常用变量如下:
|
||||
|
||||
| 变量 | 说明 | 默认/示例 |
|
||||
|------|------|-----------|
|
||||
| `LANQIN_IMAGE` | all-in-one 镜像 | `ghcr.io/lanqin996/lanqin-email:latest` |
|
||||
| `LANQIN_PUBLIC_HOSTNAME` | 邮件服务器主机名,影响 Postfix/DNS 展示/链接 | `mail.example.com` |
|
||||
| `LANQIN_PUBLIC_BASE_URL` | Webmail 对外访问地址 | `https://mail.example.com` |
|
||||
| `LANQIN_ADMIN_EMAIL` | 初始管理员邮箱 | `admin@example.com` |
|
||||
| `LANQIN_ADMIN_PASSWORD` | 初始管理员密码,生产必须修改 | `ChangeMe123!` |
|
||||
| `LANQIN_DB_PATH` | SQLite 数据库路径 | `/data/lanqin.db` |
|
||||
| `LANQIN_ALLOW_INSECURE_HTTP` | 是否允许非 HTTPS Cookie,本地调试可开 | `false` |
|
||||
| `LANQIN_OPEN_REGISTRATION` | 是否开放注册 | `false` |
|
||||
| `LANQIN_TWO_FACTOR_ENABLED` | 2FA 功能总开关 | `false` |
|
||||
| `LANQIN_TURNSTILE_ENABLED` | 是否启用 Turnstile | `false` |
|
||||
| `LANQIN_SMTP_HOST` / `LANQIN_SMTP_PORT` | Webmail 发信 SMTP | `127.0.0.1` / `25` |
|
||||
| `LANQIN_MAILDIR_ROOT` | Maildir 根目录 | `/var/mail/vhosts` |
|
||||
| `LANQIN_CATCH_ALL_ENABLED` | 未注册收件地址是否进入全部邮件 | `false` |
|
||||
| `LANQIN_USER_MAILBOX_APPLY_ENABLED` | 是否允许用户自助申请邮箱 | `false` |
|
||||
| `LANQIN_EXTERNAL_IMAP_ENABLED` | 是否启用外部 IMAP 接入;也可在后台“系统设置 > 外部 IMAP”配置 | `false` |
|
||||
| `LANQIN_EXTERNAL_IMAP_SECRET_KEY` | 外部 IMAP 密码加密密钥,启用接入前必须设置;也可在后台配置 | 随机长字符串 |
|
||||
| `LANQIN_EXTERNAL_IMAP_SYNC_SECONDS` | 外部 IMAP 本地存储模式同步间隔;也可在后台配置 | `300` |
|
||||
| `LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS` | 是否允许外部 IMAP 连接内网/localhost 主机;也可在后台配置 | `false` |
|
||||
| `LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_ID` / `LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET` | Gmail 外部 IMAP OAuth2,回调为 `/api/external-imap-oauth/gmail/callback` | 空 |
|
||||
| `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID` / `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET` | Microsoft 365 / Outlook 外部 IMAP OAuth2,回调为 `/api/external-imap-oauth/outlook/callback` | 空 |
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Docker 容器 │
|
||||
│ ┌──────┐ ┌────────┐ ┌──────┐ ┌──────────┐ │
|
||||
│ │ API │ │ Web │ │Nginx │ │ Postfix │ │
|
||||
│ │ Go │ │ React │ │反代 │ │ MTA │ │
|
||||
│ └──┬───┘ └────────┘ └──────┘ └────┬─────┘ │
|
||||
│ │ SQLite Maildir │ │
|
||||
│ └──────────────────────────────────┘ │
|
||||
│ ┌────────┐ ┌──────────┐ │
|
||||
│ │Dovecot │ │ Rspamd │ │
|
||||
│ │ IMAP │ │ 反垃圾 │ │
|
||||
│ └────────┘ └──────────┘ │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```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 接收 → Dovecot 写入 Maildir → API worker 同步到 SQLite → Webmail 展示
|
||||
2. **发件** → Webmail 编辑 → API 构造 MIME → Postfix 投递
|
||||
3. **反垃圾** → Rspamd 在 Postfix 投递前评分,标记 Spam 文件夹
|
||||
邮件流转:
|
||||
|
||||
## 能力
|
||||
1. **收件**:Postfix 接收邮件 → Rspamd 评分/标记 → Dovecot 写入 Maildir → API worker 同步到 SQLite → Webmail 展示。
|
||||
2. **发件**:Webmail 调用 API → API 构造 MIME → SMTP 提交给 Postfix 或外部 SMTP → 投递到目标地址。
|
||||
3. **本地投递**:开发环境中,系统内邮箱互发可直接写入对方 Inbox;未配置 `LANQIN_SMTP_HOST` 时不会真正投递外部收件人。
|
||||
4. **第三方客户端**:可通过 SMTP 465/587、IMAP 993、POP3 995 连接;生产环境请配置匹配 `LANQIN_PUBLIC_HOSTNAME` 的证书。
|
||||
5. **外部邮箱接入**:个人邮箱管理可添加外部 IMAP 账号。本地存储模式会同步入库;远端直连模式每次读取远端,不写入本地邮件表。
|
||||
|
||||
| 模块 | 功能 |
|
||||
|------|------|
|
||||
| 认证 | 登录/注册、会话管理、双因素 TOTP、Turnstile 人机验证 |
|
||||
| 域名 | 多域名管理、DKIM 密钥生成、DNS 记录展示与检测 |
|
||||
| 邮箱 | 邮箱账号管理、容量配额、密码同步 |
|
||||
| Webmail | 文件夹、邮件列表、阅读、写信、附件、搜索、已读/未读、星标、移动、删除、标签 |
|
||||
| 规则 | 收件规则(条件+动作)、发件人黑名单 |
|
||||
| 联系人 | 个人通讯录管理 |
|
||||
| 管理 | 用户/域名/邮箱/别名 CRUD、系统设置持久化、邮件模板编辑、SMTP 测试 |
|
||||
| 清理 | 归档已读、清空回收站/垃圾邮件 |
|
||||
## 开发与验证
|
||||
|
||||
## 收发说明
|
||||
```bash
|
||||
# API 测试
|
||||
cd apps/api
|
||||
go test ./...
|
||||
|
||||
- **开发环境**:系统内邮箱互发直接投递到对方 Inbox。未配置 `LANQIN_SMTP_HOST` 时外部收件人不会真正投递。
|
||||
- **服务器部署**:`.env` 默认 `LANQIN_SMTP_HOST=127.0.0.1`,发件交给同容器内 Postfix。
|
||||
- **收件同步**:Postfix/Dovecot 收到 Maildir 后,API 的 Maildir worker 同步到 SQLite 后展示。
|
||||
- **公网收发**:需要正确配置 MX/SPF/DKIM/DMARC,并确认云厂商开放 25/587/993 端口。
|
||||
# Web 检查与构建
|
||||
cd apps/web
|
||||
pnpm run check
|
||||
|
||||
## 要求
|
||||
# 单容器源码构建验证
|
||||
cd deploy
|
||||
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
```
|
||||
|
||||
- Go 1.22+
|
||||
- Node.js 20+
|
||||
- Docker & Docker Compose(部署)
|
||||
## 生产注意事项
|
||||
|
||||
- 生产环境必须修改默认管理员密码,并妥善保管 `.env`、SQLite 数据库、Maildir 与 DKIM 私钥。
|
||||
- Web 可放在宿主机 Nginx/宝塔/边缘网关后,但 SMTP/IMAP/POP3 证书需要单独挂载给容器内 Postfix/Dovecot。
|
||||
- 云厂商常默认封禁 25 端口;无法收发公网邮件时先检查端口、安全组、防火墙与反向 DNS。
|
||||
- SQLite 适合单机部署;多节点部署前需要迁移数据库,并同步调整 Postfix/Dovecot 查询配置。
|
||||
|
||||
## SMTP 提交
|
||||
|
||||
- 第三方客户端的 SMTP 提交 `465/587` 由 LanQin API 进程处理。
|
||||
- 启用 SMTP 提交前必须配置 `LANQIN_TLS_CERT_FILE` / `LANQIN_TLS_KEY_FILE`;API 不会用 localhost 自签证书对外提供 465/587。
|
||||
- Postfix 只保留 `25` 端口,用于公网入站邮件和内部/外部 relay。
|
||||
- Webmail/API 和第三方客户端发信都会先写入 Sent,再进入发送队列。
|
||||
- 发送队列由 LanQin API 后台 worker relay 到 `LANQIN_SMTP_HOST:LANQIN_SMTP_PORT`,失败会记录审计并按退避策略重试。
|
||||
- v1 支持本人邮箱发信;如需 send-as,可使用启用的别名转发 source 指向本人邮箱,或在数据库中配置 `send_as_grants`。
|
||||
- 如果客户端随后又通过 IMAP APPEND 写入自己的 Sent 副本,Maildir 同步会按 Sent 文件夹内的 `Message-ID` 去重。
|
||||
|
||||
## License
|
||||
|
||||
[MIT](./LICENSE)
|
||||
|
||||
|
||||
## Star 趋势
|
||||
|
||||
<a href="https://www.star-history.com/?repos=LanQin996%2FLanQin-Email&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
友情链接:[LINUX DO](https://linux.do/) —— 新的理想型社区
|
||||
|
||||
|
||||
@@ -7,9 +7,12 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
smtpserver "github.com/emersion/go-smtp"
|
||||
|
||||
"lanqin-email-api/internal/app"
|
||||
)
|
||||
|
||||
@@ -29,6 +32,15 @@ func main() {
|
||||
Handler: svc.Router(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
submissionServers := &app.SubmissionServers{}
|
||||
if strings.TrimSpace(cfg.SubmissionAddr) != "" || strings.TrimSpace(cfg.SubmissionTLSAddr) != "" {
|
||||
tlsConfig, err := app.LoadServerTLSConfig(cfg)
|
||||
if err != nil {
|
||||
logger.Error("failed to initialize TLS config", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
submissionServers = svc.NewSubmissionServers(tlsConfig)
|
||||
}
|
||||
|
||||
go func() {
|
||||
logger.Info("LanQin API listening", "addr", cfg.Addr)
|
||||
@@ -37,6 +49,24 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
if submissionServers.Plain != nil {
|
||||
go func() {
|
||||
logger.Info("LanQin SMTP submission listening", "addr", cfg.SubmissionAddr)
|
||||
if err := submissionServers.Plain.ListenAndServe(); err != nil && !errors.Is(err, smtpserver.ErrServerClosed) {
|
||||
logger.Error("smtp submission server stopped unexpectedly", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
if submissionServers.TLS != nil {
|
||||
go func() {
|
||||
logger.Info("LanQin SMTP implicit TLS submission listening", "addr", cfg.SubmissionTLSAddr)
|
||||
if err := submissionServers.TLS.ListenAndServeTLS(); err != nil && !errors.Is(err, smtpserver.ErrServerClosed) {
|
||||
logger.Error("smtp tls submission server stopped unexpectedly", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
@@ -48,5 +78,9 @@ func main() {
|
||||
logger.Error("server shutdown failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := submissionServers.Shutdown(shutdownCtx); err != nil {
|
||||
logger.Error("smtp submission shutdown failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Info("server stopped")
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ require (
|
||||
require (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8 // indirect
|
||||
github.com/emersion/go-message v0.18.2 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
github.com/emersion/go-smtp v0.24.0 // 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
|
||||
@@ -20,6 +24,7 @@ require (
|
||||
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/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sys v0.23.0 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
modernc.org/libc v1.55.3 // indirect
|
||||
|
||||
@@ -2,6 +2,14 @@ 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/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||
@@ -22,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=
|
||||
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.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
||||
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.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.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
|
||||
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
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/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=
|
||||
|
||||
@@ -73,21 +73,37 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
item.Mailboxes = splitCSV(mailboxCSV)
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list users")
|
||||
return
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list users")
|
||||
return
|
||||
}
|
||||
for i := range items {
|
||||
if err := a.attachUserAuthorization(r.Context(), &items[i].User); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load user permissions")
|
||||
return
|
||||
}
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
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"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Password string `json:"password"`
|
||||
Disabled bool `json:"disabled"`
|
||||
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"))
|
||||
@@ -105,6 +121,10 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("invalid role"))
|
||||
return
|
||||
}
|
||||
if role == "admin" && (actor == nil || actor.Role != "admin") {
|
||||
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"))
|
||||
return
|
||||
@@ -116,12 +136,29 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
id := newID("usr")
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
_, err = a.db.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)
|
||||
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(), `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 {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
permissionGroupIDs := req.PermissionGroupIDs
|
||||
if role == "admin" {
|
||||
permissionGroupIDs = nil
|
||||
}
|
||||
if err := a.setUserPermissionGroups(r.Context(), tx, id, permissionGroupIDs, actor); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to create user")
|
||||
return
|
||||
}
|
||||
user, err := a.adminUserByID(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load user")
|
||||
@@ -134,9 +171,10 @@ 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"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
PermissionGroupIDs *[]string `json:"permissionGroupIds"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
@@ -155,21 +193,79 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("invalid role"))
|
||||
return
|
||||
}
|
||||
disabled := false
|
||||
existing, err := a.userByID(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
if current == nil || (current.Role != "admin" && (existing.Role == "admin" || role == "admin")) {
|
||||
respondError(w, http.StatusForbidden, "only administrators can modify administrator users")
|
||||
return
|
||||
}
|
||||
disabled := existing.Disabled
|
||||
if req.Disabled != nil {
|
||||
disabled = *req.Disabled
|
||||
}
|
||||
if current != nil && current.ID == id && (disabled || role != "admin") {
|
||||
badRequest(w, errors.New("cannot remove your own admin access"))
|
||||
if a.isDefaultAdminUser(existing) && (role != "admin" || disabled) {
|
||||
badRequest(w, errors.New("default administrator must remain an active super administrator"))
|
||||
return
|
||||
}
|
||||
if err := a.ensureAdminRemains(r.Context(), id, role, disabled); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
_, err := a.db.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)
|
||||
shouldUpdatePermissionGroups := role == "admin" || existing.Role == "admin" || req.PermissionGroupIDs != nil
|
||||
var permissionGroupIDs []string
|
||||
if role == "user" {
|
||||
if req.PermissionGroupIDs != nil {
|
||||
permissionGroupIDs = *req.PermissionGroupIDs
|
||||
} else if existing.Role == "user" {
|
||||
for _, groupID := range existing.PermissionGroupIDs {
|
||||
if isAssignablePermissionGroupID(groupID) {
|
||||
permissionGroupIDs = append(permissionGroupIDs, groupID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if current != nil && current.ID == id {
|
||||
next := *existing
|
||||
next.Role = role
|
||||
next.Disabled = disabled
|
||||
if shouldUpdatePermissionGroups {
|
||||
if role == "admin" {
|
||||
next.Permissions = allPermissionKeys()
|
||||
} else {
|
||||
permissions, err := a.effectivePermissionsForUserGroups(r.Context(), nil, permissionGroupIDs)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
next.Permissions = permissions
|
||||
}
|
||||
}
|
||||
if next.Disabled || !userHasAdminAccess(&next) {
|
||||
badRequest(w, errors.New("cannot remove your own admin access"))
|
||||
return
|
||||
}
|
||||
}
|
||||
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 display_name=?, role=?, disabled=?, updated_at=? WHERE id=?`,
|
||||
displayName, role, boolInt(disabled), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update user")
|
||||
return
|
||||
}
|
||||
if shouldUpdatePermissionGroups {
|
||||
if err := a.setUserPermissionGroups(r.Context(), tx, id, permissionGroupIDs, current); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update user")
|
||||
return
|
||||
}
|
||||
@@ -183,6 +279,16 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) handleResetUserPassword(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if target, err := a.userByID(r.Context(), id); err != nil {
|
||||
respondError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
} else if target.Role == "admin" {
|
||||
current := currentUser(r)
|
||||
if current == nil || current.Role != "admin" {
|
||||
respondError(w, http.StatusForbidden, "only administrators can reset administrator passwords")
|
||||
return
|
||||
}
|
||||
}
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
@@ -234,6 +340,16 @@ func (a *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("cannot delete your own user"))
|
||||
return
|
||||
}
|
||||
if target, err := a.userByID(r.Context(), id); err != nil {
|
||||
respondError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
} else if a.isDefaultAdminUser(target) {
|
||||
badRequest(w, errors.New("default administrator cannot be deleted"))
|
||||
return
|
||||
} else if target.Role == "admin" && (current == nil || current.Role != "admin") {
|
||||
respondError(w, http.StatusForbidden, "only administrators can delete administrator users")
|
||||
return
|
||||
}
|
||||
if err := a.ensureAdminRemains(r.Context(), id, "user", true); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
@@ -409,6 +525,13 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("invalid role"))
|
||||
return
|
||||
}
|
||||
if role == "admin" {
|
||||
current := currentUser(r)
|
||||
if current == nil || current.Role != "admin" {
|
||||
respondError(w, http.StatusForbidden, "only administrators can create administrator users")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
domain, err := a.domainByID(r.Context(), req.DomainID)
|
||||
if err != nil {
|
||||
@@ -592,7 +715,7 @@ func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
rows.Close()
|
||||
for _, messageID := range messageIDs {
|
||||
a.deleteMessageFiles(r.Context(), messageID)
|
||||
a.deleteMessage(r.Context(), messageID)
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id)
|
||||
if err != nil {
|
||||
@@ -663,7 +786,7 @@ func (a *App) handleAdminMessages(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
args = append(args, limit+1, offset)
|
||||
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(mb.address,''),COALESCE(u.email,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,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
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(mb.address,''),COALESCE(u.email,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),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
|
||||
LEFT JOIN folders f ON f.id=m.folder_id
|
||||
LEFT JOIN mailboxes mb ON mb.id=m.mailbox_id
|
||||
@@ -710,6 +833,116 @@ func (a *App) handleAdminMessage(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, msg)
|
||||
}
|
||||
|
||||
func (a *App) handleAdminSendAudit(w http.ResponseWriter, r *http.Request) {
|
||||
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
|
||||
messageID := strings.TrimSpace(r.URL.Query().Get("messageId"))
|
||||
event := strings.TrimSpace(r.URL.Query().Get("event"))
|
||||
from, err := adminAuditTimeParam(r.URL.Query().Get("from"), false)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
to, err := adminAuditTimeParam(r.URL.Query().Get("to"), true)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("cursor"))
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
limit := 50
|
||||
|
||||
where := []string{"1=1"}
|
||||
args := []any{}
|
||||
if mailboxID != "" && mailboxID != "all" {
|
||||
where = append(where, "sae.mailbox_id=?")
|
||||
args = append(args, mailboxID)
|
||||
}
|
||||
if messageID != "" {
|
||||
where = append(where, "(sq.message_id=? OR m.message_id=? OR sae.sent_message_id=?)")
|
||||
args = append(args, messageID, messageID, messageID)
|
||||
}
|
||||
if event != "" && event != "all" {
|
||||
if !isSendAuditEvent(event) {
|
||||
badRequest(w, errors.New("invalid event"))
|
||||
return
|
||||
}
|
||||
where = append(where, "sae.event=?")
|
||||
args = append(args, event)
|
||||
}
|
||||
if from != "" {
|
||||
where = append(where, "sae.created_at>=?")
|
||||
args = append(args, from)
|
||||
}
|
||||
if to != "" {
|
||||
where = append(where, "sae.created_at<=?")
|
||||
args = append(args, to)
|
||||
}
|
||||
args = append(args, limit+1, offset)
|
||||
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT sae.id,sae.queue_id,sae.mailbox_id,COALESCE(mb.address,''),sae.sent_message_id,COALESCE(sq.message_id,m.message_id,''),sae.source,sae.event,sae.status,sae.mail_from,sae.header_from,sae.recipients_json,sae.error,sae.created_at
|
||||
FROM send_audit_events sae
|
||||
LEFT JOIN mailboxes mb ON mb.id=sae.mailbox_id
|
||||
LEFT JOIN send_queue sq ON sq.id=sae.queue_id
|
||||
LEFT JOIN messages m ON m.id=sae.sent_message_id
|
||||
WHERE `+strings.Join(where, " AND ")+`
|
||||
ORDER BY sae.created_at DESC, sae.id DESC LIMIT ? OFFSET ?`, args...)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load send audit")
|
||||
return
|
||||
}
|
||||
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.MailboxAddress, &item.SentMessageID, &item.MessageID, &item.Source, &item.Event, &item.Status, &item.MailFrom, &item.HeaderFrom, &recipientsJSON, &item.Error, &createdAt); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan send audit")
|
||||
return
|
||||
}
|
||||
item.Recipients = jsonDecodeSlice(recipientsJSON)
|
||||
item.CreatedAt = parseTime(createdAt)
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load send audit")
|
||||
return
|
||||
}
|
||||
next := ""
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
next = strconv.Itoa(offset + limit)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
|
||||
}
|
||||
|
||||
func adminAuditTimeParam(value string, endOfDay bool) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
|
||||
return t.UTC().Format(time.RFC3339Nano), nil
|
||||
}
|
||||
if t, err := time.Parse("2006-01-02", value); err == nil {
|
||||
if endOfDay {
|
||||
t = t.Add(24*time.Hour - time.Nanosecond)
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339Nano), nil
|
||||
}
|
||||
return "", errors.New("invalid time filter")
|
||||
}
|
||||
|
||||
func isSendAuditEvent(event string) bool {
|
||||
switch event {
|
||||
case sendAuditAccepted, sendAuditQueued, sendAuditRetry, sendAuditDelivered, sendAuditFailed, sendAuditCanceled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleCreateAlias(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
DomainID string `json:"domainId"`
|
||||
@@ -836,6 +1069,9 @@ func (a *App) adminUserByID(ctx context.Context, id string) (*AdminUser, error)
|
||||
item.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
item.CreatedAt = parseTime(created)
|
||||
item.Mailboxes = splitCSV(mailboxCSV)
|
||||
if err := a.attachUserAuthorization(ctx, &item.User); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
@@ -913,6 +1149,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,created_at) VALUES(?,?,?,?,?)`, id, mailboxID, folder, role, 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
|
||||
}
|
||||
|
||||
+400
-13
@@ -15,6 +15,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -22,12 +23,15 @@ import (
|
||||
)
|
||||
|
||||
type App struct {
|
||||
cfg Config
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
policy *HTMLPolicy
|
||||
workerCancel context.CancelFunc
|
||||
cfg Config
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
now func() time.Time
|
||||
policy *HTMLPolicy
|
||||
workerCancel context.CancelFunc
|
||||
workerWG sync.WaitGroup
|
||||
maildirHealth *maildirSyncHealthTracker
|
||||
externalIMAP externalIMAPClientFactory
|
||||
}
|
||||
|
||||
func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
@@ -47,7 +51,8 @@ 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()}
|
||||
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
|
||||
@@ -70,13 +75,24 @@ 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(cfg.MaildirRoot) != "" {
|
||||
go a.maildirWorker(workerCtx)
|
||||
a.startWorker(func() { a.scheduledSendWorker(workerCtx) })
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) != "" {
|
||||
a.startWorker(func() { a.maildirWorker(workerCtx) })
|
||||
}
|
||||
a.startWorker(func() { a.sendQueueWorker(workerCtx) })
|
||||
a.startWorker(func() { a.externalIMAPWorker(workerCtx) })
|
||||
a.startWorker(func() { a.smtpEventsCleanupWorker(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
|
||||
@@ -84,6 +100,7 @@ func (a *App) Close() error {
|
||||
if a.workerCancel != nil {
|
||||
a.workerCancel()
|
||||
}
|
||||
a.workerWG.Wait()
|
||||
return a.db.Close()
|
||||
}
|
||||
|
||||
@@ -115,6 +132,23 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS permission_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
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}',
|
||||
system INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS user_permission_groups (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id TEXT NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id, group_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_user_permission_groups_group ON user_permission_groups(group_id, user_id)`,
|
||||
`CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -182,6 +216,10 @@ 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,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(mailbox_id, name)
|
||||
)`,
|
||||
@@ -207,7 +245,14 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
is_starred INTEGER NOT NULL DEFAULT 0,
|
||||
has_attachments INTEGER NOT NULL DEFAULT 0,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
auth_results TEXT NOT NULL DEFAULT '',
|
||||
auth_spf TEXT NOT NULL DEFAULT 'unknown',
|
||||
auth_dkim TEXT NOT NULL DEFAULT 'unknown',
|
||||
auth_dmarc TEXT NOT NULL DEFAULT 'unknown',
|
||||
received_spf TEXT NOT NULL DEFAULT '',
|
||||
raw_path TEXT NOT NULL DEFAULT '',
|
||||
imap_uid INTEGER NOT NULL DEFAULT 0,
|
||||
imap_modseq INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
@@ -215,6 +260,60 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
`CREATE INDEX IF NOT EXISTS idx_messages_search ON messages(mailbox_id, subject, from_addr, from_name, snippet)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_mailbox_raw_path ON messages(mailbox_id, raw_path) WHERE raw_path <> '' AND mailbox_id IS NOT NULL`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_unregistered_raw_path ON messages(raw_path) WHERE raw_path <> '' AND mailbox_id IS NULL`,
|
||||
`CREATE TABLE IF NOT EXISTS sent_message_dedupe_keys (
|
||||
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
folder_id TEXT NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
|
||||
message_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(mailbox_id, folder_id, message_id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS send_as_grants (
|
||||
id TEXT PRIMARY KEY,
|
||||
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
address TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(mailbox_id, address)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS send_queue (
|
||||
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,
|
||||
sent_message_id TEXT NOT NULL DEFAULT '',
|
||||
message_id TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL,
|
||||
mail_from TEXT NOT NULL,
|
||||
header_from TEXT NOT NULL,
|
||||
recipients_json TEXT NOT NULL,
|
||||
mime_base64 TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
max_attempts INTEGER NOT NULL DEFAULT 5,
|
||||
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_send_queue_due ON send_queue(status, next_attempt_at, created_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS send_audit_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
queue_id TEXT NOT NULL DEFAULT '',
|
||||
user_id TEXT NOT NULL DEFAULT '',
|
||||
mailbox_id TEXT NOT NULL DEFAULT '',
|
||||
sent_message_id TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
mail_from TEXT NOT NULL DEFAULT '',
|
||||
header_from TEXT NOT NULL DEFAULT '',
|
||||
recipients_json TEXT NOT NULL DEFAULT '[]',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
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 attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
@@ -238,6 +337,92 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
sent_at TEXT
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_scheduled_sends_due ON scheduled_sends(status, send_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS smtp_send_events (
|
||||
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,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_smtp_send_events_user_created ON smtp_send_events(user_id, created_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS imap_events (
|
||||
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,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_imap_events_user_created ON imap_events(user_id, created_at)`,
|
||||
`CREATE TABLE IF NOT EXISTS pop3_events (
|
||||
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,
|
||||
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,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -318,6 +503,12 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
if err := a.migrateMessagesFromName(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateMessageAuthentication(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.rebuildHTMLOnlyMessageSnippets(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateUsersForTwoFactor(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -327,9 +518,195 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
if err := a.migrateLegacyBootstrapMailbox(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migratePermissionGroupLimits(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateSendQueueMessageID(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
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.ensureDefaultPermissionGroups(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) migrateMessageAuthentication(ctx context.Context) error {
|
||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
columns[name] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
alter := []struct {
|
||||
name string
|
||||
sql string
|
||||
}{
|
||||
{"auth_results", `ALTER TABLE messages ADD COLUMN auth_results TEXT NOT NULL DEFAULT ''`},
|
||||
{"auth_spf", `ALTER TABLE messages ADD COLUMN auth_spf TEXT NOT NULL DEFAULT 'unknown'`},
|
||||
{"auth_dkim", `ALTER TABLE messages ADD COLUMN auth_dkim TEXT NOT NULL DEFAULT 'unknown'`},
|
||||
{"auth_dmarc", `ALTER TABLE messages ADD COLUMN auth_dmarc TEXT NOT NULL DEFAULT 'unknown'`},
|
||||
{"received_spf", `ALTER TABLE messages ADD COLUMN received_spf TEXT NOT NULL DEFAULT ''`},
|
||||
}
|
||||
for _, item := range alter {
|
||||
if !columns[item.name] {
|
||||
if _, err := a.db.ExecContext(ctx, item.sql); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
hasMessageID := 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 == "message_id" {
|
||||
hasMessageID = true
|
||||
}
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasMessageID {
|
||||
if _, err := a.db.ExecContext(ctx, `ALTER TABLE send_queue ADD COLUMN message_id TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `DELETE FROM send_queue
|
||||
WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY mailbox_id, source, message_id
|
||||
ORDER BY
|
||||
CASE status
|
||||
WHEN 'queued' THEN 0
|
||||
WHEN 'sending' THEN 1
|
||||
WHEN 'failed' THEN 2
|
||||
WHEN 'delivered' THEN 3
|
||||
ELSE 4
|
||||
END,
|
||||
created_at DESC,
|
||||
id DESC
|
||||
) AS row_num
|
||||
FROM send_queue
|
||||
WHERE message_id <> ''
|
||||
)
|
||||
WHERE row_num > 1
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_send_queue_mailbox_source_message_id ON send_queue(mailbox_id, source, message_id) WHERE message_id <> ''`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) migratePermissionGroupLimits(ctx context.Context) error {
|
||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(permission_groups)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hasLimits := 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 == "limits_json" {
|
||||
hasLimits = true
|
||||
}
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
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}'`)
|
||||
return err
|
||||
}
|
||||
|
||||
// migrateLegacyBootstrapMailbox removes mailboxes created by an older version of seed()
|
||||
// that implicitly created an admin mailbox with display_name "LanQin Admin".
|
||||
// Current seed() creates mailboxes with display_name = admin email, so this migration
|
||||
@@ -392,7 +769,7 @@ func (a *App) migrateLegacyBootstrapMailbox(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
for _, messageID := range messageIDs {
|
||||
a.deleteMessageFiles(ctx, messageID)
|
||||
a.deleteMessage(ctx, messageID)
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `DELETE FROM mailboxes WHERE id=?`, item.id); err != nil {
|
||||
return err
|
||||
@@ -679,7 +1056,7 @@ func (a *App) seed(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
return a.ensureConfiguredAdminSuperAdmin(ctx)
|
||||
}
|
||||
|
||||
adminPassword := a.cfg.AdminPassword
|
||||
@@ -736,6 +1113,16 @@ func (a *App) seed(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) ensureConfiguredAdminSuperAdmin(ctx context.Context) error {
|
||||
adminEmail := normalizeEmail(a.cfg.AdminEmail)
|
||||
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
|
||||
return nil
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx, `UPDATE users SET role='admin', disabled=0, updated_at=? WHERE email=?`,
|
||||
a.now().UTC().Format(time.RFC3339Nano), adminEmail)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) createDomainTx(ctx context.Context, tx *sql.Tx, name string) (string, error) {
|
||||
name = normalizeDomain(name)
|
||||
if name == "" || !strings.Contains(name, ".") {
|
||||
@@ -829,7 +1216,7 @@ func (a *App) createMailboxWithPasswordHash(ctx context.Context, userID, domainI
|
||||
return "", err
|
||||
}
|
||||
for _, f := range defaultFolderDefs() {
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,created_at) VALUES(?,?,?,?,?)`, newID("fld"), id, f.name, f.role, 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
|
||||
}
|
||||
|
||||
+3395
-49
File diff suppressed because it is too large
Load Diff
@@ -8,67 +8,93 @@ 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
|
||||
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
|
||||
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
|
||||
ExternalIMAPEnabled bool
|
||||
ExternalIMAPSecretKey string
|
||||
ExternalIMAPSyncSeconds int
|
||||
ExternalIMAPAllowPrivateHosts bool
|
||||
ExternalIMAPGmailClientID string
|
||||
ExternalIMAPGmailClientSecret string
|
||||
ExternalIMAPOutlookClientID string
|
||||
ExternalIMAPOutlookClientSecret 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),
|
||||
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"),
|
||||
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"),
|
||||
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", ""),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,274 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
type imapMetadata struct {
|
||||
UID int64
|
||||
ModSeq int64
|
||||
}
|
||||
|
||||
func (a *App) migrateIMAPMetadata(ctx context.Context) error {
|
||||
if err := a.ensureTableColumn(ctx, "folders", "uid_validity", `ALTER TABLE folders ADD COLUMN uid_validity INTEGER NOT NULL DEFAULT 0`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.ensureTableColumn(ctx, "folders", "uid_next", `ALTER TABLE folders ADD COLUMN uid_next INTEGER NOT NULL DEFAULT 1`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.ensureTableColumn(ctx, "folders", "highest_modseq", `ALTER TABLE folders ADD COLUMN highest_modseq INTEGER NOT NULL DEFAULT 1`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.ensureTableColumn(ctx, "messages", "imap_uid", `ALTER TABLE messages ADD COLUMN imap_uid INTEGER NOT NULL DEFAULT 0`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.ensureTableColumn(ctx, "messages", "imap_modseq", `ALTER TABLE messages ADD COLUMN imap_modseq INTEGER NOT NULL DEFAULT 1`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE folders SET uid_validity=? WHERE uid_validity=0`, a.newUIDValidity()); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE folders SET uid_next=1 WHERE uid_next<1`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE folders SET highest_modseq=1 WHERE highest_modseq<1`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.backfillIMAPUIDs(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_folder_imap_uid ON messages(folder_id, imap_uid) WHERE folder_id IS NOT NULL AND imap_uid > 0`)
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
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 == column {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, alterSQL)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) backfillIMAPUIDs(ctx context.Context) error {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id FROM folders ORDER BY created_at,id`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var folderIDs []string
|
||||
for rows.Next() {
|
||||
var folderID string
|
||||
if err := rows.Scan(&folderID); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
folderIDs = append(folderIDs, folderID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, folderID := range folderIDs {
|
||||
if err := a.backfillFolderIMAPUIDs(ctx, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) backfillFolderIMAPUIDs(ctx context.Context, folderID string) error {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE folder_id=? AND imap_uid=0 ORDER BY created_at,id`, folderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var messageIDs []string
|
||||
for rows.Next() {
|
||||
var messageID string
|
||||
if err := rows.Scan(&messageID); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
messageIDs = append(messageIDs, messageID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, messageID := range messageIDs {
|
||||
meta, err := a.nextIMAPMetadata(ctx, a.db, folderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET imap_uid=?,imap_modseq=? WHERE id=?`, meta.UID, meta.ModSeq, messageID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var maxUID, maxModSeq int64
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(imap_uid),0),COALESCE(MAX(imap_modseq),1) FROM messages WHERE folder_id=?`, folderID).Scan(&maxUID, &maxModSeq); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE folders SET uid_next=MAX(uid_next,?),highest_modseq=MAX(highest_modseq,?) WHERE id=?`, maxUID+1, maxModSeq, folderID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) newUIDValidity() int64 {
|
||||
value := a.now().UTC().Unix()
|
||||
if value <= 0 {
|
||||
return time.Now().UTC().Unix()
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func (a *App) nextIMAPMetadata(ctx context.Context, db dbExecutor, folderID string) (imapMetadata, error) {
|
||||
if folderID == "" {
|
||||
return imapMetadata{}, nil
|
||||
}
|
||||
rowDB, ok := db.(dbQueryer)
|
||||
if !ok {
|
||||
return imapMetadata{}, nil
|
||||
}
|
||||
var nextUID, highestModSeq int64
|
||||
err := rowDB.QueryRowContext(ctx, `SELECT uid_next,highest_modseq FROM folders WHERE id=?`, folderID).Scan(&nextUID, &highestModSeq)
|
||||
if err != nil {
|
||||
return imapMetadata{}, err
|
||||
}
|
||||
if nextUID < 1 {
|
||||
nextUID = 1
|
||||
}
|
||||
nextModSeq := highestModSeq + 1
|
||||
if nextModSeq < 1 {
|
||||
nextModSeq = 1
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `UPDATE folders SET uid_next=?,highest_modseq=MAX(highest_modseq,?) WHERE id=?`, nextUID+1, nextModSeq, folderID); err != nil {
|
||||
return imapMetadata{}, err
|
||||
}
|
||||
return imapMetadata{UID: nextUID, ModSeq: nextModSeq}, nil
|
||||
}
|
||||
|
||||
func (a *App) bumpFolderModSeq(ctx context.Context, folderID string) (int64, error) {
|
||||
return a.bumpFolderModSeqWithDB(ctx, a.db, folderID)
|
||||
}
|
||||
|
||||
func (a *App) bumpFolderModSeqWithDB(ctx context.Context, db dbExecutor, folderID string) (int64, error) {
|
||||
if folderID == "" {
|
||||
return 0, nil
|
||||
}
|
||||
rowDB, ok := db.(dbQueryer)
|
||||
if !ok {
|
||||
return 0, nil
|
||||
}
|
||||
var current int64
|
||||
if err := rowDB.QueryRowContext(ctx, `SELECT highest_modseq FROM folders WHERE id=?`, folderID).Scan(¤t); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
next := current + 1
|
||||
if next < 1 || next == math.MaxInt64 {
|
||||
next = current
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `UPDATE folders SET highest_modseq=MAX(highest_modseq,?) WHERE id=?`, next, folderID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
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
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, messageID).Scan(&dbFolderID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !dbFolderID.Valid || dbFolderID.String == "" {
|
||||
return 0, nil
|
||||
}
|
||||
folderID = dbFolderID.String
|
||||
}
|
||||
modSeq, err := a.bumpFolderModSeq(ctx, folderID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if modSeq == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET imap_modseq=? WHERE id=?`, modSeq, messageID)
|
||||
return modSeq, err
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxMaildirRecentErrors = 10
|
||||
|
||||
type maildirSyncCounts struct {
|
||||
FilesScanned int `json:"filesScanned"`
|
||||
Imported int `json:"imported"`
|
||||
Backfilled int `json:"backfilled"`
|
||||
Cleaned int `json:"cleaned"`
|
||||
FileErrors int `json:"fileErrors"`
|
||||
fileErrorDetails []string `json:"-"`
|
||||
}
|
||||
|
||||
func (c maildirSyncCounts) total() int {
|
||||
return c.Imported + c.Backfilled + c.Cleaned
|
||||
}
|
||||
|
||||
type maildirSyncRun struct {
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Counts maildirSyncCounts `json:"counts"`
|
||||
}
|
||||
|
||||
type maildirSyncHealthResponse struct {
|
||||
Configured bool `json:"configured"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Root string `json:"root"`
|
||||
ScanSeconds int `json:"scanSeconds"`
|
||||
WorkerStarted bool `json:"workerStarted"`
|
||||
Running bool `json:"running"`
|
||||
LastRun *maildirSyncRun `json:"lastRun,omitempty"`
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
NextRunAt *time.Time `json:"nextRunAt,omitempty"`
|
||||
RecentErrors []string `json:"recentErrors"`
|
||||
Summary maildirSyncCounts `json:"summary"`
|
||||
}
|
||||
|
||||
type maildirSyncHealthTracker struct {
|
||||
mu sync.Mutex
|
||||
workerStarted bool
|
||||
running bool
|
||||
current *maildirSyncRun
|
||||
lastRun *maildirSyncRun
|
||||
lastError string
|
||||
nextRunAt *time.Time
|
||||
recentErrors []string
|
||||
summary maildirSyncCounts
|
||||
}
|
||||
|
||||
func newMaildirSyncHealthTracker() *maildirSyncHealthTracker {
|
||||
return &maildirSyncHealthTracker{}
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) markWorkerStarted(nextRunAt *time.Time) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.workerStarted = true
|
||||
h.nextRunAt = cloneTimePtr(nextRunAt)
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) markWorkerStopped() {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.workerStarted = false
|
||||
h.nextRunAt = nil
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) markRunStarted(startedAt time.Time) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
run := &maildirSyncRun{StartedAt: startedAt.UTC(), Status: "running"}
|
||||
h.running = true
|
||||
h.current = run
|
||||
h.lastRun = cloneMaildirSyncRun(run)
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) markRunFinished(finishedAt time.Time, counts maildirSyncCounts, err error, nextRunAt *time.Time) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
run := h.current
|
||||
if run == nil {
|
||||
run = &maildirSyncRun{StartedAt: finishedAt.UTC()}
|
||||
}
|
||||
finished := finishedAt.UTC()
|
||||
run.FinishedAt = &finished
|
||||
run.DurationMs = finished.Sub(run.StartedAt).Milliseconds()
|
||||
run.Counts = counts
|
||||
run.Status = "success"
|
||||
run.Error = ""
|
||||
if err != nil {
|
||||
run.Status = "error"
|
||||
run.Error = err.Error()
|
||||
h.lastError = run.Error
|
||||
h.pushRecentError(run.Error)
|
||||
} else if counts.FileErrors > 0 {
|
||||
run.Status = "partial"
|
||||
if len(counts.fileErrorDetails) > 0 {
|
||||
run.Error = counts.fileErrorDetails[0]
|
||||
h.lastError = run.Error
|
||||
}
|
||||
for _, detail := range counts.fileErrorDetails {
|
||||
h.pushRecentError(detail)
|
||||
}
|
||||
} else {
|
||||
h.lastError = ""
|
||||
}
|
||||
h.summary.FilesScanned += counts.FilesScanned
|
||||
h.summary.Imported += counts.Imported
|
||||
h.summary.Backfilled += counts.Backfilled
|
||||
h.summary.Cleaned += counts.Cleaned
|
||||
h.summary.FileErrors += counts.FileErrors
|
||||
h.running = false
|
||||
h.current = nil
|
||||
h.lastRun = cloneMaildirSyncRun(run)
|
||||
h.nextRunAt = cloneTimePtr(nextRunAt)
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) snapshot(cfg Config) maildirSyncHealthResponse {
|
||||
root := strings.TrimSpace(cfg.MaildirRoot)
|
||||
scanSeconds := cfg.MaildirScanSeconds
|
||||
if scanSeconds <= 0 {
|
||||
scanSeconds = 30
|
||||
}
|
||||
out := maildirSyncHealthResponse{
|
||||
Configured: root != "",
|
||||
Enabled: root != "",
|
||||
Root: root,
|
||||
ScanSeconds: scanSeconds,
|
||||
}
|
||||
if h == nil {
|
||||
return out
|
||||
}
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
out.WorkerStarted = h.workerStarted
|
||||
out.Running = h.running
|
||||
out.LastRun = cloneMaildirSyncRun(h.lastRun)
|
||||
out.LastError = h.lastError
|
||||
out.NextRunAt = cloneTimePtr(h.nextRunAt)
|
||||
out.RecentErrors = append([]string(nil), h.recentErrors...)
|
||||
out.Summary = h.summary
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *maildirSyncHealthTracker) pushRecentError(value string) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
h.recentErrors = append([]string{value}, h.recentErrors...)
|
||||
if len(h.recentErrors) > maxMaildirRecentErrors {
|
||||
h.recentErrors = h.recentErrors[:maxMaildirRecentErrors]
|
||||
}
|
||||
}
|
||||
|
||||
func cloneMaildirSyncRun(in *maildirSyncRun) *maildirSyncRun {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := *in
|
||||
out.FinishedAt = cloneTimePtr(in.FinishedAt)
|
||||
return &out
|
||||
}
|
||||
|
||||
func cloneTimePtr(in *time.Time) *time.Time {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := in.UTC()
|
||||
return &out
|
||||
}
|
||||
|
||||
func (a *App) handleMaildirSyncHealth(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, a.maildirHealth.snapshot(a.cfg))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -49,54 +49,79 @@ func (a *App) maildirWorker(ctx context.Context) {
|
||||
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())
|
||||
if n, err := a.syncMaildirOnce(ctx); err != nil {
|
||||
if counts, err := a.syncMaildirOnceTracked(ctx, interval); err != nil {
|
||||
a.log.Warn("initial maildir sync failed", "error", err)
|
||||
} else if n > 0 {
|
||||
a.log.Info("initial maildir sync imported messages", "count", n)
|
||||
} else if n := counts.total(); n > 0 {
|
||||
a.log.Info("initial maildir sync processed messages", "count", n)
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
a.maildirHealth.markWorkerStopped()
|
||||
a.log.Info("maildir sync worker stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
n, err := a.syncMaildirOnce(ctx)
|
||||
counts, err := a.syncMaildirOnceTracked(ctx, interval)
|
||||
if err != nil {
|
||||
a.log.Warn("maildir sync failed", "error", err)
|
||||
continue
|
||||
}
|
||||
if n > 0 {
|
||||
a.log.Info("maildir sync imported messages", "count", n)
|
||||
if n := counts.total(); n > 0 {
|
||||
a.log.Info("maildir sync processed messages", "count", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) syncMaildirOnceTracked(ctx context.Context, interval time.Duration) (maildirSyncCounts, error) {
|
||||
startedAt := a.now().UTC()
|
||||
a.maildirHealth.markRunStarted(startedAt)
|
||||
counts, err := a.syncMaildirOnceDetailed(ctx)
|
||||
finishedAt := a.now().UTC()
|
||||
var nextRunAt *time.Time
|
||||
if interval > 0 && err == nil {
|
||||
next := finishedAt.Add(interval)
|
||||
nextRunAt = &next
|
||||
}
|
||||
a.maildirHealth.markRunFinished(finishedAt, counts, err, nextRunAt)
|
||||
return counts, err
|
||||
}
|
||||
|
||||
func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
|
||||
counts, err := a.syncMaildirOnceDetailed(ctx)
|
||||
return counts.total(), err
|
||||
}
|
||||
|
||||
func (a *App) syncMaildirOnceDetailed(ctx context.Context) (maildirSyncCounts, error) {
|
||||
root := strings.TrimSpace(a.cfg.MaildirRoot)
|
||||
if root == "" {
|
||||
return 0, nil
|
||||
return maildirSyncCounts{}, nil
|
||||
}
|
||||
mailboxes, err := a.maildirMailboxes(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return maildirSyncCounts{}, err
|
||||
}
|
||||
imported := 0
|
||||
counts := maildirSyncCounts{}
|
||||
for _, mb := range mailboxes {
|
||||
if mb.Unregistered {
|
||||
count, err := a.syncUnregisteredMaildir(ctx, mb)
|
||||
mbCounts, err := a.syncUnregisteredMaildirDetailed(ctx, mb)
|
||||
counts.FilesScanned += mbCounts.FilesScanned
|
||||
counts.Imported += mbCounts.Imported
|
||||
counts.FileErrors += mbCounts.FileErrors
|
||||
counts.fileErrorDetails = append(counts.fileErrorDetails, mbCounts.fileErrorDetails...)
|
||||
if err != nil {
|
||||
return imported, err
|
||||
return counts, err
|
||||
}
|
||||
imported += count
|
||||
continue
|
||||
}
|
||||
folders, err := a.maildirFolders(ctx, mb.ID)
|
||||
if err != nil {
|
||||
return imported, err
|
||||
return counts, err
|
||||
}
|
||||
base := filepath.Join(root, mb.Domain, mb.LocalPart, "Maildir")
|
||||
for _, folder := range folders {
|
||||
@@ -104,7 +129,7 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
|
||||
for _, sub := range []string{"new", "cur"} {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return imported, ctx.Err()
|
||||
return counts, ctx.Err()
|
||||
default:
|
||||
}
|
||||
dir := filepath.Join(folderBase, sub)
|
||||
@@ -113,26 +138,39 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return imported, err
|
||||
return counts, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
counts.FilesScanned++
|
||||
ok, err := a.syncMaildirFile(ctx, mb, folder, path)
|
||||
if err != nil {
|
||||
counts.FileErrors++
|
||||
counts.fileErrorDetails = append(counts.fileErrorDetails, fmt.Sprintf("%s: %v", path, err))
|
||||
a.log.Warn("maildir file import failed", "path", path, "error", err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
imported++
|
||||
counts.Imported++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return imported, nil
|
||||
backfilled, err := a.backfillSQLiteMessagesToMaildir(ctx)
|
||||
if err != nil {
|
||||
return counts, err
|
||||
}
|
||||
counts.Backfilled += backfilled
|
||||
cleaned, err := a.cleanupMissingMaildirMessages(ctx)
|
||||
if err != nil {
|
||||
return counts, err
|
||||
}
|
||||
counts.Cleaned += cleaned
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
|
||||
@@ -179,12 +217,17 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
|
||||
}
|
||||
|
||||
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")
|
||||
imported := 0
|
||||
counts := maildirSyncCounts{}
|
||||
for _, sub := range []string{"new", "cur"} {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return imported, ctx.Err()
|
||||
return counts, ctx.Err()
|
||||
default:
|
||||
}
|
||||
dir := filepath.Join(base, sub)
|
||||
@@ -193,24 +236,27 @@ func (a *App) syncUnregisteredMaildir(ctx context.Context, mb maildirMailbox) (i
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return imported, err
|
||||
return counts, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
counts.FilesScanned++
|
||||
ok, err := a.syncUnregisteredMaildirFile(ctx, mb, path)
|
||||
if err != nil {
|
||||
counts.FileErrors++
|
||||
counts.fileErrorDetails = append(counts.fileErrorDetails, fmt.Sprintf("%s: %v", path, err))
|
||||
a.log.Warn("unregistered maildir file import failed", "path", path, "error", err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
imported++
|
||||
counts.Imported++
|
||||
}
|
||||
}
|
||||
}
|
||||
return imported, nil
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox, path string) (bool, error) {
|
||||
@@ -248,6 +294,7 @@ func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox
|
||||
if exists, err := a.unregisteredMaildirMessageExists(ctx, path, msg.MessageID, msg.RecipientAddr); err != nil {
|
||||
return false, err
|
||||
} else if exists {
|
||||
a.attachUnregisteredMaildirRawPathToExisting(ctx, path, msg.MessageID, msg.RecipientAddr)
|
||||
return false, nil
|
||||
}
|
||||
_, err = a.insertMessage(ctx, msg, attachments)
|
||||
@@ -291,7 +338,7 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
|
||||
}
|
||||
msg.MailboxID = mb.ID
|
||||
msg.FolderID = folder.ID
|
||||
msg.IsRead = !strings.EqualFold(folder.Name, "Inbox")
|
||||
msg.IsRead, msg.IsStarred = maildirFlagsFromPath(path, folder.Name)
|
||||
msg.RawPath = path
|
||||
if msg.MessageUID == "" {
|
||||
msg.MessageUID = newID("uid")
|
||||
@@ -311,6 +358,14 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
|
||||
if exists, err := a.maildirMessageExists(ctx, mb.ID, folder.ID, path, msg.MessageID); err != nil {
|
||||
return false, err
|
||||
} else if exists {
|
||||
if _, err := a.syncExistingMaildirMessageState(ctx, mb.ID, folder.ID, path, msg.MessageID, msg.IsRead, msg.IsStarred); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
if handled, err := a.syncExistingMaildirMessageState(ctx, mb.ID, folder.ID, path, msg.MessageID, msg.IsRead, msg.IsStarred); err != nil {
|
||||
return false, err
|
||||
} else if handled {
|
||||
return false, nil
|
||||
}
|
||||
id, err := a.insertMessage(ctx, msg, attachments)
|
||||
@@ -338,6 +393,210 @@ 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
|
||||
var oldRead, oldStarred int
|
||||
var oldModSeq int64
|
||||
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(folder_id,''),is_read,is_starred,imap_modseq FROM messages WHERE mailbox_id=? AND raw_path=?`, mailboxID, rawPath).Scan(&samePathID, &oldFolderID, &oldRead, &oldStarred, &oldModSeq)
|
||||
if err == nil {
|
||||
if oldFolderID != folderID {
|
||||
if oldFolderID != "" {
|
||||
if _, err := a.bumpFolderModSeq(ctx, oldFolderID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
meta, err := a.nextIMAPMetadata(ctx, a.db, folderID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,is_read=?,is_starred=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`,
|
||||
folderID, rawPath, boolInt(read), boolInt(starred), meta.UID, meta.ModSeq, now, samePathID)
|
||||
return err == nil, err
|
||||
}
|
||||
modSeq := oldModSeq
|
||||
if oldRead != boolInt(read) || oldStarred != boolInt(starred) {
|
||||
modSeq, err = a.bumpFolderModSeq(ctx, folderID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,is_read=?,is_starred=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`,
|
||||
rawPath, boolInt(read), boolInt(starred), modSeq, modSeq, now, samePathID)
|
||||
return err == nil, err
|
||||
}
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return false, err
|
||||
}
|
||||
if strings.TrimSpace(messageID) == "" {
|
||||
return false, nil
|
||||
}
|
||||
type candidate struct {
|
||||
ID string
|
||||
RawPath string
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id,raw_path FROM messages WHERE mailbox_id=? AND message_id=? AND message_id <> '' ORDER BY CASE WHEN folder_id=? THEN 0 ELSE 1 END, created_at`, mailboxID, messageID, folderID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var chosen candidate
|
||||
for rows.Next() {
|
||||
var c candidate
|
||||
if err := rows.Scan(&c.ID, &c.RawPath); err != nil {
|
||||
rows.Close()
|
||||
return false, err
|
||||
}
|
||||
if c.RawPath == "" || c.RawPath == rawPath {
|
||||
chosen = c
|
||||
break
|
||||
}
|
||||
ok, err := a.pathIsUnderMaildirRoot(c.RawPath)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return false, err
|
||||
}
|
||||
if ok {
|
||||
if _, err := os.Stat(c.RawPath); errors.Is(err, os.ErrNotExist) {
|
||||
chosen = c
|
||||
break
|
||||
} else if err != nil {
|
||||
rows.Close()
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return false, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if chosen.ID == "" {
|
||||
a.removeDuplicateMaildirMessage(ctx, rawPath, mailboxID, folderID, messageID)
|
||||
return false, nil
|
||||
}
|
||||
var previousFolderID string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COALESCE(folder_id,'') FROM messages WHERE id=?`, chosen.ID).Scan(&previousFolderID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if previousFolderID != "" && previousFolderID != folderID {
|
||||
if _, err := a.bumpFolderModSeq(ctx, previousFolderID); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
meta, err := a.nextIMAPMetadata(ctx, a.db, folderID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,is_read=?,is_starred=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, folderID, rawPath, boolInt(read), boolInt(starred), meta.UID, meta.ModSeq, now, chosen.ID)
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
func (a *App) removeDuplicateMaildirMessage(ctx context.Context, rawPath, mailboxID, folderID, messageID string) {
|
||||
var existing string
|
||||
err := a.db.QueryRowContext(ctx, `SELECT raw_path FROM messages WHERE mailbox_id=? AND folder_id=? AND message_id=? AND message_id <> '' AND raw_path<>'' LIMIT 1`, mailboxID, folderID, messageID).Scan(&existing)
|
||||
if err != nil || existing == "" || existing == rawPath {
|
||||
return
|
||||
}
|
||||
a.removeMaildirPath(ctx, rawPath)
|
||||
}
|
||||
|
||||
func (a *App) cleanupMissingMaildirMessages(ctx context.Context) (int, error) {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
return 0, nil
|
||||
}
|
||||
cutoff := a.now().UTC().Add(-5 * time.Minute).Format(time.RFC3339Nano)
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id,raw_path FROM messages WHERE COALESCE(mailbox_id,'')<>'' AND raw_path<>'' AND updated_at<?`, cutoff)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
type item struct {
|
||||
ID string
|
||||
RawPath string
|
||||
}
|
||||
var missing []item
|
||||
for rows.Next() {
|
||||
var it item
|
||||
if err := rows.Scan(&it.ID, &it.RawPath); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
ok, err := a.pathIsUnderMaildirRoot(it.RawPath)
|
||||
if err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(it.RawPath); errors.Is(err, os.ErrNotExist) {
|
||||
missing = append(missing, it)
|
||||
} else if err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, it := range missing {
|
||||
a.deleteMessageFiles(ctx, it.ID)
|
||||
var folderID sql.NullString
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, it.ID).Scan(&folderID)
|
||||
if _, err := a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, it.ID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if folderID.Valid && folderID.String != "" {
|
||||
if _, err := a.bumpFolderModSeq(ctx, folderID.String); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return len(missing), nil
|
||||
}
|
||||
|
||||
func maildirFlagsFromPath(path, folderName string) (bool, bool) {
|
||||
base := filepath.Base(path)
|
||||
flags := ""
|
||||
hasFlags := false
|
||||
for _, sep := range []string{maildirFlagSeparator(), ":2,", "!2,"} {
|
||||
if idx := strings.LastIndex(base, sep); idx >= 0 {
|
||||
flags = base[idx+len(sep):]
|
||||
hasFlags = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasFlags {
|
||||
return strings.ContainsRune(flags, 'S'), strings.ContainsRune(flags, 'F')
|
||||
}
|
||||
return !strings.EqualFold(folderName, "Inbox"), false
|
||||
}
|
||||
|
||||
func (a *App) attachUnregisteredMaildirRawPathToExisting(ctx context.Context, rawPath, messageID, recipient 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 IS NULL AND recipient_addr=? AND message_id=? AND message_id <> '' AND raw_path=''`,
|
||||
rawPath, a.now().UTC().Format(time.RFC3339Nano), recipient, messageID); err != nil {
|
||||
a.log.Warn("failed to attach unregistered maildir raw path to existing message", "path", rawPath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func unregisteredRecipientFromMessage(msg storedMessage, domain string) string {
|
||||
domain = normalizeDomain(domain)
|
||||
for _, address := range append(append([]string{}, msg.To...), msg.CC...) {
|
||||
@@ -382,19 +641,20 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
|
||||
receivedAt = sentAt
|
||||
}
|
||||
return storedMessage{
|
||||
MessageUID: newID("uid"),
|
||||
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
|
||||
Subject: subject,
|
||||
From: from,
|
||||
FromName: fromName,
|
||||
To: to,
|
||||
CC: cc,
|
||||
SentAt: sentAt,
|
||||
ReceivedAt: receivedAt,
|
||||
Snippet: snippetFrom(bodyText, bodyHTML),
|
||||
BodyText: bodyText,
|
||||
BodyHTML: bodyHTML,
|
||||
IsRead: false,
|
||||
MessageUID: newID("uid"),
|
||||
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
|
||||
Subject: subject,
|
||||
From: from,
|
||||
FromName: fromName,
|
||||
To: to,
|
||||
CC: cc,
|
||||
SentAt: sentAt,
|
||||
ReceivedAt: receivedAt,
|
||||
Snippet: snippetFrom(bodyText, bodyHTML),
|
||||
BodyText: bodyText,
|
||||
BodyHTML: bodyHTML,
|
||||
IsRead: false,
|
||||
Authentication: parseMailAuthentication(textproto.MIMEHeader(m.Header)),
|
||||
}, parsed.Attachments, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
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) == "" {
|
||||
return nil
|
||||
}
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.writeRawMessageToMaildir(ctx, messageID, raw, false)
|
||||
}
|
||||
|
||||
func (a *App) rewriteMessageMaildir(ctx context.Context, messageID string) error {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
return nil
|
||||
}
|
||||
msg, err := a.storedMessageByID(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attachments, err := a.attachmentInputsForMessage(ctx, messageID)
|
||||
if err != nil {
|
||||
return 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,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.writeRawMessageToMaildir(ctx, messageID, raw, true)
|
||||
}
|
||||
|
||||
func (a *App) writeRawMessageToMaildir(ctx context.Context, messageID string, raw []byte, replace bool) error {
|
||||
state, err := a.maildirMessageState(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.writeRawMessageToMaildirFolder(ctx, messageID, state.FolderID, raw, replace, false)
|
||||
}
|
||||
|
||||
func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, folderID string, raw []byte, replace bool, updateFolder bool) error {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
return nil
|
||||
}
|
||||
state, err := a.maildirMessageState(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if folderID != "" {
|
||||
oldFolderID := state.FolderID
|
||||
state.FolderID = folderID
|
||||
if updateFolder && oldFolderID != "" && oldFolderID != state.FolderID {
|
||||
if _, err := a.bumpFolderModSeq(ctx, oldFolderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if state.MailboxID == "" || state.FolderID == "" {
|
||||
return nil
|
||||
}
|
||||
if !replace && state.RawPath != "" {
|
||||
if ok, err := a.pathIsUnderMaildirRoot(state.RawPath); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
if _, err := os.Stat(state.RawPath); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
mb, err := a.maildirMailboxByID(ctx, state.MailboxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
folderName, err := a.folderNameByID(ctx, state.FolderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
||||
folderBase := maildirFolderPath(base, folderName)
|
||||
subdir := "cur"
|
||||
if strings.EqualFold(folderName, "Inbox") && !state.IsRead {
|
||||
subdir = "new"
|
||||
}
|
||||
if err := ensureMaildirFolderDirs(base, folderBase); err != nil {
|
||||
return err
|
||||
}
|
||||
filename := maildirFilename(messageID, state.MessageID)
|
||||
tmpPath := filepath.Join(folderBase, "tmp", filename)
|
||||
finalPath := filepath.Join(folderBase, subdir, filename)
|
||||
finalPath = maildirPathWithFlags(finalPath, state.IsRead, state.IsStarred)
|
||||
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
|
||||
}
|
||||
if replace || state.RawPath != "" {
|
||||
a.removeMaildirPath(ctx, state.RawPath)
|
||||
}
|
||||
if updateFolder {
|
||||
if state.IMAPUID > 0 && folderID == "" {
|
||||
modSeq, metaErr := a.bumpFolderModSeq(ctx, state.FolderID)
|
||||
if metaErr != nil {
|
||||
return metaErr
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`, state.FolderID, finalPath, modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
} else {
|
||||
meta, metaErr := a.nextIMAPMetadata(ctx, a.db, state.FolderID)
|
||||
if metaErr != nil {
|
||||
return metaErr
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, state.FolderID, finalPath, meta.UID, meta.ModSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
}
|
||||
} else {
|
||||
modSeq, metaErr := a.bumpFolderModSeq(ctx, state.FolderID)
|
||||
if metaErr != nil {
|
||||
return metaErr
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`, finalPath, modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID string) error {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
state, stateErr := a.maildirMessageState(ctx, messageID)
|
||||
if stateErr != nil {
|
||||
return stateErr
|
||||
}
|
||||
if state.FolderID != "" && state.FolderID != targetFolderID {
|
||||
if _, err := a.bumpFolderModSeq(ctx, state.FolderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
meta, metaErr := a.nextIMAPMetadata(ctx, a.db, targetFolderID)
|
||||
if metaErr != nil {
|
||||
return metaErr
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, targetFolderID, meta.UID, meta.ModSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
return err
|
||||
}
|
||||
state, err := a.maildirMessageState(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state.MailboxID == "" {
|
||||
return nil
|
||||
}
|
||||
state.FolderID = targetFolderID
|
||||
if state.RawPath == "" {
|
||||
return a.writeMessageToNewMaildirFolder(ctx, messageID, targetFolderID)
|
||||
}
|
||||
ok, err := a.pathIsUnderMaildirRoot(state.RawPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return a.writeMessageToNewMaildirFolder(ctx, messageID, targetFolderID)
|
||||
}
|
||||
if _, err := os.Stat(state.RawPath); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return a.writeMessageToNewMaildirFolder(ctx, messageID, targetFolderID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
mb, err := a.maildirMailboxByID(ctx, state.MailboxID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
folderName, err := a.folderNameByID(ctx, targetFolderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
||||
folderBase := maildirFolderPath(base, folderName)
|
||||
if err := ensureMaildirFolderDirs(base, folderBase); err != nil {
|
||||
return err
|
||||
}
|
||||
subdir := "cur"
|
||||
if strings.EqualFold(folderName, "Inbox") && !state.IsRead {
|
||||
subdir = "new"
|
||||
}
|
||||
targetPath := filepath.Join(folderBase, subdir, filepath.Base(state.RawPath))
|
||||
if filepath.Clean(targetPath) != filepath.Clean(state.RawPath) {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
}
|
||||
meta, err := a.nextIMAPMetadata(ctx, a.db, targetFolderID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, targetFolderID, targetPath, meta.UID, meta.ModSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) writeMessageToNewMaildirFolder(ctx context.Context, messageID, folderID string) error {
|
||||
msg, err := a.storedMessageByID(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg.FolderID = folderID
|
||||
attachments, err := a.attachmentInputsForMessage(ctx, messageID)
|
||||
if err != nil {
|
||||
return 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,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.writeRawMessageToMaildirFolder(ctx, messageID, folderID, raw, true, true)
|
||||
}
|
||||
|
||||
func (a *App) deleteMessageMaildirFile(ctx context.Context, messageID string) {
|
||||
var rawPath string
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT raw_path FROM messages WHERE id=?`, messageID).Scan(&rawPath); err != nil {
|
||||
return
|
||||
}
|
||||
a.removeMaildirPath(ctx, rawPath)
|
||||
}
|
||||
|
||||
func (a *App) updateMessageMaildirFlags(ctx context.Context, messageID string, read, starred *bool) error {
|
||||
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
||||
return nil
|
||||
}
|
||||
state, err := a.maildirMessageState(ctx, messageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state.RawPath == "" {
|
||||
return nil
|
||||
}
|
||||
ok, err := a.pathIsUnderMaildirRoot(state.RawPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(state.RawPath); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
currentRead := state.IsRead
|
||||
currentStarred := state.IsStarred
|
||||
if read != nil {
|
||||
currentRead = *read
|
||||
}
|
||||
if starred != nil {
|
||||
currentStarred = *starred
|
||||
}
|
||||
targetPath := maildirPathWithFlags(state.RawPath, currentRead, currentStarred)
|
||||
if filepath.Clean(targetPath) == filepath.Clean(state.RawPath) {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
_, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`, targetPath, modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) removeMaildirPath(ctx context.Context, rawPath string) {
|
||||
rawPath = strings.TrimSpace(rawPath)
|
||||
if rawPath == "" {
|
||||
return
|
||||
}
|
||||
ok, err := a.pathIsUnderMaildirRoot(rawPath)
|
||||
if err != nil || !ok {
|
||||
if err != nil {
|
||||
a.log.Warn("failed to validate maildir path", "path", rawPath, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := os.Remove(rawPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
a.log.Warn("failed to remove maildir message", "path", rawPath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) backfillSQLiteMessagesToMaildir(ctx context.Context) (int, error) {
|
||||
if strings.TrimSpace(a.cfg.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`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
rows.Close()
|
||||
return 0, ctx.Err()
|
||||
default:
|
||||
}
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return 0, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
count := 0
|
||||
for _, id := range ids {
|
||||
if err := a.rewriteMessageMaildir(ctx, id); err != nil {
|
||||
return count, err
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
type maildirMessageState struct {
|
||||
MailboxID string
|
||||
FolderID string
|
||||
MessageID string
|
||||
RawPath string
|
||||
IsRead bool
|
||||
IsStarred bool
|
||||
IMAPUID int64
|
||||
IMAPModSeq int64
|
||||
}
|
||||
|
||||
func (a *App) maildirMessageState(ctx context.Context, id string) (maildirMessageState, error) {
|
||||
var state maildirMessageState
|
||||
var mailboxID, folderID sql.NullString
|
||||
var read, starred int
|
||||
err := a.db.QueryRowContext(ctx, `SELECT mailbox_id,folder_id,message_id,raw_path,is_read,is_starred,imap_uid,imap_modseq FROM messages WHERE id=?`, id).Scan(&mailboxID, &folderID, &state.MessageID, &state.RawPath, &read, &starred, &state.IMAPUID, &state.IMAPModSeq)
|
||||
if err != nil {
|
||||
return state, err
|
||||
}
|
||||
state.MailboxID = mailboxID.String
|
||||
state.FolderID = folderID.String
|
||||
state.IsRead = intBool(read)
|
||||
state.IsStarred = intBool(starred)
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (a *App) storedMessageByID(ctx context.Context, id string) (storedMessage, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT COALESCE(mailbox_id,''),COALESCE(folder_id,''),recipient_addr,message_uid,message_id,subject,from_addr,from_name,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,raw_path FROM messages WHERE id=?`, id)
|
||||
var msg storedMessage
|
||||
var toJSON, ccJSON, bccJSON, sent, received string
|
||||
var read, starred int
|
||||
err := row.Scan(&msg.MailboxID, &msg.FolderID, &msg.RecipientAddr, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &msg.BodyText, &msg.BodyHTML, &read, &starred, &msg.RawPath)
|
||||
if err != nil {
|
||||
return msg, err
|
||||
}
|
||||
msg.To = jsonDecodeSlice(toJSON)
|
||||
msg.CC = jsonDecodeSlice(ccJSON)
|
||||
msg.BCC = jsonDecodeSlice(bccJSON)
|
||||
msg.SentAt = parseTime(sent)
|
||||
msg.ReceivedAt = parseTime(received)
|
||||
msg.IsRead = intBool(read)
|
||||
msg.IsStarred = intBool(starred)
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (a *App) attachmentInputsForMessage(ctx context.Context, messageID string) ([]AttachmentInput, error) {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT filename,content_type,storage_path FROM attachments WHERE message_id=? ORDER BY filename`, messageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AttachmentInput
|
||||
for rows.Next() {
|
||||
var filename, contentType, storagePath string
|
||||
if err := rows.Scan(&filename, &contentType, &storagePath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(storagePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, AttachmentInput{Filename: filename, ContentType: contentType, ContentBase64: base64.StdEncoding.EncodeToString(data)})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) maildirMailboxByID(ctx context.Context, mailboxID string) (maildirMailbox, error) {
|
||||
var mb maildirMailbox
|
||||
err := a.db.QueryRowContext(ctx, `SELECT m.id,m.address,m.local_part,d.name FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE m.id=?`, mailboxID).Scan(&mb.ID, &mb.Address, &mb.LocalPart, &mb.Domain)
|
||||
return mb, err
|
||||
}
|
||||
|
||||
func (a *App) folderNameByID(ctx context.Context, folderID string) (string, error) {
|
||||
var name string
|
||||
err := a.db.QueryRowContext(ctx, `SELECT name FROM folders WHERE id=?`, folderID).Scan(&name)
|
||||
return name, err
|
||||
}
|
||||
|
||||
func (a *App) pathIsUnderMaildirRoot(path string) (bool, error) {
|
||||
root := strings.TrimSpace(a.cfg.MaildirRoot)
|
||||
if root == "" || strings.TrimSpace(path) == "" {
|
||||
return false, nil
|
||||
}
|
||||
rootAbs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
pathAbs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
rel, err := filepath.Rel(rootAbs, pathAbs)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return rel != "." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != "..", nil
|
||||
}
|
||||
|
||||
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 == "" {
|
||||
base = messageID
|
||||
}
|
||||
return fmt.Sprintf("%d.%s.%s", time.Now().UnixNano(), safeMaildirName(messageID), safeMaildirName(base))
|
||||
}
|
||||
|
||||
func safeMaildirName(value string) string {
|
||||
value = strings.Trim(value, "<>")
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == '.', r == '_', r == '-', r == '@':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
out := strings.Trim(b.String(), "._-")
|
||||
if out == "" {
|
||||
out = "message"
|
||||
}
|
||||
if len(out) > 120 {
|
||||
out = out[:120]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func messageDate(msg storedMessage) time.Time {
|
||||
if !msg.SentAt.IsZero() {
|
||||
return msg.SentAt
|
||||
}
|
||||
if !msg.ReceivedAt.IsZero() {
|
||||
return msg.ReceivedAt
|
||||
}
|
||||
return time.Now().UTC()
|
||||
}
|
||||
|
||||
func maildirPathWithFlags(path string, read, starred bool) string {
|
||||
dir := filepath.Dir(path)
|
||||
name := filepath.Base(path)
|
||||
if read || starred {
|
||||
dir = filepath.Join(filepath.Dir(dir), "cur")
|
||||
} else if filepath.Base(dir) == "cur" {
|
||||
dir = filepath.Join(filepath.Dir(dir), "new")
|
||||
}
|
||||
base := name
|
||||
sep := maildirFlagSeparator()
|
||||
existingFlags := ""
|
||||
if idx := strings.LastIndex(base, sep); idx >= 0 {
|
||||
existingFlags = base[idx+len(sep):]
|
||||
base = base[:idx]
|
||||
}
|
||||
flags := preserveMaildirFlags(existingFlags, "SF")
|
||||
if read {
|
||||
flags = appendMaildirFlag(flags, 'S')
|
||||
}
|
||||
if starred {
|
||||
flags = appendMaildirFlag(flags, 'F')
|
||||
}
|
||||
if flags != "" {
|
||||
base += sep + flags
|
||||
}
|
||||
return filepath.Join(dir, base)
|
||||
}
|
||||
|
||||
func preserveMaildirFlags(flags, managed string) string {
|
||||
var b strings.Builder
|
||||
for _, flag := range flags {
|
||||
if strings.ContainsRune(managed, flag) || strings.ContainsRune(b.String(), flag) {
|
||||
continue
|
||||
}
|
||||
b.WriteRune(flag)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func appendMaildirFlag(flags string, flag rune) string {
|
||||
if strings.ContainsRune(flags, flag) {
|
||||
return flags
|
||||
}
|
||||
return flags + string(flag)
|
||||
}
|
||||
|
||||
func maildirFlagSeparator() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return "!2,"
|
||||
}
|
||||
return ":2,"
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func (a *App) handlePermissionCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": permissionCatalog()})
|
||||
}
|
||||
|
||||
func (a *App) handleDefaultPermissionLimits(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, defaultPermissionLimits())
|
||||
}
|
||||
|
||||
func (a *App) handleListPermissionGroups(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,description,permissions_json,limits_json,system,created_at,updated_at
|
||||
FROM permission_groups
|
||||
ORDER BY created_at ASC,name ASC`)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list permission groups")
|
||||
return
|
||||
}
|
||||
|
||||
items := []PermissionGroup{}
|
||||
for rows.Next() {
|
||||
var item PermissionGroup
|
||||
var rawPermissions, rawLimits, created, updated string
|
||||
var system int
|
||||
if err := rows.Scan(&item.ID, &item.Name, &item.Description, &rawPermissions, &rawLimits, &system, &created, &updated); err != nil {
|
||||
rows.Close()
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan permission groups")
|
||||
return
|
||||
}
|
||||
item.Permissions = decodeStoredPermissions(rawPermissions)
|
||||
item.Limits = decodeStoredLimits(rawLimits)
|
||||
item.System = intBool(system)
|
||||
item.CreatedAt = parseTime(created)
|
||||
item.UpdatedAt = parseTime(updated)
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
respondError(w, http.StatusInternalServerError, "failed to list permission groups")
|
||||
return
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list permission groups")
|
||||
return
|
||||
}
|
||||
var adminCount, regularCount int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users WHERE role='admin'`).Scan(&adminCount); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to count users")
|
||||
return
|
||||
}
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users u
|
||||
WHERE u.role<>'admin'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_permission_groups upg
|
||||
WHERE upg.user_id=u.id AND upg.group_id NOT IN (?,?)
|
||||
)`, PermissionGroupSuperAdmin, PermissionGroupRegular,
|
||||
).Scan(®ularCount); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to count users")
|
||||
return
|
||||
}
|
||||
for i := range items {
|
||||
switch items[i].ID {
|
||||
case PermissionGroupSuperAdmin:
|
||||
items[i].UserCount = adminCount
|
||||
case PermissionGroupRegular:
|
||||
items[i].UserCount = regularCount
|
||||
default:
|
||||
var count int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_permission_groups WHERE group_id=?`, items[i].ID).Scan(&count); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to count users")
|
||||
return
|
||||
}
|
||||
items[i].UserCount = count
|
||||
}
|
||||
}
|
||||
sortPermissionGroups(items)
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items, "catalog": permissionCatalog()})
|
||||
}
|
||||
|
||||
func (a *App) handleCreatePermissionGroup(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Limits *PermissionLimits `json:"limits"`
|
||||
}
|
||||
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
|
||||
}
|
||||
permissions, err := normalizePermissionList(req.Permissions)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if !actorCanGrantPermissions(currentUser(r), permissions) {
|
||||
respondError(w, http.StatusForbidden, "cannot grant permissions you do not hold")
|
||||
return
|
||||
}
|
||||
limits := defaultPermissionLimits()
|
||||
if req.Limits != nil {
|
||||
var err error
|
||||
limits, err = normalizePermissionLimits(*req.Limits)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !actorCanGrantLimits(currentUser(r), limits) {
|
||||
respondError(w, http.StatusForbidden, "cannot grant limits above your own")
|
||||
return
|
||||
}
|
||||
id := newID("pg")
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO permission_groups(id,name,description,permissions_json,limits_json,system,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,0,?,?)`, id, name, strings.TrimSpace(req.Description), encodePermissions(permissions), encodePermissionLimits(limits), now, now); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
group, err := a.permissionGroupByID(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load permission group")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, group)
|
||||
}
|
||||
|
||||
func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var existingSystem int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT system FROM permission_groups WHERE id=?`, id).Scan(&existingSystem); err != nil {
|
||||
respondError(w, http.StatusNotFound, "permission group not found")
|
||||
return
|
||||
}
|
||||
if id == PermissionGroupSuperAdmin {
|
||||
respondError(w, http.StatusForbidden, "super administrator group cannot be edited")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Permissions []string `json:"permissions"`
|
||||
Limits *PermissionLimits `json:"limits"`
|
||||
}
|
||||
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
|
||||
}
|
||||
permissions, err := normalizePermissionList(req.Permissions)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if !actorCanGrantPermissions(currentUser(r), permissions) {
|
||||
respondError(w, http.StatusForbidden, "cannot grant permissions you do not hold")
|
||||
return
|
||||
}
|
||||
limits := defaultPermissionLimits()
|
||||
if req.Limits != nil {
|
||||
var err error
|
||||
limits, err = normalizePermissionLimits(*req.Limits)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
var rawLimits string
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT limits_json FROM permission_groups WHERE id=?`, id).Scan(&rawLimits); err != nil {
|
||||
respondError(w, http.StatusNotFound, "permission group not found")
|
||||
return
|
||||
}
|
||||
limits = decodeStoredLimits(rawLimits)
|
||||
}
|
||||
if !actorCanGrantLimits(currentUser(r), limits) {
|
||||
respondError(w, http.StatusForbidden, "cannot grant limits above your own")
|
||||
return
|
||||
}
|
||||
if _, err := a.db.ExecContext(r.Context(), `UPDATE permission_groups SET name=?,description=?,permissions_json=?,limits_json=?,updated_at=? WHERE id=?`,
|
||||
name, strings.TrimSpace(req.Description), encodePermissions(permissions), encodePermissionLimits(limits), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
group, err := a.permissionGroupByID(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load permission group")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, group)
|
||||
}
|
||||
|
||||
func (a *App) handleDeletePermissionGroup(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var system int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT system FROM permission_groups WHERE id=?`, id).Scan(&system); err != nil {
|
||||
respondError(w, http.StatusNotFound, "permission group not found")
|
||||
return
|
||||
}
|
||||
if intBool(system) {
|
||||
respondError(w, http.StatusForbidden, "system permission groups cannot be deleted")
|
||||
return
|
||||
}
|
||||
var userCount int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_permission_groups WHERE group_id=?`, id).Scan(&userCount); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to check permission group")
|
||||
return
|
||||
}
|
||||
if userCount > 0 {
|
||||
badRequest(w, errors.New("permission group is assigned to users"))
|
||||
return
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `DELETE FROM permission_groups WHERE id=?`, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete permission group")
|
||||
return
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
if affected == 0 {
|
||||
respondError(w, http.StatusNotFound, "permission group not found")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -466,14 +467,12 @@ func (a *App) handleCreateRule(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
matchMode := strings.TrimSpace(req.MatchMode)
|
||||
if matchMode == "" {
|
||||
matchMode = "all"
|
||||
}
|
||||
if matchMode != "all" && matchMode != "any" {
|
||||
rawMatchMode := strings.ToLower(strings.TrimSpace(req.MatchMode))
|
||||
if rawMatchMode != "" && rawMatchMode != "all" && rawMatchMode != "and" && rawMatchMode != "any" && rawMatchMode != "or" {
|
||||
badRequest(w, errors.New("invalid match mode"))
|
||||
return
|
||||
}
|
||||
matchMode := normalizeRuleMatchMode(rawMatchMode)
|
||||
conditions := normalizeRuleConditions(req.Conditions, req.FromContains, req.SubjectContains)
|
||||
if len(conditions) == 0 {
|
||||
badRequest(w, errors.New("rule condition is required"))
|
||||
@@ -639,10 +638,21 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load stats")
|
||||
return
|
||||
}
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(a.id) 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); err != nil {
|
||||
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 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")
|
||||
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
|
||||
WHERE `+where+` 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`, args...)
|
||||
@@ -723,11 +733,14 @@ func (a *App) deleteMessagesInFolder(ctx context.Context, mailboxID, folder stri
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, id := range ids {
|
||||
a.deleteMessageFiles(ctx, id)
|
||||
if _, err := a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, id); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
a.deleteMessage(ctx, id)
|
||||
}
|
||||
return int64(len(ids)), nil
|
||||
}
|
||||
@@ -741,13 +754,31 @@ func (a *App) archiveReadInbox(ctx context.Context, mailboxID string) (int64, er
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
res, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE mailbox_id=? AND folder_id=? AND is_read=1`,
|
||||
archiveID, a.now().UTC().Format(time.RFC3339Nano), mailboxID, inboxID)
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? AND is_read=1`, mailboxID, inboxID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err := a.moveMessageMaildir(ctx, id, archiveID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
return int64(len(ids)), nil
|
||||
}
|
||||
|
||||
func scanContact(row messageSummaryScanner) (Contact, error) {
|
||||
@@ -832,9 +863,7 @@ func scanRule(row messageSummaryScanner) (MailRule, error) {
|
||||
if err == nil {
|
||||
item.Conditions = decodeRuleConditions(conditionsJSON, item.FromContains, item.SubjectContains)
|
||||
item.Actions = decodeRuleActions(actionsJSON, item.Action)
|
||||
if item.MatchMode == "" {
|
||||
item.MatchMode = "all"
|
||||
}
|
||||
item.MatchMode = normalizeRuleMatchMode(item.MatchMode)
|
||||
}
|
||||
item.ApplyToExisting = intBool(applyToExisting)
|
||||
item.StopProcessing = intBool(stopProcessing)
|
||||
@@ -856,13 +885,8 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT user_id FROM mailboxes WHERE id=?`, mailboxID).Scan(&userID); err != nil {
|
||||
return
|
||||
}
|
||||
from = normalizeEmail(from)
|
||||
var blocked int
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM blocked_senders WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND email=?`, userID, mailboxID, from).Scan(&blocked)
|
||||
if blocked > 0 {
|
||||
if spamID, err := a.ensureFolder(ctx, mailboxID, "Spam"); err == nil {
|
||||
_, _ = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, spamID, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
||||
}
|
||||
if a.senderBlocked(ctx, userID, mailboxID, from) {
|
||||
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)
|
||||
@@ -878,26 +902,94 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr
|
||||
}
|
||||
rows.Close()
|
||||
msg := ruleMessage{ID: messageID, MailboxID: mailboxID, From: from, Subject: subject}
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT trim(from_addr || ' ' || COALESCE(from_name,'')),to_addrs,subject,snippet,body_text FROM messages WHERE id=?`, messageID).Scan(&msg.From, &msg.To, &msg.Subject, &msg.Snippet, &msg.BodyText)
|
||||
msg, ok := a.ruleMessageByID(ctx, messageID)
|
||||
if !ok {
|
||||
msg = ruleMessage{ID: messageID, MailboxID: mailboxID, From: from, Subject: subject}
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if !ruleMatches(rule, msg) {
|
||||
continue
|
||||
}
|
||||
_ = a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions)
|
||||
if rule.StopProcessing {
|
||||
return
|
||||
break
|
||||
}
|
||||
}
|
||||
if a.senderBlocked(ctx, userID, mailboxID, from) {
|
||||
a.moveBlockedMessageToSpam(ctx, messageID, mailboxID)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) senderBlocked(ctx context.Context, userID, mailboxID, from string) bool {
|
||||
from = normalizeEmail(from)
|
||||
if from == "" {
|
||||
return false
|
||||
}
|
||||
var blocked int
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM blocked_senders WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND email=?`, userID, mailboxID, from).Scan(&blocked)
|
||||
return blocked > 0
|
||||
}
|
||||
|
||||
func (a *App) moveBlockedMessageToSpam(ctx context.Context, messageID, mailboxID string) {
|
||||
spamID, err := a.ensureFolder(ctx, mailboxID, "Spam")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = a.moveMessageMaildir(ctx, messageID, spamID)
|
||||
}
|
||||
|
||||
type ruleMessage struct {
|
||||
ID string
|
||||
MailboxID string
|
||||
From string
|
||||
To string
|
||||
Subject string
|
||||
Snippet string
|
||||
BodyText string
|
||||
ID string
|
||||
MailboxID string
|
||||
From string
|
||||
To string
|
||||
CC string
|
||||
Subject string
|
||||
Snippet string
|
||||
BodyText string
|
||||
AttachmentNames string
|
||||
SizeBytes int64
|
||||
ReceivedAt time.Time
|
||||
}
|
||||
|
||||
func (a *App) ruleMessageByID(ctx context.Context, messageID string) (ruleMessage, bool) {
|
||||
var msg ruleMessage
|
||||
var toAddrs, ccAddrs, receivedAt string
|
||||
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(mailbox_id,''),trim(from_addr || ' ' || COALESCE(from_name,'')),to_addrs,cc_addrs,subject,snippet,body_text,size_bytes,received_at FROM messages WHERE id=?`, messageID).
|
||||
Scan(&msg.ID, &msg.MailboxID, &msg.From, &toAddrs, &ccAddrs, &msg.Subject, &msg.Snippet, &msg.BodyText, &msg.SizeBytes, &receivedAt)
|
||||
if err != nil {
|
||||
return ruleMessage{}, false
|
||||
}
|
||||
msg.To = ruleAddressText(toAddrs)
|
||||
msg.CC = ruleAddressText(ccAddrs)
|
||||
msg.ReceivedAt = parseTime(receivedAt)
|
||||
msg.AttachmentNames = a.ruleAttachmentNames(ctx, messageID)
|
||||
return msg, true
|
||||
}
|
||||
|
||||
func ruleAddressText(raw string) string {
|
||||
var items []string
|
||||
if strings.TrimSpace(raw) != "" && json.Unmarshal([]byte(raw), &items) == nil {
|
||||
return strings.Join(items, " ")
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func (a *App) ruleAttachmentNames(ctx context.Context, messageID string) string {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT filename,content_type FROM attachments WHERE message_id=? ORDER BY filename`, messageID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer rows.Close()
|
||||
parts := []string{}
|
||||
for rows.Next() {
|
||||
var filename, contentType string
|
||||
if err := rows.Scan(&filename, &contentType); err != nil {
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
parts = append(parts, filename, contentType)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func normalizeRuleConditions(items []MailRuleCondition, legacyFrom, legacySubject string) []MailRuleCondition {
|
||||
@@ -911,26 +1003,56 @@ func normalizeRuleConditions(items []MailRuleCondition, legacyFrom, legacySubjec
|
||||
}
|
||||
out := []MailRuleCondition{}
|
||||
for _, item := range items {
|
||||
field := strings.TrimSpace(item.Field)
|
||||
operator := strings.TrimSpace(item.Operator)
|
||||
value := strings.TrimSpace(item.Value)
|
||||
if value == "" {
|
||||
continue
|
||||
if normalized, ok := normalizeRuleCondition(item); ok {
|
||||
out = append(out, normalized)
|
||||
}
|
||||
if field != "from" && field != "to" && field != "subject" && field != "body" {
|
||||
continue
|
||||
}
|
||||
if operator == "" {
|
||||
operator = "contains"
|
||||
}
|
||||
if operator != "contains" && operator != "not-contains" && operator != "equals" && operator != "not-equals" && operator != "starts-with" && operator != "ends-with" {
|
||||
continue
|
||||
}
|
||||
out = append(out, MailRuleCondition{Field: field, Operator: operator, Value: value})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeRuleCondition(item MailRuleCondition) (MailRuleCondition, bool) {
|
||||
matchMode := normalizeRuleMatchMode(item.MatchMode)
|
||||
if len(item.Conditions) > 0 {
|
||||
children := normalizeRuleConditions(item.Conditions, "", "")
|
||||
if len(children) == 0 {
|
||||
return MailRuleCondition{}, false
|
||||
}
|
||||
return MailRuleCondition{MatchMode: matchMode, Conditions: children}, true
|
||||
}
|
||||
field := strings.ToLower(strings.TrimSpace(item.Field))
|
||||
operator := strings.ToLower(strings.TrimSpace(item.Operator))
|
||||
value := strings.TrimSpace(item.Value)
|
||||
if value == "" {
|
||||
return MailRuleCondition{}, false
|
||||
}
|
||||
switch field {
|
||||
case "from", "to", "cc", "subject", "body", "attachment", "size", "date":
|
||||
default:
|
||||
return MailRuleCondition{}, false
|
||||
}
|
||||
if operator == "" {
|
||||
operator = "contains"
|
||||
}
|
||||
switch operator {
|
||||
case "contains", "not-contains", "equals", "not-equals", "starts-with", "ends-with":
|
||||
case "gt", "gte", "lt", "lte", "before", "after", "on":
|
||||
default:
|
||||
return MailRuleCondition{}, false
|
||||
}
|
||||
return MailRuleCondition{Field: field, Operator: operator, Value: value}, true
|
||||
}
|
||||
|
||||
func normalizeRuleMatchMode(matchMode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(matchMode)) {
|
||||
case "any", "or":
|
||||
return "any"
|
||||
case "all", "and":
|
||||
return "all"
|
||||
default:
|
||||
return "all"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRuleActions(items []MailRuleAction, legacyAction string) []MailRuleAction {
|
||||
if len(items) == 0 && strings.TrimSpace(legacyAction) != "" {
|
||||
items = append(items, MailRuleAction{Type: strings.TrimSpace(legacyAction)})
|
||||
@@ -987,10 +1109,11 @@ func ruleMatches(rule MailRule, msg ruleMessage) bool {
|
||||
if len(conditions) == 0 {
|
||||
return false
|
||||
}
|
||||
matchMode := rule.MatchMode
|
||||
if matchMode == "" {
|
||||
matchMode = "all"
|
||||
}
|
||||
matchMode := normalizeRuleMatchMode(rule.MatchMode)
|
||||
return ruleConditionsMatch(conditions, matchMode, msg)
|
||||
}
|
||||
|
||||
func ruleConditionsMatch(conditions []MailRuleCondition, matchMode string, msg ruleMessage) bool {
|
||||
matched := 0
|
||||
for _, condition := range conditions {
|
||||
if ruleConditionMatches(condition, msg) {
|
||||
@@ -1006,12 +1129,17 @@ func ruleMatches(rule MailRule, msg ruleMessage) bool {
|
||||
}
|
||||
|
||||
func ruleConditionMatches(condition MailRuleCondition, msg ruleMessage) bool {
|
||||
if len(condition.Conditions) > 0 {
|
||||
return ruleConditionsMatch(condition.Conditions, normalizeRuleMatchMode(condition.MatchMode), msg)
|
||||
}
|
||||
var source string
|
||||
switch condition.Field {
|
||||
switch strings.ToLower(strings.TrimSpace(condition.Field)) {
|
||||
case "from":
|
||||
source = msg.From
|
||||
case "to":
|
||||
source = msg.To
|
||||
case "cc":
|
||||
source = msg.CC
|
||||
case "subject":
|
||||
source = msg.Subject
|
||||
case "body":
|
||||
@@ -1019,12 +1147,18 @@ func ruleConditionMatches(condition MailRuleCondition, msg ruleMessage) bool {
|
||||
if source == "" {
|
||||
source = msg.Snippet
|
||||
}
|
||||
case "attachment":
|
||||
source = msg.AttachmentNames
|
||||
case "size":
|
||||
return ruleNumericConditionMatches(condition, msg.SizeBytes)
|
||||
case "date":
|
||||
return ruleDateConditionMatches(condition, msg.ReceivedAt)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
source = strings.ToLower(source)
|
||||
value := strings.ToLower(condition.Value)
|
||||
switch condition.Operator {
|
||||
switch strings.ToLower(strings.TrimSpace(condition.Operator)) {
|
||||
case "contains":
|
||||
return strings.Contains(source, value)
|
||||
case "not-contains":
|
||||
@@ -1042,35 +1176,139 @@ func ruleConditionMatches(condition MailRuleCondition, msg ruleMessage) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func ruleNumericConditionMatches(condition MailRuleCondition, source int64) bool {
|
||||
value, ok := parseRuleSizeValue(condition.Value)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(condition.Operator)) {
|
||||
case "gt":
|
||||
return source > value
|
||||
case "gte":
|
||||
return source >= value
|
||||
case "lt":
|
||||
return source < value
|
||||
case "lte":
|
||||
return source <= value
|
||||
case "equals":
|
||||
return source == value
|
||||
case "not-equals":
|
||||
return source != value
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func parseRuleSizeValue(raw string) (int64, bool) {
|
||||
value := strings.ToLower(strings.TrimSpace(raw))
|
||||
multiplier := int64(1)
|
||||
for _, suffix := range []struct {
|
||||
text string
|
||||
multiplier int64
|
||||
}{
|
||||
{"kb", 1024},
|
||||
{"k", 1024},
|
||||
{"mb", 1024 * 1024},
|
||||
{"m", 1024 * 1024},
|
||||
{"gb", 1024 * 1024 * 1024},
|
||||
{"g", 1024 * 1024 * 1024},
|
||||
{"b", 1},
|
||||
} {
|
||||
if strings.HasSuffix(value, suffix.text) {
|
||||
multiplier = suffix.multiplier
|
||||
value = strings.TrimSpace(strings.TrimSuffix(value, suffix.text))
|
||||
break
|
||||
}
|
||||
}
|
||||
n, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || n < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return n * multiplier, true
|
||||
}
|
||||
|
||||
func ruleDateConditionMatches(condition MailRuleCondition, source time.Time) bool {
|
||||
if source.IsZero() {
|
||||
return false
|
||||
}
|
||||
target, ok := parseRuleDateValue(condition.Value)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
source = source.UTC()
|
||||
switch strings.ToLower(strings.TrimSpace(condition.Operator)) {
|
||||
case "before", "lt":
|
||||
return source.Before(target)
|
||||
case "after", "gt":
|
||||
return source.After(target)
|
||||
case "on", "equals":
|
||||
y1, m1, d1 := source.Date()
|
||||
y2, m2, d2 := target.Date()
|
||||
return y1 == y2 && m1 == m2 && d1 == d2
|
||||
case "not-equals":
|
||||
y1, m1, d1 := source.Date()
|
||||
y2, m2, d2 := target.Date()
|
||||
return y1 != y2 || m1 != m2 || d1 != d2
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func parseRuleDateValue(raw string) (time.Time, bool) {
|
||||
value := strings.TrimSpace(raw)
|
||||
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02"} {
|
||||
if t, err := time.Parse(layout, value); err == nil {
|
||||
return t.UTC(), true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func (a *App) applyRuleActions(ctx context.Context, mailboxID, messageID string, actions []MailRuleAction) error {
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
for _, action := range normalizeRuleActions(actions, "") {
|
||||
switch action.Type {
|
||||
case "archive":
|
||||
if folderID, err := a.ensureFolder(ctx, mailboxID, "Archive"); err == nil {
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, now, messageID); err != nil {
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "trash":
|
||||
if folderID, err := a.ensureFolder(ctx, mailboxID, "Trash"); err == nil {
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, now, messageID); err != nil {
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "move":
|
||||
target := ruleTargetFolder(action.Value)
|
||||
if folderID, err := a.ensureFolder(ctx, mailboxID, target); err == nil {
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, now, messageID); err != nil {
|
||||
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case "star":
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET is_starred=1, updated_at=? WHERE id=?`, now, messageID); err != nil {
|
||||
starred := true
|
||||
if err := a.updateMessageMaildirFlags(ctx, messageID, nil, &starred); err != nil {
|
||||
return err
|
||||
}
|
||||
modSeq, err := a.updateMessageModSeq(ctx, messageID, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET is_starred=1, imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END, updated_at=? WHERE id=?`, modSeq, modSeq, now, messageID); err != nil {
|
||||
return err
|
||||
}
|
||||
case "mark-read":
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET is_read=1, updated_at=? WHERE id=?`, now, messageID); err != nil {
|
||||
read := true
|
||||
if err := a.updateMessageMaildirFlags(ctx, messageID, &read, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
modSeq, err := a.updateMessageModSeq(ctx, messageID, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET is_read=1, imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END, updated_at=? WHERE id=?`, modSeq, modSeq, now, messageID); err != nil {
|
||||
return err
|
||||
}
|
||||
case "label":
|
||||
@@ -1128,19 +1366,21 @@ func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID
|
||||
where += ` AND m.mailbox_id=?`
|
||||
args = append(args, mailboxID)
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT m.id,m.mailbox_id,trim(m.from_addr || ' ' || COALESCE(m.from_name,'')),m.to_addrs,m.subject,m.snippet,m.body_text FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...)
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
messages := []ruleMessage{}
|
||||
var count int64
|
||||
for rows.Next() {
|
||||
var msg ruleMessage
|
||||
var toAddrs sql.NullString
|
||||
if err := rows.Scan(&msg.ID, &msg.MailboxID, &msg.From, &toAddrs, &msg.Subject, &msg.Snippet, &msg.BodyText); err != nil {
|
||||
var messageID string
|
||||
if err := rows.Scan(&messageID); err != nil {
|
||||
return count, err
|
||||
}
|
||||
msg.To = toAddrs.String
|
||||
msg, ok := a.ruleMessageByID(ctx, messageID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !ruleMatches(rule, msg) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ func (a *App) Router() http.Handler {
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(a.corsMiddleware)
|
||||
|
||||
r.Post("/auth-policy", a.handleAuthPolicy)
|
||||
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "time": a.now().UTC()})
|
||||
})
|
||||
@@ -36,87 +37,119 @@ func (a *App) Router() http.Handler {
|
||||
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/mailbox-apply-options", a.handleMailboxApplyOptions)
|
||||
r.With(a.requireAuth).Post("/me/mailboxes/apply", a.handleApplyMailbox)
|
||||
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).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)
|
||||
r.With(a.requireAuth).Get("/me/contacts", a.handleListContacts)
|
||||
r.With(a.requireAuth).Post("/me/contacts", a.handleCreateContact)
|
||||
r.With(a.requireAuth).Delete("/me/contacts/{id}", a.handleDeleteContact)
|
||||
r.With(a.requireAuth).Get("/me/signatures", a.handleListSignatures)
|
||||
r.With(a.requireAuth).Post("/me/signatures", a.handleCreateSignature)
|
||||
r.With(a.requireAuth).Post("/me/signatures/{id}", a.handleUpdateSignature)
|
||||
r.With(a.requireAuth).Post("/me/signatures/{id}/default", a.handleSetDefaultSignature)
|
||||
r.With(a.requireAuth).Delete("/me/signatures/{id}", a.handleDeleteSignature)
|
||||
r.With(a.requireAuth).Get("/me/signatures/default", a.handleDefaultSignature)
|
||||
r.With(a.requireAuth).Get("/me/rules", a.handleListRules)
|
||||
r.With(a.requireAuth).Post("/me/rules", a.handleCreateRule)
|
||||
r.With(a.requireAuth).Delete("/me/rules/{id}", a.handleDeleteRule)
|
||||
r.With(a.requireAuth).Get("/me/blocked-senders", a.handleListBlockedSenders)
|
||||
r.With(a.requireAuth).Post("/me/blocked-senders", a.handleCreateBlockedSender)
|
||||
r.With(a.requireAuth).Delete("/me/blocked-senders/{id}", a.handleDeleteBlockedSender)
|
||||
r.With(a.requireAuth).Get("/me/stats", a.handleMailStats)
|
||||
r.With(a.requireAuth).Post("/me/cleanup", a.handleMailCleanup)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailContacts)).Get("/me/contacts", a.handleListContacts)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailContacts)).Post("/me/contacts", a.handleCreateContact)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailContacts)).Delete("/me/contacts/{id}", a.handleDeleteContact)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Get("/me/signatures", a.handleListSignatures)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Post("/me/signatures", a.handleCreateSignature)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Post("/me/signatures/{id}", a.handleUpdateSignature)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Post("/me/signatures/{id}/default", a.handleSetDefaultSignature)
|
||||
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Delete("/me/signatures/{id}", a.handleDeleteSignature)
|
||||
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)).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.Group(func(r chi.Router) {
|
||||
r.Use(a.requireAuth)
|
||||
r.Get("/mail/mailboxes", a.handleMyMailboxes)
|
||||
r.Get("/mail/folders", a.handleMailFolders)
|
||||
r.Get("/mail/labels", a.handleMailLabels)
|
||||
r.Post("/mail/labels", a.handleCreateMailLabel)
|
||||
r.Get("/mail/messages", a.handleMailMessages)
|
||||
r.Get("/mail/starred", a.handleStarredMessages)
|
||||
r.Get("/mail/messages/{id}", a.handleMailMessage)
|
||||
r.Post("/mail/send", a.handleMailSend)
|
||||
r.Get("/mail/scheduled-sends", a.handleScheduledSends)
|
||||
r.Post("/mail/schedule-send", a.handleScheduleSend)
|
||||
r.Delete("/mail/schedule-send/{id}", a.handleCancelScheduledSend)
|
||||
r.Post("/mail/drafts", a.handleSaveDraft)
|
||||
r.Post("/mail/drafts/{id}", a.handleSaveDraft)
|
||||
r.Delete("/mail/drafts/{id}", a.handleDeleteDraft)
|
||||
r.Post("/mail/messages/{id}/mark-read", a.handleMarkRead)
|
||||
r.Post("/mail/messages/{id}/star", a.handleStar)
|
||||
r.Post("/mail/messages/{id}/labels", a.handleAddMessageLabel)
|
||||
r.Delete("/mail/messages/{id}/labels/{labelID}", a.handleRemoveMessageLabel)
|
||||
r.Post("/mail/messages/{id}/move", a.handleMove)
|
||||
r.Delete("/mail/messages/{id}", a.handleDeleteMessage)
|
||||
r.Get("/mail/attachments/{id}", a.handleAttachment)
|
||||
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/messages/{id}", a.handleMailMessage)
|
||||
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(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)
|
||||
r.With(a.requirePermission(PermissionMailSend)).Post("/mail/send-queue/{id}/retry", a.handleRetrySendQueue)
|
||||
r.With(a.requirePermission(PermissionMailSend)).Delete("/mail/send-queue/{id}", a.handleCancelSendQueue)
|
||||
r.With(a.requirePermission(PermissionMailSchedule)).Get("/mail/scheduled-sends", a.handleScheduledSends)
|
||||
r.With(a.requirePermission(PermissionMailSchedule), a.requirePermission(PermissionMailSend)).Post("/mail/schedule-send", a.handleScheduleSend)
|
||||
r.With(a.requirePermission(PermissionMailSchedule)).Delete("/mail/schedule-send/{id}", a.handleCancelScheduledSend)
|
||||
r.With(a.requirePermission(PermissionMailDrafts)).Post("/mail/drafts", a.handleSaveDraft)
|
||||
r.With(a.requirePermission(PermissionMailDrafts)).Post("/mail/drafts/{id}", a.handleSaveDraft)
|
||||
r.With(a.requirePermission(PermissionMailDrafts)).Delete("/mail/drafts/{id}", a.handleDeleteDraft)
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/mark-read", a.handleMarkRead)
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/star", a.handleStar)
|
||||
r.With(a.requirePermission(PermissionMailLabels)).Post("/mail/messages/{id}/labels", a.handleAddMessageLabel)
|
||||
r.With(a.requirePermission(PermissionMailLabels)).Delete("/mail/messages/{id}/labels/{labelID}", a.handleRemoveMessageLabel)
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/move", a.handleMove)
|
||||
r.With(a.requirePermission(PermissionMailOrganize)).Delete("/mail/messages/{id}", a.handleDeleteMessage)
|
||||
r.With(a.requirePermission(PermissionMailAttachments)).Get("/mail/attachments/{id}", a.handleAttachment)
|
||||
})
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.requireAuth)
|
||||
r.Use(a.requireAdmin)
|
||||
r.Get("/admin/overview", a.handleAdminOverview)
|
||||
r.Get("/admin/users", a.handleListUsers)
|
||||
r.Post("/admin/users", a.handleCreateUser)
|
||||
r.Post("/admin/users/{id}", a.handleUpdateUser)
|
||||
r.Post("/admin/users/{id}/password", a.handleResetUserPassword)
|
||||
r.Delete("/admin/users/{id}", a.handleDeleteUser)
|
||||
r.Get("/admin/domains", a.handleListDomains)
|
||||
r.Post("/admin/domains", a.handleCreateDomain)
|
||||
r.Post("/admin/domains/{id}", a.handleUpdateDomain)
|
||||
r.Delete("/admin/domains/{id}", a.handleDeleteDomain)
|
||||
r.Get("/admin/mailboxes", a.handleListMailboxes)
|
||||
r.Post("/admin/mailboxes", a.handleCreateMailbox)
|
||||
r.Post("/admin/mailboxes/{id}", a.handleUpdateMailbox)
|
||||
r.Delete("/admin/mailboxes/{id}", a.handleDeleteMailbox)
|
||||
r.Get("/admin/aliases", a.handleListAliases)
|
||||
r.Post("/admin/aliases", a.handleCreateAlias)
|
||||
r.Post("/admin/aliases/{id}", a.handleUpdateAlias)
|
||||
r.Delete("/admin/aliases/{id}", a.handleDeleteAlias)
|
||||
r.Get("/admin/messages", a.handleAdminMessages)
|
||||
r.Get("/admin/messages/{id}", a.handleAdminMessage)
|
||||
r.Get("/admin/attachments/{id}", a.handleAdminAttachment)
|
||||
r.Get("/admin/settings", a.handleGetSystemSettings)
|
||||
r.Post("/admin/settings", a.handleUpdateSystemSettings)
|
||||
r.Post("/admin/settings/test-smtp", a.handleTestSMTP)
|
||||
r.Get("/admin/mail-templates", a.handleListMailTemplates)
|
||||
r.Post("/admin/mail-templates/{key}", a.handleUpdateMailTemplate)
|
||||
r.Post("/admin/mail-templates/{key}/reset", a.handleResetMailTemplate)
|
||||
r.Get("/admin/domains/{id}/dns-records", a.handleDNSRecords)
|
||||
r.Post("/admin/domains/{id}/check-dns", a.handleDNSCheck)
|
||||
r.Use(a.requireAdminAccess)
|
||||
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)
|
||||
r.With(a.requirePermission(PermissionUsersUpdate)).Post("/admin/users/{id}", a.handleUpdateUser)
|
||||
r.With(a.requirePermission(PermissionUsersResetPassword)).Post("/admin/users/{id}/password", a.handleResetUserPassword)
|
||||
r.With(a.requirePermission(PermissionUsersDelete)).Delete("/admin/users/{id}", a.handleDeleteUser)
|
||||
r.With(a.requireAnyPermission(PermissionGroupsView, PermissionUsersView)).Get("/admin/permission-limits/defaults", a.handleDefaultPermissionLimits)
|
||||
r.With(a.requireAnyPermission(PermissionGroupsView, PermissionUsersView)).Get("/admin/permissions", a.handlePermissionCatalog)
|
||||
r.With(a.requireAnyPermission(PermissionGroupsView, PermissionUsersView)).Get("/admin/permission-groups", a.handleListPermissionGroups)
|
||||
r.With(a.requirePermission(PermissionGroupsCreate)).Post("/admin/permission-groups", a.handleCreatePermissionGroup)
|
||||
r.With(a.requirePermission(PermissionGroupsUpdate)).Post("/admin/permission-groups/{id}", a.handleUpdatePermissionGroup)
|
||||
r.With(a.requirePermission(PermissionGroupsDelete)).Delete("/admin/permission-groups/{id}", a.handleDeletePermissionGroup)
|
||||
r.With(a.requireAnyPermission(PermissionDomainsView, PermissionDNSView, PermissionMailboxesView, PermissionAliasesView, PermissionSettingsView, PermissionTemplatesView)).Get("/admin/domains", a.handleListDomains)
|
||||
r.With(a.requirePermission(PermissionDomainsCreate)).Post("/admin/domains", a.handleCreateDomain)
|
||||
r.With(a.requirePermission(PermissionDomainsUpdate)).Post("/admin/domains/{id}", a.handleUpdateDomain)
|
||||
r.With(a.requirePermission(PermissionDomainsDelete)).Delete("/admin/domains/{id}", a.handleDeleteDomain)
|
||||
r.With(a.requireAnyPermission(PermissionMailboxesView, PermissionMessagesView)).Get("/admin/mailboxes", a.handleListMailboxes)
|
||||
r.With(a.requirePermission(PermissionMailboxesCreate)).Post("/admin/mailboxes", a.handleCreateMailbox)
|
||||
r.With(a.requirePermission(PermissionMailboxesUpdate)).Post("/admin/mailboxes/{id}", a.handleUpdateMailbox)
|
||||
r.With(a.requirePermission(PermissionMailboxesDelete)).Delete("/admin/mailboxes/{id}", a.handleDeleteMailbox)
|
||||
r.With(a.requirePermission(PermissionAliasesView)).Get("/admin/aliases", a.handleListAliases)
|
||||
r.With(a.requirePermission(PermissionAliasesCreate)).Post("/admin/aliases", a.handleCreateAlias)
|
||||
r.With(a.requirePermission(PermissionAliasesUpdate)).Post("/admin/aliases/{id}", a.handleUpdateAlias)
|
||||
r.With(a.requirePermission(PermissionAliasesDelete)).Delete("/admin/aliases/{id}", a.handleDeleteAlias)
|
||||
r.With(a.requirePermission(PermissionMessagesView)).Get("/admin/messages", a.handleAdminMessages)
|
||||
r.With(a.requirePermission(PermissionMessagesView)).Get("/admin/send-audit", a.handleAdminSendAudit)
|
||||
r.With(a.requirePermission(PermissionMessagesRead)).Get("/admin/messages/{id}", a.handleAdminMessage)
|
||||
r.With(a.requirePermission(PermissionMessagesAttachment)).Get("/admin/attachments/{id}", a.handleAdminAttachment)
|
||||
r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/settings", a.handleGetSystemSettings)
|
||||
r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/maildir-sync/health", a.handleMaildirSyncHealth)
|
||||
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings", a.handleUpdateSystemSettings)
|
||||
r.With(a.requirePermission(PermissionSettingsTestSMTP)).Post("/admin/settings/test-smtp", a.handleTestSMTP)
|
||||
r.With(a.requirePermission(PermissionTemplatesView)).Get("/admin/mail-templates", a.handleListMailTemplates)
|
||||
r.With(a.requirePermission(PermissionTemplatesUpdate)).Post("/admin/mail-templates/{key}", a.handleUpdateMailTemplate)
|
||||
r.With(a.requirePermission(PermissionTemplatesReset)).Post("/admin/mail-templates/{key}/reset", a.handleResetMailTemplate)
|
||||
r.With(a.requirePermission(PermissionDNSView)).Get("/admin/domains/{id}/dns-records", a.handleDNSRecords)
|
||||
r.With(a.requirePermission(PermissionDNSCheck)).Post("/admin/domains/{id}/check-dns", a.handleDNSCheck)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -152,17 +185,6 @@ func (a *App) requireAuth(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) requireAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
if user == nil || user.Role != "admin" {
|
||||
respondError(w, http.StatusForbidden, "admin role required")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func currentUser(r *http.Request) *User {
|
||||
user, _ := r.Context().Value(userContextKey).(*User)
|
||||
return user
|
||||
@@ -188,6 +210,9 @@ func (a *App) authenticateRequest(r *http.Request) (*User, error) {
|
||||
if u.Disabled {
|
||||
return nil, errors.New("disabled")
|
||||
}
|
||||
if err := a.attachUserAuthorization(r.Context(), &u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
@@ -206,6 +231,9 @@ func (a *App) userByEmail(ctx context.Context, email string) (*User, string, err
|
||||
u.Disabled = intBool(disabled)
|
||||
u.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
u.CreatedAt = parseTime(created)
|
||||
if err := a.attachUserAuthorization(ctx, &u); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &u, passwordHash, nil
|
||||
}
|
||||
|
||||
@@ -223,5 +251,8 @@ func (a *App) userByID(ctx context.Context, id string) (*User, error) {
|
||||
u.Disabled = intBool(disabled)
|
||||
u.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
u.CreatedAt = parseTime(created)
|
||||
if err := a.attachUserAuthorization(ctx, &u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
sendQueueStatusQueued = "queued"
|
||||
sendQueueStatusSending = "sending"
|
||||
sendQueueStatusDelivered = "delivered"
|
||||
sendQueueStatusFailed = "failed"
|
||||
sendQueueStatusCanceled = "canceled"
|
||||
|
||||
sendAuditAccepted = "accepted"
|
||||
sendAuditQueued = "queued"
|
||||
sendAuditDelivered = "delivered"
|
||||
sendAuditFailed = "failed"
|
||||
sendAuditRetry = "retry"
|
||||
sendAuditCanceled = "canceled"
|
||||
|
||||
sendSourceWebmail = "webmail"
|
||||
sendSourceSubmission = "submission"
|
||||
|
||||
sendQueueStaleAfter = 15 * time.Minute
|
||||
sendQueueConcurrency = 4
|
||||
|
||||
sendQueueDeliveredMarkerDir = "send_queue_delivered"
|
||||
)
|
||||
|
||||
type sendQueueInput struct {
|
||||
UserID string
|
||||
MailboxID string
|
||||
SentMessageID string
|
||||
MessageID string
|
||||
Source string
|
||||
MailFrom string
|
||||
HeaderFrom string
|
||||
Recipients []string
|
||||
MIMEBytes []byte
|
||||
Now time.Time
|
||||
}
|
||||
|
||||
type sendQueueItem struct {
|
||||
ID string
|
||||
UserID string
|
||||
MailboxID string
|
||||
SentMessageID string
|
||||
MessageID string
|
||||
Source string
|
||||
MailFrom string
|
||||
HeaderFrom string
|
||||
Recipients []string
|
||||
MIMEBytes []byte
|
||||
AttemptCount int
|
||||
MaxAttempts int
|
||||
}
|
||||
|
||||
func (a *App) enqueueSend(ctx context.Context, in sendQueueInput) (string, error) {
|
||||
if strings.TrimSpace(a.cfg.SMTPHost) == "" {
|
||||
return "", nil
|
||||
}
|
||||
now := in.Now.UTC()
|
||||
if now.IsZero() {
|
||||
now = a.now().UTC()
|
||||
}
|
||||
id := newID("snd")
|
||||
messageID := strings.TrimSpace(in.MessageID)
|
||||
mimeBase64 := base64.StdEncoding.EncodeToString(in.MIMEBytes)
|
||||
recipientsJSON := jsonEncode(dedupeEmails(in.Recipients))
|
||||
_, err := a.db.ExecContext(ctx, `INSERT OR IGNORE INTO send_queue(id,user_id,mailbox_id,sent_message_id,message_id,source,mail_from,header_from,recipients_json,mime_base64,status,next_attempt_at,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
id, in.UserID, in.MailboxID, in.SentMessageID, messageID, in.Source, normalizeEmail(in.MailFrom), normalizeEmail(in.HeaderFrom), recipientsJSON, mimeBase64, sendQueueStatusQueued, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(messageID) != "" {
|
||||
var existingID, status string
|
||||
var attemptCount, maxAttempts int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT id,status,attempt_count,max_attempts FROM send_queue WHERE mailbox_id=? AND source=? AND message_id=?`, in.MailboxID, in.Source, messageID).Scan(&existingID, &status, &attemptCount, &maxAttempts); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if existingID != id {
|
||||
if status == sendQueueStatusDelivered || status == sendQueueStatusCanceled || (status == sendQueueStatusFailed && attemptCount >= maxAttempts) {
|
||||
_, err := a.db.ExecContext(ctx, `UPDATE send_queue SET user_id=?,sent_message_id=?,mail_from=?,header_from=?,recipients_json=?,mime_base64=?,status=?,attempt_count=0,next_attempt_at=?,last_error='',updated_at=?,delivered_at=NULL WHERE id=? AND status=?`,
|
||||
in.UserID, in.SentMessageID, normalizeEmail(in.MailFrom), normalizeEmail(in.HeaderFrom), recipientsJSON, mimeBase64, sendQueueStatusQueued, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), existingID, status)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
a.deleteSendQueueDeliveredMarker(existingID)
|
||||
a.recordSendAudit(ctx, sendAuditQueued, sendQueueStatusQueued, sendAuditInput{
|
||||
QueueID: existingID,
|
||||
UserID: in.UserID,
|
||||
MailboxID: in.MailboxID,
|
||||
SentMessageID: in.SentMessageID,
|
||||
Source: in.Source,
|
||||
MailFrom: in.MailFrom,
|
||||
HeaderFrom: in.HeaderFrom,
|
||||
Recipients: in.Recipients,
|
||||
})
|
||||
}
|
||||
return existingID, nil
|
||||
}
|
||||
}
|
||||
a.recordSendAudit(ctx, sendAuditQueued, sendQueueStatusQueued, sendAuditInput{
|
||||
QueueID: id,
|
||||
UserID: in.UserID,
|
||||
MailboxID: in.MailboxID,
|
||||
SentMessageID: in.SentMessageID,
|
||||
Source: in.Source,
|
||||
MailFrom: in.MailFrom,
|
||||
HeaderFrom: in.HeaderFrom,
|
||||
Recipients: in.Recipients,
|
||||
})
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (a *App) sendQueueWorker(ctx context.Context) {
|
||||
a.log.Info("send queue worker started")
|
||||
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)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
a.log.Info("send queue worker stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) processDueSendQueue(ctx context.Context) error {
|
||||
if strings.TrimSpace(a.cfg.SMTPHost) == "" {
|
||||
return nil
|
||||
}
|
||||
if err := a.recoverStaleSendQueueItems(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id FROM send_queue WHERE (status=? OR (status=? AND attempt_count<max_attempts)) AND next_attempt_at<=? ORDER BY next_attempt_at, created_at LIMIT 20`, sendQueueStatusQueued, sendQueueStatusFailed, a.now().UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
sem := make(chan struct{}, sendQueueConcurrency)
|
||||
done := make(chan struct{}, len(ids))
|
||||
for _, id := range ids {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case sem <- struct{}{}:
|
||||
}
|
||||
go func(id string) {
|
||||
defer func() {
|
||||
<-sem
|
||||
done <- struct{}{}
|
||||
}()
|
||||
a.processSendQueueItem(ctx, id)
|
||||
}(id)
|
||||
}
|
||||
for range ids {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-done:
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) recoverStaleSendQueueItems(ctx context.Context) error {
|
||||
cutoff := a.now().UTC().Add(-sendQueueStaleAfter).Format(time.RFC3339Nano)
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id,user_id,mailbox_id,sent_message_id,message_id,source,mail_from,header_from,recipients_json,mime_base64,attempt_count,max_attempts FROM send_queue WHERE status=? AND updated_at<=? AND attempt_count<max_attempts LIMIT 20`, sendQueueStatusSending, cutoff)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []sendQueueItem
|
||||
for rows.Next() {
|
||||
var item sendQueueItem
|
||||
var recipientsJSON, mimeBase64 string
|
||||
if err := rows.Scan(&item.ID, &item.UserID, &item.MailboxID, &item.SentMessageID, &item.MessageID, &item.Source, &item.MailFrom, &item.HeaderFrom, &recipientsJSON, &mimeBase64, &item.AttemptCount, &item.MaxAttempts); err != nil {
|
||||
return err
|
||||
}
|
||||
item.Recipients = jsonDecodeSlice(recipientsJSON)
|
||||
delivered, err := a.hasSendQueueDeliveredMarker(item.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if delivered {
|
||||
items = append(items, item)
|
||||
continue
|
||||
}
|
||||
mimeBytes, err := base64.StdEncoding.DecodeString(mimeBase64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
item.MIMEBytes = mimeBytes
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
for _, item := range items {
|
||||
delivered, err := a.hasSendQueueDeliveredMarker(item.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if delivered {
|
||||
if err := a.markSendQueueDelivered(ctx, item); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
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, sendQueueStatusQueued, sendAuditInputFromQueue(item, "send attempt interrupted"))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) processSendQueueItem(ctx context.Context, id string) {
|
||||
item, err := a.claimSendQueueItem(ctx, id)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
a.log.Warn("failed to claim send queue item", "id", id, "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := a.sendSMTP(item.MailFrom, item.Recipients, item.MIMEBytes); err != nil {
|
||||
a.markSendQueueFailed(ctx, item, err)
|
||||
return
|
||||
}
|
||||
if err := a.writeSendQueueDeliveredMarker(item.ID); err != nil {
|
||||
a.log.Warn("failed to persist send queue delivered marker", "id", item.ID, "error", err)
|
||||
}
|
||||
if err := a.markSendQueueDelivered(ctx, item); err != nil {
|
||||
a.log.Warn("failed to mark send queue delivered", "id", item.ID, "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) markSendQueueDelivered(ctx context.Context, item sendQueueItem) error {
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := a.db.ExecContext(ctx, `UPDATE send_queue SET status=?,delivered_at=?,updated_at=?,last_error='',mime_base64='' WHERE id=?`, sendQueueStatusDelivered, now, now, item.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
a.deleteSendQueueDeliveredMarker(item.ID)
|
||||
a.recordSendAudit(ctx, sendAuditDelivered, sendQueueStatusDelivered, sendAuditInputFromQueue(item, ""))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) claimSendQueueItem(ctx context.Context, id string) (sendQueueItem, error) {
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
res, err := a.db.ExecContext(ctx, `UPDATE send_queue SET status=?,attempt_count=attempt_count+1,updated_at=? WHERE id=? AND (status=? OR (status=? AND attempt_count<max_attempts)) AND next_attempt_at<=?`, sendQueueStatusSending, now, id, sendQueueStatusQueued, sendQueueStatusFailed, now)
|
||||
if err != nil {
|
||||
return sendQueueItem{}, err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return sendQueueItem{}, sql.ErrNoRows
|
||||
}
|
||||
var item sendQueueItem
|
||||
var recipientsJSON, mimeBase64 string
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,user_id,mailbox_id,sent_message_id,message_id,source,mail_from,header_from,recipients_json,mime_base64,attempt_count,max_attempts FROM send_queue WHERE id=?`, id)
|
||||
if err := row.Scan(&item.ID, &item.UserID, &item.MailboxID, &item.SentMessageID, &item.MessageID, &item.Source, &item.MailFrom, &item.HeaderFrom, &recipientsJSON, &mimeBase64, &item.AttemptCount, &item.MaxAttempts); err != nil {
|
||||
return sendQueueItem{}, err
|
||||
}
|
||||
item.Recipients = jsonDecodeSlice(recipientsJSON)
|
||||
mimeBytes, err := base64.StdEncoding.DecodeString(mimeBase64)
|
||||
if err != nil {
|
||||
return sendQueueItem{}, err
|
||||
}
|
||||
item.MIMEBytes = mimeBytes
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (a *App) markSendQueueFailed(ctx context.Context, item sendQueueItem, sendErr error) {
|
||||
now := a.now().UTC()
|
||||
status := sendQueueStatusFailed
|
||||
nextAttempt := now.Add(sendRetryDelay(item.AttemptCount))
|
||||
if item.AttemptCount >= item.MaxAttempts {
|
||||
nextAttempt = now.Add(365 * 24 * time.Hour)
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx, `UPDATE send_queue SET status=?,next_attempt_at=?,last_error=?,updated_at=? WHERE id=?`, status, nextAttempt.Format(time.RFC3339Nano), sendErr.Error(), now.Format(time.RFC3339Nano), item.ID)
|
||||
if err != nil {
|
||||
a.log.Warn("failed to mark send queue failed", "id", item.ID, "error", err)
|
||||
}
|
||||
event := sendAuditRetry
|
||||
if item.AttemptCount >= item.MaxAttempts {
|
||||
event = sendAuditFailed
|
||||
}
|
||||
a.recordSendAudit(ctx, event, status, sendAuditInputFromQueue(item, sendErr.Error()))
|
||||
}
|
||||
|
||||
func sendRetryDelay(attempt int) time.Duration {
|
||||
if attempt < 1 {
|
||||
attempt = 1
|
||||
}
|
||||
delays := []time.Duration{30 * time.Second, 2 * time.Minute, 10 * time.Minute, time.Hour, 6 * time.Hour}
|
||||
if attempt > len(delays) {
|
||||
return delays[len(delays)-1]
|
||||
}
|
||||
return delays[attempt-1]
|
||||
}
|
||||
|
||||
type sendAuditInput struct {
|
||||
QueueID string
|
||||
UserID string
|
||||
MailboxID string
|
||||
SentMessageID string
|
||||
Source string
|
||||
MailFrom string
|
||||
HeaderFrom string
|
||||
Recipients []string
|
||||
Error string
|
||||
}
|
||||
|
||||
func sendAuditInputFromQueue(item sendQueueItem, errorText string) sendAuditInput {
|
||||
return sendAuditInput{
|
||||
QueueID: item.ID,
|
||||
UserID: item.UserID,
|
||||
MailboxID: item.MailboxID,
|
||||
SentMessageID: item.SentMessageID,
|
||||
Source: item.Source,
|
||||
MailFrom: item.MailFrom,
|
||||
HeaderFrom: item.HeaderFrom,
|
||||
Recipients: item.Recipients,
|
||||
Error: errorText,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) recordSendAudit(ctx context.Context, event, status string, in sendAuditInput) {
|
||||
source := strings.TrimSpace(in.Source)
|
||||
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))
|
||||
if err != nil {
|
||||
a.log.Warn("failed to record send audit", "event", event, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) sendQueueDeliveredMarkerPath(id string) string {
|
||||
safeID := filepath.Base(strings.TrimSpace(id))
|
||||
if safeID == "" || safeID == "." {
|
||||
safeID = "unknown"
|
||||
}
|
||||
return filepath.Join(a.cfg.DataDir, sendQueueDeliveredMarkerDir, safeID+".marker")
|
||||
}
|
||||
|
||||
func (a *App) writeSendQueueDeliveredMarker(id string) error {
|
||||
path := a.sendQueueDeliveredMarkerPath(id)
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := filepath.Join(dir, filepath.Base(path)+"."+newID("tmp"))
|
||||
if err := os.WriteFile(tmp, []byte(a.now().UTC().Format(time.RFC3339Nano)), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) hasSendQueueDeliveredMarker(id string) (bool, error) {
|
||||
_, err := os.Stat(a.sendQueueDeliveredMarkerPath(id))
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
func (a *App) deleteSendQueueDeliveredMarker(id string) {
|
||||
err := os.Remove(a.sendQueueDeliveredMarkerPath(id))
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
a.log.Warn("failed to remove send queue delivered marker", "id", id, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) authorizedSender(ctx context.Context, mb *Mailbox, from string) (string, string, error) {
|
||||
from = normalizeEmail(from)
|
||||
if from == "" {
|
||||
from = normalizeEmail(mb.Address)
|
||||
}
|
||||
if from == normalizeEmail(mb.Address) {
|
||||
return normalizeEmail(mb.Address), mb.DisplayName, nil
|
||||
}
|
||||
var displayName string
|
||||
var enabled int
|
||||
err := a.db.QueryRowContext(ctx, `SELECT display_name,enabled FROM send_as_grants WHERE mailbox_id=? AND address=?`, mb.ID, from).Scan(&displayName, &enabled)
|
||||
if err == nil {
|
||||
if enabled == 0 {
|
||||
return "", "", errSenderNotAuthorized
|
||||
}
|
||||
return from, strings.TrimSpace(displayName), nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return "", "", err
|
||||
}
|
||||
var aliasDestination string
|
||||
err = a.db.QueryRowContext(ctx, `SELECT destination FROM aliases WHERE source=? AND enabled=1`, from).Scan(&aliasDestination)
|
||||
if err == nil {
|
||||
for _, destination := range strings.Split(aliasDestination, ",") {
|
||||
if normalizeEmail(destination) == normalizeEmail(mb.Address) {
|
||||
return from, mb.DisplayName, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", "", errSenderNotAuthorized
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -88,7 +105,7 @@ func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) {
|
||||
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: a.cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: a.cfg.TurnstileSiteKey, PublicHostname: a.cfg.PublicHostname, MailAutoRefresh: a.cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000, ExternalIMAPEnabled: a.cfg.ExternalIMAPEnabled}
|
||||
|
||||
// Include available domains for mailbox creation during registration
|
||||
if a.cfg.OpenRegistration {
|
||||
@@ -165,6 +182,27 @@ 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")
|
||||
@@ -248,28 +286,36 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) systemSettingsSnapshot() SystemSettings {
|
||||
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: 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"),
|
||||
ExternalIMAPEnabled: a.cfg.ExternalIMAPEnabled,
|
||||
ExternalIMAPSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPSecretKey) != "",
|
||||
ExternalIMAPSyncSeconds: a.cfg.ExternalIMAPSyncSeconds,
|
||||
ExternalIMAPAllowPrivateHosts: a.cfg.ExternalIMAPAllowPrivateHosts,
|
||||
ExternalIMAPGmailClientID: a.cfg.ExternalIMAPGmailClientID,
|
||||
ExternalIMAPGmailClientSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPGmailClientSecret) != "",
|
||||
ExternalIMAPOutlookClientID: a.cfg.ExternalIMAPOutlookClientID,
|
||||
ExternalIMAPOutlookClientSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPOutlookClientSecret) != "",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,6 +381,24 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
|
||||
a.cfg.UserMailboxDomainIDs = value
|
||||
case "reservedMailboxPrefixes":
|
||||
a.cfg.ReservedMailboxPrefixes = value
|
||||
case "externalImapEnabled":
|
||||
a.cfg.ExternalIMAPEnabled = value == "true"
|
||||
case "externalImapSecretKey":
|
||||
a.cfg.ExternalIMAPSecretKey = value
|
||||
case "externalImapSyncSeconds":
|
||||
if n, err := strconv.Atoi(value); err == nil && n > 0 {
|
||||
a.cfg.ExternalIMAPSyncSeconds = n
|
||||
}
|
||||
case "externalImapAllowPrivateHosts":
|
||||
a.cfg.ExternalIMAPAllowPrivateHosts = value == "true"
|
||||
case "externalImapGmailClientId":
|
||||
a.cfg.ExternalIMAPGmailClientID = value
|
||||
case "externalImapGmailClientSecret":
|
||||
a.cfg.ExternalIMAPGmailClientSecret = value
|
||||
case "externalImapOutlookClientId":
|
||||
a.cfg.ExternalIMAPOutlookClientID = value
|
||||
case "externalImapOutlookClientSecret":
|
||||
a.cfg.ExternalIMAPOutlookClientSecret = value
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
@@ -342,28 +406,36 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
|
||||
|
||||
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,506 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
netmail "net/mail"
|
||||
"net/textproto"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-sasl"
|
||||
smtpserver "github.com/emersion/go-smtp"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSubmissionMaxRecipients = 200
|
||||
)
|
||||
|
||||
type SubmissionServers struct {
|
||||
Plain *smtpserver.Server
|
||||
TLS *smtpserver.Server
|
||||
}
|
||||
|
||||
func (s *SubmissionServers) Shutdown(ctx context.Context) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
var errs []error
|
||||
if s.Plain != nil {
|
||||
if err := s.Plain.Shutdown(ctx); err != nil && !errors.Is(err, smtpserver.ErrServerClosed) {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
if s.TLS != nil {
|
||||
if err := s.TLS.Shutdown(ctx); err != nil && !errors.Is(err, smtpserver.ErrServerClosed) {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (a *App) NewSubmissionServers(tlsConfig *tls.Config) *SubmissionServers {
|
||||
return &SubmissionServers{
|
||||
Plain: a.newSubmissionServer(a.cfg.SubmissionAddr, tlsConfig),
|
||||
TLS: a.newSubmissionServer(a.cfg.SubmissionTLSAddr, tlsConfig),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) newSubmissionServer(addr string, tlsConfig *tls.Config) *smtpserver.Server {
|
||||
addr = strings.TrimSpace(addr)
|
||||
if addr == "" {
|
||||
return nil
|
||||
}
|
||||
s := smtpserver.NewServer(submissionBackend{app: a})
|
||||
s.Addr = addr
|
||||
s.Domain = a.cfg.PublicHostname
|
||||
s.TLSConfig = tlsConfig
|
||||
s.AllowInsecureAuth = false
|
||||
s.MaxRecipients = defaultSubmissionMaxRecipients
|
||||
s.MaxMessageBytes = int64(a.cfg.SubmissionMaxMessageMB) * 1024 * 1024
|
||||
s.ReadTimeout = smtpSessionTimeout
|
||||
s.WriteTimeout = smtpSessionTimeout
|
||||
s.ErrorLog = log.New(submissionLogWriter{log: a.log}, "smtp/submission ", 0)
|
||||
return s
|
||||
}
|
||||
|
||||
func LoadServerTLSConfig(cfg Config) (*tls.Config, error) {
|
||||
certFile, keyFile := strings.TrimSpace(cfg.TLSCertFile), strings.TrimSpace(cfg.TLSKeyFile)
|
||||
if certFile == "" || keyFile == "" {
|
||||
return nil, errors.New("LANQIN_TLS_CERT_FILE and LANQIN_TLS_KEY_FILE are required when SMTP submission is enabled")
|
||||
}
|
||||
return &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cert, nil
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type submissionLogWriter struct {
|
||||
log slogLogger
|
||||
}
|
||||
|
||||
func (w submissionLogWriter) Write(p []byte) (int, error) {
|
||||
if w.log != nil {
|
||||
w.log.Warn(strings.TrimSpace(string(p)))
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
type slogLogger interface {
|
||||
Warn(msg string, args ...any)
|
||||
}
|
||||
|
||||
type submissionBackend struct {
|
||||
app *App
|
||||
}
|
||||
|
||||
func (b submissionBackend) NewSession(*smtpserver.Conn) (smtpserver.Session, error) {
|
||||
return &submissionSession{app: b.app}, nil
|
||||
}
|
||||
|
||||
type submissionSession struct {
|
||||
app *App
|
||||
user *User
|
||||
mailbox *Mailbox
|
||||
mailFrom string
|
||||
recipients []string
|
||||
}
|
||||
|
||||
func (s *submissionSession) AuthMechanisms() []string {
|
||||
return []string{sasl.Plain}
|
||||
}
|
||||
|
||||
func (s *submissionSession) Auth(mech string) (sasl.Server, error) {
|
||||
if !strings.EqualFold(mech, sasl.Plain) {
|
||||
return nil, smtpserver.ErrAuthUnknownMechanism
|
||||
}
|
||||
return sasl.NewPlainServer(func(identity, username, password string) error {
|
||||
user, mailbox, err := s.app.authenticateSubmission(context.Background(), username, password)
|
||||
if err != nil {
|
||||
return smtpserver.ErrAuthFailed
|
||||
}
|
||||
s.user, s.mailbox = user, mailbox
|
||||
return nil
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (s *submissionSession) Mail(from string, _ *smtpserver.MailOptions) error {
|
||||
if s.user == nil || s.mailbox == nil {
|
||||
return smtpserver.ErrAuthRequired
|
||||
}
|
||||
from = normalizeEmail(from)
|
||||
authorized, _, err := s.app.authorizedSender(context.Background(), s.mailbox, from)
|
||||
if err != nil || from == "" || from != authorized {
|
||||
return smtpError(553, smtpserver.EnhancedCode{5, 7, 1}, "sender must match authenticated mailbox")
|
||||
}
|
||||
s.mailFrom = from
|
||||
s.recipients = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *submissionSession) Rcpt(to string, _ *smtpserver.RcptOptions) error {
|
||||
if s.user == nil || s.mailbox == nil {
|
||||
return smtpserver.ErrAuthRequired
|
||||
}
|
||||
to = normalizeEmail(to)
|
||||
if to == "" || !strings.Contains(to, "@") {
|
||||
return smtpError(501, smtpserver.EnhancedCode{5, 1, 3}, "invalid recipient")
|
||||
}
|
||||
s.recipients = append(s.recipients, to)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *submissionSession) Data(r io.Reader) error {
|
||||
if s.user == nil || s.mailbox == nil {
|
||||
return smtpserver.ErrAuthRequired
|
||||
}
|
||||
if s.mailFrom == "" || len(s.recipients) == 0 {
|
||||
return smtpError(503, smtpserver.EnhancedCode{5, 5, 1}, "missing sender or recipients")
|
||||
}
|
||||
if err := s.app.submitSMTPMessage(context.Background(), s.user, s.mailbox, s.mailFrom, s.recipients, r); err != nil {
|
||||
var smtpErr *smtpserver.SMTPError
|
||||
if errors.As(err, &smtpErr) {
|
||||
return smtpErr
|
||||
}
|
||||
return smtpError(451, smtpserver.EnhancedCode{4, 0, 0}, "message submission failed")
|
||||
}
|
||||
s.Reset()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *submissionSession) Reset() {
|
||||
s.mailFrom = ""
|
||||
s.recipients = nil
|
||||
}
|
||||
|
||||
func (s *submissionSession) Logout() error {
|
||||
s.Reset()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) authenticateSubmission(ctx context.Context, username, password string) (*User, *Mailbox, error) {
|
||||
address := normalizeEmail(username)
|
||||
if address == "" {
|
||||
return nil, nil, errors.New("missing username")
|
||||
}
|
||||
var mb Mailbox
|
||||
var passwordHash, created string
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,user_id,domain_id,local_part,address,display_name,password_hash,quota_mb,status,created_at
|
||||
FROM mailboxes WHERE address=? AND status='active'`, address)
|
||||
if err := row.Scan(&mb.ID, &mb.UserID, &mb.DomainID, &mb.LocalPart, &mb.Address, &mb.DisplayName, &passwordHash, &mb.QuotaMB, &mb.Status, &created); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
mb.CreatedAt = parseTime(created)
|
||||
user, err := a.userByID(ctx, mb.UserID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if user.Disabled {
|
||||
return nil, nil, errors.New("user disabled")
|
||||
}
|
||||
if !userHasPermission(user, PermissionMailSend) {
|
||||
return nil, nil, errors.New("send permission required")
|
||||
}
|
||||
return user, &mb, nil
|
||||
}
|
||||
|
||||
func (a *App) submitSMTPMessage(ctx context.Context, user *User, mb *Mailbox, mailFrom string, recipients []string, r io.Reader) error {
|
||||
if err := a.recordSMTPRate(ctx, user, mb); err != nil {
|
||||
if errors.Is(err, errSMTPRateLimited) {
|
||||
return smtpError(452, smtpserver.EnhancedCode{4, 7, 0}, err.Error())
|
||||
}
|
||||
return err
|
||||
}
|
||||
raw, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
prepared, msg, attachments, err := a.prepareSubmittedMessage(ctx, raw, mb, mailFrom, recipients)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg.MailboxID = mb.ID
|
||||
sentID, insertedSent, err := a.insertSentMessageOnce(ctx, msg, attachments)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if insertedSent {
|
||||
if err := a.rewriteMessageMaildir(ctx, sentID); err != nil {
|
||||
a.deleteMessage(ctx, sentID)
|
||||
if sentFolderID, ferr := a.ensureFolder(ctx, mb.ID, "Sent"); ferr == nil {
|
||||
a.deleteSentDedupeKey(ctx, mb.ID, sentFolderID, msg.MessageID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
a.recordSendAudit(ctx, sendAuditAccepted, sendQueueStatusQueued, sendAuditInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, Source: sendSourceSubmission, MailFrom: mailFrom, HeaderFrom: msg.From, Recipients: recipients})
|
||||
if sentID != "" {
|
||||
if _, err := a.enqueueSend(ctx, sendQueueInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, MessageID: msg.MessageID, Source: sendSourceSubmission, MailFrom: mailFrom, HeaderFrom: msg.From, Recipients: recipients, MIMEBytes: prepared, Now: a.now().UTC()}); err != nil {
|
||||
if insertedSent {
|
||||
a.deleteMessage(ctx, sentID)
|
||||
if sentFolderID, ferr := a.ensureFolder(ctx, mb.ID, "Sent"); ferr == nil {
|
||||
a.deleteSentDedupeKey(ctx, mb.ID, sentFolderID, msg.MessageID)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) prepareSubmittedMessage(ctx context.Context, raw []byte, mb *Mailbox, mailFrom string, recipients []string) ([]byte, storedMessage, []AttachmentInput, error) {
|
||||
header, body, err := readMessageHeader(raw)
|
||||
if err != nil {
|
||||
return nil, storedMessage{}, nil, smtpError(554, smtpserver.EnhancedCode{5, 6, 0}, "invalid message")
|
||||
}
|
||||
fromAddress, fromName, ok := singleHeaderAddress(header.Get("From"))
|
||||
if !ok || fromAddress == "" {
|
||||
return nil, storedMessage{}, nil, smtpError(550, smtpserver.EnhancedCode{5, 7, 1}, "From header must contain exactly one address")
|
||||
}
|
||||
authAddress, fromName, err := a.authorizedSender(ctx, mb, fromAddress)
|
||||
if err != nil || normalizeEmail(mailFrom) != authAddress || normalizeEmail(fromAddress) != authAddress {
|
||||
return nil, storedMessage{}, nil, smtpError(553, smtpserver.EnhancedCode{5, 7, 1}, "sender must match authenticated mailbox")
|
||||
}
|
||||
now := a.now().UTC()
|
||||
messageID := strings.TrimSpace(header.Get("Message-Id"))
|
||||
if messageID == "" {
|
||||
messageID = fmt.Sprintf("<%s@%s>", newID("msg"), domainPart(authAddress))
|
||||
header.Set("Message-ID", messageID)
|
||||
} else {
|
||||
header.Set("Message-ID", messageID)
|
||||
}
|
||||
sentAt := parseMailDate(header.Get("Date"))
|
||||
if sentAt.IsZero() {
|
||||
sentAt = now
|
||||
header.Set("Date", sentAt.Format(time.RFC1123Z))
|
||||
}
|
||||
header.Del("Bcc")
|
||||
prepared := serializeMessage(header, body)
|
||||
msg, attachments, err := a.parseMaildirMessage(prepared, authAddress)
|
||||
if err != nil {
|
||||
return nil, storedMessage{}, nil, smtpError(554, smtpserver.EnhancedCode{5, 6, 0}, "invalid message")
|
||||
}
|
||||
if msg.MessageID == "" {
|
||||
msg.MessageID = messageID
|
||||
}
|
||||
if msg.SentAt.IsZero() {
|
||||
msg.SentAt = sentAt
|
||||
}
|
||||
if msg.ReceivedAt.IsZero() {
|
||||
msg.ReceivedAt = sentAt
|
||||
}
|
||||
msg.From = authAddress
|
||||
msg.FromName = fromName
|
||||
msg.To = dedupeEmails(msg.To)
|
||||
msg.CC = dedupeEmails(msg.CC)
|
||||
msg.BCC = deduceBCCRecipients(recipients, addressList(header.Get("To")), addressList(header.Get("Cc")))
|
||||
msg.IsRead = true
|
||||
msg.RawPath = ""
|
||||
if msg.Subject == "" {
|
||||
msg.Subject = "(no subject)"
|
||||
}
|
||||
if msg.Snippet == "" {
|
||||
msg.Snippet = snippetFrom(msg.BodyText, msg.BodyHTML)
|
||||
}
|
||||
return prepared, msg, attachments, nil
|
||||
}
|
||||
|
||||
func (a *App) insertSentMessageOnce(ctx context.Context, msg storedMessage, attachments []AttachmentInput) (string, bool, error) {
|
||||
sentFolderID, err := a.ensureFolder(ctx, msg.MailboxID, "Sent")
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
msg.FolderID = sentFolderID
|
||||
if msg.MessageUID == "" {
|
||||
msg.MessageUID = newID("uid")
|
||||
}
|
||||
tx, err := a.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
committed := false
|
||||
messageIDForCleanup := ""
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback()
|
||||
if messageIDForCleanup != "" {
|
||||
a.deleteMessageFiles(ctx, messageIDForCleanup)
|
||||
}
|
||||
}
|
||||
}()
|
||||
if msg.MessageID != "" {
|
||||
existing, err := sentMessageIDByMessageID(ctx, tx, msg.MailboxID, sentFolderID, msg.MessageID)
|
||||
if err == nil {
|
||||
if err := a.insertSentDedupeKeyWithDB(ctx, tx, msg.MailboxID, sentFolderID, msg.MessageID); err != nil && !errors.Is(err, errSentDedupeExists) {
|
||||
return "", false, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
committed = true
|
||||
return existing, false, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return "", false, err
|
||||
}
|
||||
if err := a.insertSentDedupeKeyWithDB(ctx, tx, msg.MailboxID, sentFolderID, msg.MessageID); err != nil {
|
||||
if errors.Is(err, errSentDedupeExists) {
|
||||
existing, qerr := sentMessageIDByMessageID(ctx, tx, msg.MailboxID, sentFolderID, msg.MessageID)
|
||||
if qerr == nil {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
committed = true
|
||||
return existing, false, nil
|
||||
}
|
||||
if errors.Is(qerr, sql.ErrNoRows) {
|
||||
return "", false, fmt.Errorf("sent dedupe key exists without sent message: %w", errSentDedupeExists)
|
||||
}
|
||||
return "", false, qerr
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
}
|
||||
id, err := a.insertMessageWithDB(ctx, tx, msg, attachments)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
messageIDForCleanup = id
|
||||
if err := tx.Commit(); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
committed = true
|
||||
return id, true, nil
|
||||
}
|
||||
|
||||
var errSentDedupeExists = errors.New("sent message already exists")
|
||||
|
||||
func (a *App) insertSentDedupeKey(ctx context.Context, mailboxID, folderID, messageID string) error {
|
||||
return a.insertSentDedupeKeyWithDB(ctx, a.db, mailboxID, folderID, messageID)
|
||||
}
|
||||
|
||||
func (a *App) insertSentDedupeKeyWithDB(ctx context.Context, db dbExecutor, mailboxID, folderID, messageID string) error {
|
||||
if strings.TrimSpace(messageID) == "" {
|
||||
return nil
|
||||
}
|
||||
res, err := db.ExecContext(ctx, `INSERT OR IGNORE INTO sent_message_dedupe_keys(mailbox_id,folder_id,message_id,created_at) VALUES(?,?,?,?)`, mailboxID, folderID, messageID, a.now().UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows, err := res.RowsAffected(); err == nil && rows == 0 {
|
||||
return errSentDedupeExists
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sentMessageIDByMessageID(ctx context.Context, db dbQueryer, mailboxID, folderID, messageID string) (string, error) {
|
||||
var existing string
|
||||
err := db.QueryRowContext(ctx, `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? AND message_id=? AND message_id <> '' LIMIT 1`, mailboxID, folderID, messageID).Scan(&existing)
|
||||
return existing, err
|
||||
}
|
||||
|
||||
func (a *App) deleteSentDedupeKey(ctx context.Context, mailboxID, folderID, messageID string) {
|
||||
if strings.TrimSpace(messageID) == "" {
|
||||
return
|
||||
}
|
||||
_, _ = a.db.ExecContext(ctx, `DELETE FROM sent_message_dedupe_keys WHERE mailbox_id=? AND folder_id=? AND message_id=?`, mailboxID, folderID, messageID)
|
||||
}
|
||||
|
||||
func readMessageHeader(raw []byte) (textproto.MIMEHeader, []byte, error) {
|
||||
msg, err := netmail.ReadMessage(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
body, err := io.ReadAll(msg.Body)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return textproto.MIMEHeader(msg.Header), body, nil
|
||||
}
|
||||
|
||||
func serializeMessage(header textproto.MIMEHeader, body []byte) []byte {
|
||||
var buf bytes.Buffer
|
||||
keys := make([]string, 0, len(header))
|
||||
for key := range header {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.SliceStable(keys, func(i, j int) bool {
|
||||
return textproto.CanonicalMIMEHeaderKey(keys[i]) < textproto.CanonicalMIMEHeaderKey(keys[j])
|
||||
})
|
||||
for _, key := range keys {
|
||||
values := header[key]
|
||||
canonical := textproto.CanonicalMIMEHeaderKey(key)
|
||||
for _, value := range values {
|
||||
fmt.Fprintf(&buf, "%s: %s\r\n", canonical, strings.ReplaceAll(strings.ReplaceAll(value, "\r", ""), "\n", " "))
|
||||
}
|
||||
}
|
||||
buf.WriteString("\r\n")
|
||||
buf.Write(body)
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func singleHeaderAddress(value string) (string, string, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", "", false
|
||||
}
|
||||
items, err := netmail.ParseAddressList(value)
|
||||
if err != nil || len(items) != 1 {
|
||||
decoded := decodeMIMEHeader(value)
|
||||
items, err = netmail.ParseAddressList(decoded)
|
||||
if err != nil || len(items) != 1 {
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
item := items[0]
|
||||
return normalizeEmail(item.Address), strings.TrimSpace(decodeMIMEHeader(item.Name)), true
|
||||
}
|
||||
|
||||
func deduceBCCRecipients(envelope, to, cc []string) []string {
|
||||
visible := map[string]bool{}
|
||||
for _, item := range append(to, cc...) {
|
||||
if email := normalizeEmail(item); email != "" {
|
||||
visible[email] = true
|
||||
}
|
||||
}
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, item := range envelope {
|
||||
email := normalizeEmail(item)
|
||||
if email == "" || visible[email] || seen[email] {
|
||||
continue
|
||||
}
|
||||
seen[email] = true
|
||||
out = append(out, email)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func domainPart(email string) string {
|
||||
parts := strings.SplitN(normalizeEmail(email), "@", 2)
|
||||
if len(parts) != 2 || parts[1] == "" {
|
||||
return "lanqin.local"
|
||||
}
|
||||
return parts[1]
|
||||
}
|
||||
|
||||
func smtpError(code int, enhanced smtpserver.EnhancedCode, message string) *smtpserver.SMTPError {
|
||||
return &smtpserver.SMTPError{Code: code, EnhancedCode: enhanced, Message: message}
|
||||
}
|
||||
@@ -135,6 +135,9 @@ func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, e
|
||||
u.Disabled = intBool(disabled)
|
||||
u.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
u.CreatedAt = parseTime(created)
|
||||
if err := a.attachUserAuthorization(ctx, &u); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &u, secret, nil
|
||||
}
|
||||
|
||||
|
||||
+149
-41
@@ -3,13 +3,18 @@ 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"`
|
||||
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type AdminUser struct {
|
||||
@@ -52,11 +57,15 @@ type Alias struct {
|
||||
}
|
||||
|
||||
type MailFolder struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
UnreadCount int `json:"unreadCount"`
|
||||
TotalCount int `json:"totalCount"`
|
||||
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"`
|
||||
UIDNext int64 `json:"uidNext"`
|
||||
HighestModSeq int64 `json:"highestModseq"`
|
||||
}
|
||||
|
||||
type MailLabel struct {
|
||||
@@ -68,32 +77,46 @@ 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"`
|
||||
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"`
|
||||
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 {
|
||||
AuthenticationResults string `json:"authenticationResults"`
|
||||
ReceivedSPF string `json:"receivedSpf"`
|
||||
SPF string `json:"spf"`
|
||||
DKIM string `json:"dkim"`
|
||||
DMARC string `json:"dmarc"`
|
||||
}
|
||||
|
||||
type Attachment struct {
|
||||
@@ -163,9 +186,11 @@ type MailRule struct {
|
||||
}
|
||||
|
||||
type MailRuleCondition struct {
|
||||
Field string `json:"field"`
|
||||
Operator string `json:"operator"`
|
||||
Value string `json:"value"`
|
||||
Field string `json:"field,omitempty"`
|
||||
Operator string `json:"operator,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
MatchMode string `json:"matchMode,omitempty"`
|
||||
Conditions []MailRuleCondition `json:"conditions,omitempty"`
|
||||
}
|
||||
|
||||
type MailRuleAction struct {
|
||||
@@ -188,7 +213,10 @@ type MailStats struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -199,3 +227,83 @@ type MailStatsFolderCount struct {
|
||||
Unread int64 `json:"unread"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
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"`
|
||||
SentMessageID string `json:"sentMessageId"`
|
||||
MessageID string `json:"messageId"`
|
||||
Subject string `json:"subject"`
|
||||
Source string `json:"source"`
|
||||
MailFrom string `json:"mailFrom"`
|
||||
HeaderFrom string `json:"headerFrom"`
|
||||
Recipients []string `json:"recipients"`
|
||||
Status string `json:"status"`
|
||||
AttemptCount int `json:"attemptCount"`
|
||||
MaxAttempts int `json:"maxAttempts"`
|
||||
NextAttemptAt time.Time `json:"nextAttemptAt"`
|
||||
LastError string `json:"lastError"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeliveredAt *time.Time `json:"deliveredAt,omitempty"`
|
||||
}
|
||||
|
||||
type SendAuditEvent struct {
|
||||
ID string `json:"id"`
|
||||
QueueID string `json:"queueId"`
|
||||
MailboxID string `json:"mailboxId"`
|
||||
MailboxAddress string `json:"mailboxAddress,omitempty"`
|
||||
SentMessageID string `json:"sentMessageId"`
|
||||
MessageID string `json:"messageId,omitempty"`
|
||||
Source string `json:"source"`
|
||||
Event string `json:"event"`
|
||||
Status string `json:"status"`
|
||||
MailFrom string `json:"mailFrom"`
|
||||
HeaderFrom string `json:"headerFrom"`
|
||||
Recipients []string `json:"recipients"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
@@ -22,7 +22,21 @@ type HTMLPolicy struct{ policy *bluemonday.Policy }
|
||||
|
||||
func NewHTMLPolicy() *HTMLPolicy {
|
||||
p := bluemonday.UGCPolicy()
|
||||
p.AllowAttrs("style").OnElements("p", "span", "div", "table", "td", "th")
|
||||
p.AllowElements("html", "head", "body", "center", "font")
|
||||
p.AllowAttrs("style").Globally()
|
||||
p.AllowAttrs("class").Matching(bluemonday.SpaceSeparatedTokens).Globally()
|
||||
p.AllowAttrs("align", "valign").Matching(bluemonday.Paragraph).Globally()
|
||||
p.AllowAttrs("width", "height").Matching(bluemonday.NumberOrPercent).Globally()
|
||||
p.AllowAttrs("bgcolor", "color").Matching(regexp.MustCompile(`(?i)^#[0-9a-f]{3,8}$|^[a-z][a-z0-9 -]{0,31}$`)).Globally()
|
||||
p.AllowAttrs("border", "cellpadding", "cellspacing").Matching(bluemonday.Number).OnElements("table")
|
||||
p.AllowStyles(
|
||||
"background", "background-color", "background-image", "border", "border-collapse", "border-color",
|
||||
"border-radius", "border-spacing", "border-style", "border-width", "box-shadow", "color", "display",
|
||||
"font", "font-family", "font-size", "font-style", "font-weight", "height", "letter-spacing",
|
||||
"line-height", "margin", "margin-bottom", "margin-left", "margin-right", "margin-top", "max-width",
|
||||
"min-width", "opacity", "padding", "padding-bottom", "padding-left", "padding-right", "padding-top",
|
||||
"text-align", "text-decoration", "text-transform", "vertical-align", "white-space", "width",
|
||||
).MatchingHandler(safeEmailCSSValue).Globally()
|
||||
return &HTMLPolicy{policy: p}
|
||||
}
|
||||
|
||||
@@ -30,7 +44,68 @@ func (p *HTMLPolicy) Sanitize(s string) string {
|
||||
if p == nil || p.policy == nil {
|
||||
return s
|
||||
}
|
||||
return p.policy.Sanitize(s)
|
||||
styles, withoutStyles := extractSafeEmailStyles(s)
|
||||
clean := p.policy.Sanitize(withoutStyles)
|
||||
if len(styles) == 0 {
|
||||
return clean
|
||||
}
|
||||
return strings.Join(styles, "") + clean
|
||||
}
|
||||
|
||||
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{}
|
||||
withoutStyles := emailStyleTagRe.ReplaceAllStringFunc(value, func(tag string) string {
|
||||
match := emailStyleTagRe.FindStringSubmatch(tag)
|
||||
if len(match) != 3 {
|
||||
return ""
|
||||
}
|
||||
attrs, css := match[1], strings.TrimSpace(match[2])
|
||||
if !safeEmailStyleAttrs(attrs) || !safeEmailCSSBlock(css) {
|
||||
return ""
|
||||
}
|
||||
styles = append(styles, `<style type="text/css">`+css+`</style>`)
|
||||
return ""
|
||||
})
|
||||
return styles, withoutStyles
|
||||
}
|
||||
|
||||
func safeEmailStyleAttrs(attrs string) bool {
|
||||
attrs = strings.ToLower(strings.TrimSpace(attrs))
|
||||
if attrs == "" {
|
||||
return true
|
||||
}
|
||||
return regexp.MustCompile(`^\s*type\s*=\s*["']?text/css["']?\s*$`).MatchString(attrs)
|
||||
}
|
||||
|
||||
func safeEmailCSSBlock(value string) bool {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" || len(value) > 50000 {
|
||||
return false
|
||||
}
|
||||
unsafe := []string{"expression", "javascript:", "vbscript:", "data:", "behavior", "-moz-binding", "@import", "</", "url("}
|
||||
for _, token := range unsafe {
|
||||
if strings.Contains(value, token) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func safeEmailCSSValue(value string) bool {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" || len(value) > 512 {
|
||||
return false
|
||||
}
|
||||
unsafe := []string{"expression", "javascript:", "vbscript:", "data:", "behavior", "-moz-binding", "@import", "</", "url("}
|
||||
for _, token := range unsafe {
|
||||
if strings.Contains(value, token) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func newID(prefix string) string {
|
||||
@@ -167,6 +242,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 {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React from "react"
|
||||
import { Navigate } from "react-router-dom"
|
||||
import { useMe } from "@/hooks/use-me"
|
||||
import { hasAdminAccess } from "@/lib/permissions"
|
||||
|
||||
export function AdminOnly({ children }: { children: React.ReactNode }) {
|
||||
const me = useMe()
|
||||
if (me.isLoading) return null
|
||||
if (!me.data?.user) return <Navigate to="/login" replace />
|
||||
if (me.data.user.role !== "admin") return <Navigate to="/" replace />
|
||||
if (!hasAdminAccess(me.data.user)) return <Navigate to="/" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import * as React from "react"
|
||||
import { Outlet, Link, useLocation } from "react-router-dom"
|
||||
import { BarChart3, Copy, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, Users } from "lucide-react"
|
||||
import { BarChart3, Copy, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, ShieldCheck, Users } 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 { hasAnyPermission } from "@/lib/permissions"
|
||||
import type { PermissionKey } from "@/lib/api-types"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -24,14 +26,15 @@ import {
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
const adminSections = [
|
||||
{ key: "overview", label: "概览", icon: <BarChart3 /> },
|
||||
{ key: "users", label: "用户", icon: <Users /> },
|
||||
{ key: "domains", label: "域名", icon: <Globe2 /> },
|
||||
{ key: "mailboxes", label: "邮箱账号", icon: <Mailbox /> },
|
||||
{ key: "aliases", label: "别名转发", icon: <Copy /> },
|
||||
{ key: "messages", label: "全部邮件", icon: <Inbox /> },
|
||||
{ key: "settings", label: "系统设置", icon: <Settings /> },
|
||||
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: "messages", label: "全部邮件", icon: <Inbox />, permissions: ["admin.messages.view"] },
|
||||
{ key: "settings", label: "系统设置", icon: <Settings />, permissions: ["admin.settings.view", "admin.templates.view"] },
|
||||
]
|
||||
|
||||
export function ProtectedLayout() {
|
||||
@@ -52,6 +55,7 @@ function ProtectedContent() {
|
||||
const isProfileRoute = location.pathname.startsWith("/profile")
|
||||
const isAdminRoute = location.pathname.startsWith("/admin")
|
||||
const adminSection = new URLSearchParams(location.search).get("section") || "overview"
|
||||
const visibleAdminSections = adminSections.filter((item) => hasAnyPermission(user, item.permissions))
|
||||
|
||||
if (isMailRoute || isProfileRoute) {
|
||||
return <Outlet />
|
||||
@@ -77,11 +81,11 @@ function ProtectedContent() {
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{user.role === "admin" && isAdminRoute && (
|
||||
{isAdminRoute && visibleAdminSections.length > 0 && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<AdminSectionItems activeSection={adminSection} />
|
||||
<AdminSectionItems activeSection={adminSection} sections={visibleAdminSections} />
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
@@ -102,7 +106,7 @@ function ProtectedContent() {
|
||||
<span className="truncate text-xs text-muted-foreground">{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>
|
||||
@@ -121,7 +125,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 ? adminSections.find((item) => item.key === adminSection)?.label || "系统管理" : "LanQin Email"}
|
||||
{isAdminRoute ? visibleAdminSections.find((item) => item.key === adminSection)?.label || "系统管理" : "LanQin Email"}
|
||||
</div>
|
||||
</div>
|
||||
<Outlet />
|
||||
@@ -131,13 +135,13 @@ function ProtectedContent() {
|
||||
)
|
||||
}
|
||||
|
||||
function AdminSectionItems({ activeSection }: { activeSection: string }) {
|
||||
function AdminSectionItems({ activeSection, sections }: { activeSection: string; sections: typeof adminSections }) {
|
||||
const { isMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
function closeMobile() {
|
||||
if (isMobile) setOpenMobile(false)
|
||||
}
|
||||
return adminSections.map((item) => (
|
||||
return sections.map((item) => (
|
||||
<SidebarMenuItem key={item.key}>
|
||||
<SidebarMenuButton asChild isActive={activeSection === item.key} tooltip={item.label}>
|
||||
<Link to={`/admin?section=${item.key}`} onClick={closeMobile}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -162,3 +162,29 @@ vue-devtools-anchor,
|
||||
[class*="vue-devtools"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Label badge: hide delete button by default, show on hover (desktop) */
|
||||
.label-badge-delete {
|
||||
visibility: hidden;
|
||||
width: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
transition: visibility 0.1s, width 0.1s, padding 0.1s, opacity 0.15s;
|
||||
}
|
||||
.label-badge:hover .label-badge-delete,
|
||||
.label-badge:focus-within .label-badge-delete {
|
||||
visibility: visible;
|
||||
width: 1rem;
|
||||
padding: 0;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Touch devices: always show the delete button inside label badges */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.label-badge-delete {
|
||||
visibility: visible;
|
||||
width: 1rem;
|
||||
padding: 0;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,70 @@
|
||||
export type User = { id: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; twoFactorEnabled: boolean; createdAt: string }
|
||||
export type PermissionKey =
|
||||
| "mail.access"
|
||||
| "mail.messages.read"
|
||||
| "mail.messages.send"
|
||||
| "mail.messages.drafts"
|
||||
| "mail.messages.schedule"
|
||||
| "mail.messages.organize"
|
||||
| "mail.labels.manage"
|
||||
| "mail.attachments.download"
|
||||
| "mail.contacts.manage"
|
||||
| "mail.signatures.manage"
|
||||
| "mail.rules.manage"
|
||||
| "mail.blocked_senders.manage"
|
||||
| "mail.stats.view"
|
||||
| "mail.mailboxes.apply"
|
||||
| "admin.overview.view"
|
||||
| "admin.users.view"
|
||||
| "admin.users.create"
|
||||
| "admin.users.update"
|
||||
| "admin.users.delete"
|
||||
| "admin.users.reset_password"
|
||||
| "admin.permission_groups.view"
|
||||
| "admin.permission_groups.create"
|
||||
| "admin.permission_groups.update"
|
||||
| "admin.permission_groups.delete"
|
||||
| "admin.domains.view"
|
||||
| "admin.domains.create"
|
||||
| "admin.domains.update"
|
||||
| "admin.domains.delete"
|
||||
| "admin.dns.view"
|
||||
| "admin.dns.check"
|
||||
| "admin.mailboxes.view"
|
||||
| "admin.mailboxes.create"
|
||||
| "admin.mailboxes.update"
|
||||
| "admin.mailboxes.delete"
|
||||
| "admin.aliases.view"
|
||||
| "admin.aliases.create"
|
||||
| "admin.aliases.update"
|
||||
| "admin.aliases.delete"
|
||||
| "admin.messages.view"
|
||||
| "admin.messages.read"
|
||||
| "admin.messages.attachments"
|
||||
| "admin.settings.view"
|
||||
| "admin.settings.update"
|
||||
| "admin.settings.test_smtp"
|
||||
| "admin.templates.view"
|
||||
| "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 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 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 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 }
|
||||
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 MailMessage = {
|
||||
id: string; mailboxId?: string; mailboxAddress?: string; ownerEmail?: string; recipientAddress?: string; folderId: string; folder: string; messageUid: string; 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[]
|
||||
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[]
|
||||
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[] }> }
|
||||
@@ -18,15 +73,82 @@ export type SendPayload = { mailboxId?: string; to: string[]; cc: string[]; bcc:
|
||||
export type DraftPayload = Omit<SendPayload, "attachments"> & { attachments?: SendPayload["attachments"] }
|
||||
export type ScheduleSendPayload = SendPayload & { draftId?: string; sendAt: string }
|
||||
export type ScheduledSend = { id: string; mailboxId: string; draftId?: string; subject: string; to: string[]; snippet: string; sendAt: string; status: "pending" | "sending" | "sent" | "failed" | "cancelled"; error?: string; createdAt: string; updatedAt: string; sentAt?: string }
|
||||
export type SendQueueStatus = "queued" | "sending" | "delivered" | "failed" | "canceled"
|
||||
export type SendQueueItem = {
|
||||
id: string
|
||||
mailboxId: string
|
||||
sentMessageId?: string
|
||||
messageId?: string
|
||||
mailFrom?: string
|
||||
headerFrom?: string
|
||||
subject: string
|
||||
recipients: string[]
|
||||
source: string
|
||||
status: SendQueueStatus
|
||||
attemptCount: number
|
||||
maxAttempts: number
|
||||
nextAttemptAt?: string
|
||||
lastError?: string
|
||||
error?: string
|
||||
failureReason?: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
deliveredAt?: string
|
||||
}
|
||||
export type SendQueueAuditEvent = {
|
||||
id: string
|
||||
queueId?: string
|
||||
mailboxId?: string
|
||||
mailboxAddress?: string
|
||||
sentMessageId?: string
|
||||
messageId?: string
|
||||
source?: string
|
||||
status?: SendQueueStatus
|
||||
event?: string
|
||||
eventType?: string
|
||||
mailFrom?: string
|
||||
headerFrom?: string
|
||||
recipients?: string[]
|
||||
message?: string
|
||||
error?: string
|
||||
attemptCount?: number
|
||||
createdAt: string
|
||||
}
|
||||
export type Contact = { id: string; name: string; email: string; note: string; createdAt: string }
|
||||
export type MailSignature = { id: string; mailboxId: string; name: string; content: string; isDefault: boolean; createdAt: string; updatedAt: string }
|
||||
export type MailRuleCondition = { field: "from" | "to" | "subject" | "body"; operator: "contains" | "not-contains" | "equals" | "not-equals" | "starts-with" | "ends-with"; value: string }
|
||||
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 BlockedSender = { id: string; mailboxId: string; email: string; reason: string; createdAt: string }
|
||||
export type MailStats = { totalMessages: number; unreadMessages: number; starredMessages: number; attachmentCount: number; storageBytes: number; byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] }
|
||||
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 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 }
|
||||
export type MaildirSyncRun = { startedAt: string; finishedAt?: string; durationMs: number; status: "running" | "success" | "partial" | "error"; error?: string; counts: MaildirSyncCounts }
|
||||
export type MaildirSyncHealth = {
|
||||
configured: boolean
|
||||
enabled: boolean
|
||||
root: string
|
||||
scanSeconds: number
|
||||
workerStarted: boolean
|
||||
running: boolean
|
||||
lastRun?: MaildirSyncRun
|
||||
lastError?: string
|
||||
nextRunAt?: string
|
||||
recentErrors: string[]
|
||||
summary: MaildirSyncCounts
|
||||
}
|
||||
export type SystemSettings = {
|
||||
publicHostname: string
|
||||
publicBaseUrl: string
|
||||
@@ -50,10 +172,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 PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; publicHostname: string; mailAutoRefresh: boolean; mailRefreshMs: number; externalImapEnabled: boolean; mailboxDomains?: PublicDomain[] }
|
||||
export type LoginPayload = { email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string }
|
||||
export type LoginResponse = { user?: User; twoFactorRequired?: boolean; challengeToken?: string }
|
||||
export type RegisterPayload = { email: string; displayName: string; password: string; turnstileToken?: string; domainId?: string; localPart?: string }
|
||||
|
||||
+62
-3
@@ -1,4 +1,4 @@
|
||||
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, MailboxApplyOptions, MailTemplate, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload } from "./api-types"
|
||||
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, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits } from "./api-types"
|
||||
export * from "./api-types"
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15_000
|
||||
@@ -61,10 +61,24 @@ export const api = {
|
||||
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) }),
|
||||
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"),
|
||||
createUser: (payload: { email: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updateUser: (id: string, payload: { displayName: string; role: "admin" | "user"; disabled: boolean }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
permissionGroups: () => request<ListResponse<PermissionGroup> & { catalog: PermissionInfo[] }>("/api/admin/permission-groups"),
|
||||
createPermissionGroup: (payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request<PermissionGroup>("/api/admin/permission-groups", { method: "POST", body: JSON.stringify(payload) }),
|
||||
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) }),
|
||||
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"),
|
||||
@@ -89,7 +103,19 @@ export const api = {
|
||||
return request<ListResponse<MailMessage>>(`/api/admin/messages${suffix ? `?${suffix}` : ""}`)
|
||||
},
|
||||
adminMessage: (id: string) => request<MailMessage>(`/api/admin/messages/${id}`),
|
||||
adminSendAudit: (params: { mailboxId?: string; messageId?: string; event?: string; from?: string; to?: string; cursor?: string } = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
if (params.mailboxId) query.set("mailboxId", params.mailboxId)
|
||||
if (params.messageId) query.set("messageId", params.messageId)
|
||||
if (params.event) query.set("event", params.event)
|
||||
if (params.from) query.set("from", params.from)
|
||||
if (params.to) query.set("to", params.to)
|
||||
if (params.cursor) query.set("cursor", params.cursor)
|
||||
const suffix = query.toString()
|
||||
return request<ListResponse<SendQueueAuditEvent>>(`/api/admin/send-audit${suffix ? `?${suffix}` : ""}`)
|
||||
},
|
||||
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) }),
|
||||
testSmtp: (to: string) => request<{ ok: boolean }>("/api/admin/settings/test-smtp", { method: "POST", body: JSON.stringify({ to }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||
mailTemplates: () => request<ListResponse<MailTemplate>>("/api/admin/mail-templates"),
|
||||
@@ -98,12 +124,30 @@ 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 })
|
||||
if (mailboxId) params.set("mailboxId", mailboxId)
|
||||
@@ -124,6 +168,21 @@ export const api = {
|
||||
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; 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}` : ""}`)
|
||||
},
|
||||
sendQueueAudit: (id: string) => request<ListResponse<SendQueueAuditEvent>>(`/api/mail/send-queue/${id}/audit`),
|
||||
retrySendQueue: (id: string) => request<SendQueueItem>(`/api/mail/send-queue/${id}/retry`, { method: "POST", timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||
cancelSendQueue: (id: string) => request<SendQueueItem>(`/api/mail/send-queue/${id}`, { method: "DELETE" }),
|
||||
saveDraft: (payload: DraftPayload, id?: string) => request<MailMessage>(id ? `/api/mail/drafts/${id}` : "/api/mail/drafts", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||
deleteDraft: (id: string) => request<{ ok: boolean }>(`/api/mail/drafts/${id}`, { method: "DELETE" }),
|
||||
markRead: (id: string, read: boolean) => request<{ ok: boolean }>(`/api/mail/messages/${id}/mark-read`, { method: "POST", body: JSON.stringify({ read }) }),
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { PermissionKey, User } from "@/lib/api-types"
|
||||
|
||||
export const MAIL_PERMISSIONS: PermissionKey[] = [
|
||||
"mail.access",
|
||||
"mail.messages.read",
|
||||
"mail.messages.send",
|
||||
"mail.messages.drafts",
|
||||
"mail.messages.schedule",
|
||||
"mail.messages.organize",
|
||||
"mail.labels.manage",
|
||||
"mail.attachments.download",
|
||||
"mail.contacts.manage",
|
||||
"mail.signatures.manage",
|
||||
"mail.rules.manage",
|
||||
"mail.blocked_senders.manage",
|
||||
"mail.stats.view",
|
||||
"mail.mailboxes.apply",
|
||||
]
|
||||
|
||||
export const ADMIN_PERMISSIONS: PermissionKey[] = [
|
||||
"admin.overview.view",
|
||||
"admin.users.view",
|
||||
"admin.users.create",
|
||||
"admin.users.update",
|
||||
"admin.users.delete",
|
||||
"admin.users.reset_password",
|
||||
"admin.permission_groups.view",
|
||||
"admin.permission_groups.create",
|
||||
"admin.permission_groups.update",
|
||||
"admin.permission_groups.delete",
|
||||
"admin.domains.view",
|
||||
"admin.domains.create",
|
||||
"admin.domains.update",
|
||||
"admin.domains.delete",
|
||||
"admin.dns.view",
|
||||
"admin.dns.check",
|
||||
"admin.mailboxes.view",
|
||||
"admin.mailboxes.create",
|
||||
"admin.mailboxes.update",
|
||||
"admin.mailboxes.delete",
|
||||
"admin.aliases.view",
|
||||
"admin.aliases.create",
|
||||
"admin.aliases.update",
|
||||
"admin.aliases.delete",
|
||||
"admin.messages.view",
|
||||
"admin.messages.read",
|
||||
"admin.messages.attachments",
|
||||
"admin.settings.view",
|
||||
"admin.settings.update",
|
||||
"admin.settings.test_smtp",
|
||||
"admin.templates.view",
|
||||
"admin.templates.update",
|
||||
"admin.templates.reset",
|
||||
]
|
||||
|
||||
export function hasPermission(user: User | undefined | null, permission: PermissionKey) {
|
||||
if (!user) return false
|
||||
if (user.role === "admin") return true
|
||||
return (user.permissions || []).includes(permission)
|
||||
}
|
||||
|
||||
export function hasAnyPermission(user: User | undefined | null, permissions: PermissionKey[]) {
|
||||
if (!user) return false
|
||||
if (user.role === "admin") return true
|
||||
return permissions.some((permission) => (user.permissions || []).includes(permission))
|
||||
}
|
||||
|
||||
export function hasAdminAccess(user: User | undefined | null) {
|
||||
return hasAnyPermission(user, ADMIN_PERMISSIONS)
|
||||
}
|
||||
|
||||
export function hasMailAccess(user: User | undefined | null) {
|
||||
return hasPermission(user, "mail.access")
|
||||
}
|
||||
@@ -84,3 +84,27 @@ export function decodeMimeHeader(value: string): string {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export interface LabelColorStyle {
|
||||
/** CSS background-color value (HSL string) */
|
||||
backgroundColor: string
|
||||
/** CSS color value for text — always high contrast against the background */
|
||||
color: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a deterministic, high-contrast color pair for a label name.
|
||||
* Uses FNV-1a hash with position-dependent mixing → HSL mapping.
|
||||
* Text is always white (lightness 45 % guarantees dark-enough background).
|
||||
*/
|
||||
export function generateLabelColor(name: string): LabelColorStyle {
|
||||
// FNV-1a with 32-bit offset basis and prime
|
||||
let h = 0x811c9dc5
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
h ^= name.charCodeAt(i) + i * 0x01000193 // position-dependent seed
|
||||
h = Math.imul(h, 0x01000193)
|
||||
h ^= h >>> 16
|
||||
}
|
||||
const hue = ((h >>> 0) % 360 + 360) % 360
|
||||
return { backgroundColor: `hsl(${hue}, 70%, 45%)`, color: "#ffffff" }
|
||||
}
|
||||
|
||||
+851
-86
File diff suppressed because it is too large
Load Diff
+1583
-221
File diff suppressed because it is too large
Load Diff
+490
-40
@@ -2,9 +2,9 @@ import * as React from "react"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||
import { useNavigate, useSearchParams } from "react-router-dom"
|
||||
import { ArrowLeft, BarChart3, Ban, Contact, Copy, Info, KeyRound, Laptop, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, PencilLine, Plus, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react"
|
||||
import { ArrowLeft, BarChart3, Ban, Contact, Copy, Info, KeyRound, Laptop, Link2, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, PencilLine, Plus, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react"
|
||||
import { QRCodeSVG } from "qrcode.react"
|
||||
import { api, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats } from "@/lib/api"
|
||||
import { api, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapStorageMode, ExternalImapSyncRun, ExternalImapTlsMode, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api"
|
||||
import { cn, formatBytes } from "@/lib/utils"
|
||||
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||
import { DisplayMode, useDisplayMode } from "@/lib/display-mode"
|
||||
@@ -12,6 +12,7 @@ import { useMe } from "@/hooks/use-me"
|
||||
import { useLogout } from "@/hooks/use-logout"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { validatePasswordConfirm } from "@/lib/validation"
|
||||
import { hasPermission } from "@/lib/permissions"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { PasswordInput } from "@/components/ui/password-input"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -63,23 +64,56 @@ export function ProfilePage() {
|
||||
const [blockedMailboxId, setBlockedMailboxId] = React.useState("all")
|
||||
const [ruleDialogOpen, setRuleDialogOpen] = React.useState(false)
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = React.useState(false)
|
||||
const [externalRunAccountId, setExternalRunAccountId] = React.useState("")
|
||||
const isMobile = useIsMobile()
|
||||
const themeMountedRef = React.useRef(false)
|
||||
|
||||
const rawTab = params.get("tab") as Tab | null
|
||||
const tab: Tab = rawTab && tabKeys.includes(rawTab) ? rawTab : "profile"
|
||||
const user = me.data?.user
|
||||
const mailboxes = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes })
|
||||
const mailboxApplyOptions = useQuery({ queryKey: ["mailbox-apply-options"], queryFn: api.mailboxApplyOptions })
|
||||
const canAccessMail = hasPermission(user, "mail.access")
|
||||
const canReadMail = hasPermission(user, "mail.messages.read")
|
||||
const canOrganizeMail = hasPermission(user, "mail.messages.organize")
|
||||
const canManageLabels = hasPermission(user, "mail.labels.manage")
|
||||
const canManageContacts = hasPermission(user, "mail.contacts.manage")
|
||||
const canManageSignatures = hasPermission(user, "mail.signatures.manage")
|
||||
const canManageRules = hasPermission(user, "mail.rules.manage")
|
||||
const canManageBlocked = hasPermission(user, "mail.blocked_senders.manage")
|
||||
const canViewStats = hasPermission(user, "mail.stats.view")
|
||||
const canApplyMailbox = hasPermission(user, "mail.mailboxes.apply")
|
||||
const visibleTabKeys = tabKeys.filter((key) => {
|
||||
if (key === "profile") return true
|
||||
if (key === "mailboxes") return canAccessMail || canApplyMailbox
|
||||
if (key === "clients") return canAccessMail
|
||||
if (key === "signatures") return canManageSignatures
|
||||
if (key === "contacts") return canManageContacts
|
||||
if (key === "cleanup") return canOrganizeMail
|
||||
if (key === "rules") return canManageRules
|
||||
if (key === "blocked") return canManageBlocked
|
||||
if (key === "stats") return canViewStats
|
||||
return false
|
||||
})
|
||||
const tab: Tab = rawTab && visibleTabKeys.includes(rawTab) ? rawTab : "profile"
|
||||
const mailboxes = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes, enabled: canAccessMail })
|
||||
const mailboxApplyOptions = useQuery({ queryKey: ["mailbox-apply-options"], queryFn: api.mailboxApplyOptions, enabled: canApplyMailbox })
|
||||
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
|
||||
const contacts = useQuery({ queryKey: ["contacts"], queryFn: api.contacts })
|
||||
const signatures = useQuery({ queryKey: ["signatures"], queryFn: api.signatures })
|
||||
const rules = useQuery({ queryKey: ["rules"], queryFn: api.rules })
|
||||
const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders })
|
||||
const contacts = useQuery({ queryKey: ["contacts"], queryFn: api.contacts, enabled: canManageContacts })
|
||||
const signatures = useQuery({ queryKey: ["signatures"], queryFn: api.signatures, enabled: canManageSignatures })
|
||||
const rules = useQuery({ queryKey: ["rules"], queryFn: api.rules, enabled: canManageRules })
|
||||
const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders, enabled: canManageBlocked })
|
||||
const selectedMailbox = React.useMemo(() => mailboxes.data?.items.find((m) => m.id === mailboxId), [mailboxes.data?.items, mailboxId])
|
||||
const activeMailboxId = selectedMailbox?.id || ""
|
||||
const ruleLabels = useQuery({ queryKey: ["labels", "rules", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId })
|
||||
const stats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId })
|
||||
const externalImapEnabled = publicSettings.data?.externalImapEnabled ?? false
|
||||
const externalImapAccounts = useQuery({ queryKey: ["external-imap-accounts", activeMailboxId], queryFn: () => api.externalImapAccounts(activeMailboxId), enabled: !!activeMailboxId && canAccessMail && externalImapEnabled })
|
||||
React.useEffect(() => {
|
||||
if (!externalRunAccountId) return
|
||||
if (externalImapAccounts.data?.items.some((item) => item.id === externalRunAccountId)) return
|
||||
setExternalRunAccountId("")
|
||||
}, [externalImapAccounts.data?.items, externalRunAccountId])
|
||||
const selectedExternalRunAccount = externalImapAccounts.data?.items.find((item) => item.id === externalRunAccountId)
|
||||
const externalRunFolders = useQuery({ queryKey: ["external-imap-run-folders", externalRunAccountId], queryFn: () => api.externalFolders(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled })
|
||||
const externalSyncRuns = useQuery({ queryKey: ["external-imap-sync-runs", externalRunAccountId], queryFn: () => api.externalImapSyncRuns(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled })
|
||||
const ruleLabels = useQuery({ queryKey: ["labels", "rules", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && canManageRules && (canReadMail || canManageLabels) })
|
||||
const stats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId && canViewStats })
|
||||
|
||||
const profile = useMutation({
|
||||
mutationFn: (form: FormData) => api.updateProfile({ displayName: String(form.get("displayName") || "") }),
|
||||
@@ -175,6 +209,41 @@ export function ProfilePage() {
|
||||
},
|
||||
onError: (error) => toast({ title: "申请失败", description: error.message }),
|
||||
})
|
||||
const createExternalImap = useMutation({
|
||||
mutationFn: api.createExternalImapAccount,
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["external-imap-accounts"] }); qc.invalidateQueries({ queryKey: ["mail-external-accounts"] }); toast({ title: "外部 IMAP 已保存" }) },
|
||||
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
||||
})
|
||||
const updateExternalImap = useMutation({
|
||||
mutationFn: ({ id, payload }: { id: string; payload: ExternalImapAccountPayload }) => api.updateExternalImapAccount(id, payload),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["external-imap-accounts"] }); qc.invalidateQueries({ queryKey: ["mail-external-accounts"] }); toast({ title: "外部 IMAP 已更新" }) },
|
||||
onError: (error) => toast({ title: "更新失败", description: error.message }),
|
||||
})
|
||||
const deleteExternalImap = useMutation({
|
||||
mutationFn: api.deleteExternalImapAccount,
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["external-imap-accounts"] }); qc.invalidateQueries({ queryKey: ["mail-external-accounts"] }); toast({ title: "外部 IMAP 已删除" }) },
|
||||
onError: (error) => toast({ title: "删除失败", description: error.message }),
|
||||
})
|
||||
const testExternalImap = useMutation({
|
||||
mutationFn: api.testExternalImapAccount,
|
||||
onSuccess: (res) => toast({ title: `连接成功,发现 ${res.folders} 个文件夹` }),
|
||||
onError: (error) => toast({ title: "连接失败", description: error.message }),
|
||||
})
|
||||
const syncExternalImap = useMutation({
|
||||
mutationFn: api.syncExternalImapAccount,
|
||||
onSuccess: (run) => { qc.invalidateQueries({ queryKey: ["external-imap-accounts"] }); qc.invalidateQueries({ queryKey: ["external-imap-sync-runs"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["messages"] }); toast({ title: `同步完成:导入 ${run.imported},跳过 ${run.skipped}` }) },
|
||||
onError: (error) => toast({ title: "同步失败", description: error.message }),
|
||||
})
|
||||
const syncExternalImapFolder = useMutation({
|
||||
mutationFn: ({ id, folder }: { id: string; folder: string }) => api.syncExternalImapFolder(id, folder),
|
||||
onSuccess: (run) => { qc.invalidateQueries({ queryKey: ["external-imap-accounts"] }); qc.invalidateQueries({ queryKey: ["external-imap-sync-runs"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["messages"] }); toast({ title: `${run.folder || "文件夹"} 同步完成:导入 ${run.imported},跳过 ${run.skipped}` }) },
|
||||
onError: (error) => toast({ title: "同步失败", description: error.message }),
|
||||
})
|
||||
const startExternalOAuth = useMutation({
|
||||
mutationFn: ({ provider, mailboxId, email, storageMode }: { provider: ExternalImapOAuthProvider; mailboxId: string; email: string; storageMode: ExternalImapStorageMode }) => api.startExternalImapOAuth(provider, { mailboxId, email, storageMode, syncReadState: true, enabled: true }),
|
||||
onSuccess: (res) => { window.location.href = res.url },
|
||||
onError: (error) => toast({ title: "授权失败", description: error.message }),
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!mailboxes.isSuccess) return
|
||||
@@ -191,7 +260,11 @@ export function ProfilePage() {
|
||||
|
||||
const logout = useLogout()
|
||||
async function copy(text: string) { await navigator.clipboard.writeText(text); toast({ title: "已复制" }) }
|
||||
function setTab(next: Tab) { setParams(next === "profile" ? {} : { tab: next }); setMobileSidebarOpen(false) }
|
||||
function setTab(next: Tab) {
|
||||
const visibleNext = visibleTabKeys.includes(next) ? next : "profile"
|
||||
setParams(visibleNext === "profile" ? {} : { tab: visibleNext })
|
||||
setMobileSidebarOpen(false)
|
||||
}
|
||||
function toggleSidebar() { sidebarCollapsed ? (sidebarPanelRef.current?.expand(14), setSidebarCollapsed(false)) : (sidebarPanelRef.current?.collapse(), setSidebarCollapsed(true)) }
|
||||
if (me.isLoading) return <div className="grid h-svh place-items-center text-muted-foreground">加载中...</div>
|
||||
if (me.isError || !user) return <div className="grid h-svh place-items-center text-muted-foreground">登录状态已失效</div>
|
||||
@@ -205,7 +278,7 @@ export function ProfilePage() {
|
||||
<SidebarGroup>
|
||||
{!sidebarCollapsed && <SidebarGroupLabel>个人中心</SidebarGroupLabel>}
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>{tabKeys.map((key) => <SidebarMenuItem key={key}><SidebarMenuButton isActive={tab === key} className={cn(sidebarCollapsed && "justify-center px-0")} onClick={() => setTab(key)}>{tabs[key].icon}{!sidebarCollapsed && <span>{tabs[key].label}</span>}</SidebarMenuButton></SidebarMenuItem>)}</SidebarMenu>
|
||||
<SidebarMenu>{visibleTabKeys.map((key) => <SidebarMenuItem key={key}><SidebarMenuButton isActive={tab === key} className={cn(sidebarCollapsed && "justify-center px-0")} onClick={() => setTab(key)}>{tabs[key].icon}{!sidebarCollapsed && <span>{tabs[key].label}</span>}</SidebarMenuButton></SidebarMenuItem>)}</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
@@ -264,21 +337,64 @@ export function ProfilePage() {
|
||||
</div>
|
||||
)
|
||||
function renderTab() {
|
||||
if (tab === "mailboxes") return <MailboxManagement mailboxes={mailboxes.data?.items || []} applyOptions={mailboxApplyOptions.data} applyPending={applyMailbox.isPending} selectedMailboxId={mailboxId} onSelect={setMailboxId} onCopy={copy} onOpen={(id) => { setMailboxId(id); navigate("/") }} onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)} />
|
||||
if (tab === "mailboxes") return (
|
||||
<MailboxManagement
|
||||
mailboxes={canAccessMail ? mailboxes.data?.items || [] : []}
|
||||
applyOptions={mailboxApplyOptions.data}
|
||||
applyPending={applyMailbox.isPending}
|
||||
selectedMailboxId={mailboxId}
|
||||
externalImapEnabled={externalImapEnabled}
|
||||
externalAccounts={externalImapAccounts.data?.items || []}
|
||||
externalPending={createExternalImap.isPending || updateExternalImap.isPending || deleteExternalImap.isPending || testExternalImap.isPending || syncExternalImap.isPending || syncExternalImapFolder.isPending || startExternalOAuth.isPending}
|
||||
selectedExternalRunAccountId={externalRunAccountId}
|
||||
externalRunFolders={externalRunFolders.data?.items || []}
|
||||
externalSyncRuns={externalSyncRuns.data?.items || []}
|
||||
onSelectExternalRunAccount={setExternalRunAccountId}
|
||||
onSelect={setMailboxId}
|
||||
onCopy={copy}
|
||||
onOpen={(id) => { if (!canAccessMail) return; setMailboxId(id); navigate("/") }}
|
||||
onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)}
|
||||
onCreateExternal={(payload) => createExternalImap.mutate(payload)}
|
||||
onStartExternalOAuth={(provider, payload) => startExternalOAuth.mutate({ provider, ...payload })}
|
||||
onUpdateExternal={(id, payload) => updateExternalImap.mutate({ id, payload })}
|
||||
onDeleteExternal={(id) => deleteExternalImap.mutate(id)}
|
||||
onTestExternal={(id) => testExternalImap.mutate(id)}
|
||||
onSyncExternal={(id) => syncExternalImap.mutate(id)}
|
||||
onSyncExternalFolder={(id, folder) => syncExternalImapFolder.mutate({ id, folder })}
|
||||
/>
|
||||
)
|
||||
if (tab === "clients") return <ClientSettingsSection mailboxes={mailboxes.data?.items || []} selectedMailboxId={mailboxId} hostname={publicSettings.data?.publicHostname} onSelectMailbox={setMailboxId} onCopy={copy} />
|
||||
if (tab === "signatures") return <SignaturesSection items={signatures.data?.items || []} mailboxes={mailboxes.data?.items || []} loading={signatures.isLoading} pending={createSignature.isPending || updateSignature.isPending || setDefaultSignature.isPending || deleteSignature.isPending} onCreate={(form) => createSignature.mutate(form)} onUpdate={(id, form) => updateSignature.mutate({ id, form })} onSetDefault={(id) => setDefaultSignature.mutate(id)} onDelete={(id) => deleteSignature.mutate(id)} />
|
||||
if (tab === "contacts") return <ContactsSection items={contacts.data?.items || []} loading={contacts.isLoading} pending={createContact.isPending} onCreate={(form) => createContact.mutate(form)} onDelete={(id) => deleteContact.mutate(id)} onCopy={copy} />
|
||||
if (tab === "cleanup") return <CleanupSection mailbox={selectedMailbox} stats={stats.data} pending={cleanup.isPending} onCleanup={(target) => cleanup.mutate(target)} />
|
||||
if (tab === "cleanup") return <CleanupSection mailbox={selectedMailbox} stats={canViewStats ? stats.data : undefined} showStats={canViewStats} pending={cleanup.isPending} onCleanup={(target) => cleanup.mutate(target)} />
|
||||
if (tab === "rules") return <RulesSection items={rules.data?.items || []} mailboxes={mailboxes.data?.items || []} labels={ruleLabels.data?.items || []} open={ruleDialogOpen} onOpenChange={setRuleDialogOpen} onCreate={(payload) => createRule.mutate(payload)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} />
|
||||
if (tab === "blocked") return <BlockedSection items={blocked.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={blockedMailboxId} spamCount={stats.data?.byFolder.find((f) => f.role === "spam")?.count || 0} onMailboxChange={setBlockedMailboxId} onCreate={(form) => createBlocked.mutate(form)} onDelete={(id) => deleteBlocked.mutate(id)} pending={createBlocked.isPending} />
|
||||
if (tab === "blocked") return <BlockedSection items={blocked.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={blockedMailboxId} spamCount={canViewStats ? stats.data?.byFolder.find((f) => f.role === "spam")?.count || 0 : 0} onMailboxChange={setBlockedMailboxId} onCreate={(form) => createBlocked.mutate(form)} onDelete={(id) => deleteBlocked.mutate(id)} pending={createBlocked.isPending} />
|
||||
if (tab === "stats") return <StatsSection stats={stats.data} mailbox={selectedMailbox} onRefresh={() => stats.refetch()} />
|
||||
return <ProfileOverview user={user!} profile={profile} password={password} passwordFormRef={passwordFormRef} stats={stats.data} displayMode={displayMode} onDisplayModeChange={setDisplayMode} twoFactorFormRef={twoFactorFormRef} setupTwoFactor={setupTwoFactor} enableTwoFactor={enableTwoFactor} disableTwoFactor={disableTwoFactor} onCopy={copy} />
|
||||
return <ProfileOverview user={user!} profile={profile} password={password} passwordFormRef={passwordFormRef} stats={canViewStats ? stats.data : undefined} showStats={canViewStats} displayMode={displayMode} onDisplayModeChange={setDisplayMode} twoFactorFormRef={twoFactorFormRef} setupTwoFactor={setupTwoFactor} enableTwoFactor={enableTwoFactor} disableTwoFactor={disableTwoFactor} onCopy={copy} />
|
||||
}
|
||||
}
|
||||
|
||||
function ProfileOverview({ user, profile, password, passwordFormRef, stats, displayMode, onDisplayModeChange, twoFactorFormRef, setupTwoFactor, enableTwoFactor, disableTwoFactor, onCopy }: { user: { email: string; displayName: string; role: string; disabled: boolean; twoFactorEnabled: boolean; createdAt: string }; profile: { mutate: (form: FormData) => void; isPending: boolean }; password: { mutate: (form: FormData) => void; isPending: boolean }; passwordFormRef: React.RefObject<HTMLFormElement>; stats?: MailStats; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; twoFactorFormRef: React.RefObject<HTMLFormElement>; setupTwoFactor: { data?: { secret: string; otpauthUrl: string }; mutate: () => void; reset: () => void; isPending: boolean }; enableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; disableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; onCopy: (text: string) => void }) {
|
||||
function ProfileOverview({ user, profile, password, passwordFormRef, stats, showStats, displayMode, onDisplayModeChange, twoFactorFormRef, setupTwoFactor, enableTwoFactor, disableTwoFactor, onCopy }: { user: { email: string; displayName: string; role: string; disabled: boolean; twoFactorEnabled: boolean; createdAt: string; limits?: PermissionLimits }; profile: { mutate: (form: FormData) => void; isPending: boolean }; password: { mutate: (form: FormData) => void; isPending: boolean }; passwordFormRef: React.RefObject<HTMLFormElement>; stats?: MailStats; showStats: boolean; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; twoFactorFormRef: React.RefObject<HTMLFormElement>; setupTwoFactor: { data?: { secret: string; otpauthUrl: string }; mutate: () => void; reset: () => void; isPending: boolean }; enableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; disableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; onCopy: (text: string) => void }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>账号配额</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<LimitBadge label="附件上限" value={user.limits?.maxAttachmentMb} unit="MB" />
|
||||
<LimitBadge label="SMTP 每日" value={user.limits?.smtpDailyLimit} unit="封" />
|
||||
<LimitBadge label="SMTP 每分钟" value={user.limits?.smtpMinuteLimit} unit="封" />
|
||||
<LimitBadge label="IMAP 每分钟" value={user.limits?.imapMinuteLimit} unit="次" />
|
||||
<LimitBadge label="POP3 每分钟" value={user.limits?.pop3MinuteLimit} unit="次" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{showStats && <StatsSummary stats={stats} />}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>账户信息</CardTitle>
|
||||
@@ -306,7 +422,7 @@ function ProfileOverview({ user, profile, password, passwordFormRef, stats, disp
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
角色
|
||||
</div>
|
||||
<Badge>{user.role === "admin" ? "管理员" : "普通用户"}</Badge>
|
||||
<Badge>{user.role === "admin" ? "超级管理员" : "普通用户"}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border p-3 text-sm">
|
||||
<span>账号状态</span>
|
||||
@@ -424,15 +540,73 @@ function ProfileOverview({ user, profile, password, passwordFormRef, stats, disp
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<StatsSummary stats={stats} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MailboxManagement({ mailboxes, applyOptions, applyPending, selectedMailboxId, onSelect, onCopy, onOpen, onApply }: { mailboxes: Mailbox[]; applyOptions?: MailboxApplyOptions; applyPending: boolean; selectedMailboxId: string; onSelect: (id: string) => void; onCopy: (text: string) => void; onOpen: (id: string) => void; onApply: (payload: { domainId: string; localPart: string; displayName: string }) => Promise<void> }) {
|
||||
const canApply = !!applyOptions?.enabled && (applyOptions.domains || []).length > 0
|
||||
function LimitBadge({ label, value, unit }: { label: string; value?: number; unit: string }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-3 text-center">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-lg font-semibold tabular-nums tracking-tight">
|
||||
{value !== undefined && value > 0 ? value : "不限"}
|
||||
</div>
|
||||
{value !== undefined && value > 0 && <div className="text-xs text-muted-foreground">{unit}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MailboxManagement({
|
||||
mailboxes,
|
||||
applyOptions,
|
||||
applyPending,
|
||||
selectedMailboxId,
|
||||
externalImapEnabled,
|
||||
externalAccounts,
|
||||
externalPending,
|
||||
selectedExternalRunAccountId,
|
||||
externalRunFolders,
|
||||
externalSyncRuns,
|
||||
onSelectExternalRunAccount,
|
||||
onSelect,
|
||||
onCopy,
|
||||
onOpen,
|
||||
onApply,
|
||||
onCreateExternal,
|
||||
onStartExternalOAuth,
|
||||
onUpdateExternal,
|
||||
onDeleteExternal,
|
||||
onTestExternal,
|
||||
onSyncExternal,
|
||||
onSyncExternalFolder,
|
||||
}: {
|
||||
mailboxes: Mailbox[]
|
||||
applyOptions?: MailboxApplyOptions
|
||||
applyPending: boolean
|
||||
selectedMailboxId: string
|
||||
externalImapEnabled: boolean
|
||||
externalAccounts: ExternalImapAccount[]
|
||||
externalPending: boolean
|
||||
selectedExternalRunAccountId: string
|
||||
externalRunFolders: ExternalImapFolder[]
|
||||
externalSyncRuns: ExternalImapSyncRun[]
|
||||
onSelectExternalRunAccount: (id: string) => void
|
||||
onSelect: (id: string) => void
|
||||
onCopy: (text: string) => void
|
||||
onOpen: (id: string) => void
|
||||
onApply: (payload: { domainId: string; localPart: string; displayName: string }) => Promise<void>
|
||||
onCreateExternal: (payload: ExternalImapAccountPayload) => void
|
||||
onStartExternalOAuth: (provider: ExternalImapOAuthProvider, payload: { mailboxId: string; email: string; storageMode: ExternalImapStorageMode }) => void
|
||||
onUpdateExternal: (id: string, payload: ExternalImapAccountPayload) => void
|
||||
onDeleteExternal: (id: string) => void
|
||||
onTestExternal: (id: string) => void
|
||||
onSyncExternal: (id: string) => void
|
||||
onSyncExternalFolder: (id: string, folder: string) => void
|
||||
}) {
|
||||
const canApply = !!applyOptions?.enabled && (applyOptions.domains || []).length > 0
|
||||
const selectedMailbox = mailboxes.find((item) => item.id === selectedMailboxId)
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end">
|
||||
{canApply && <ApplyMailboxDialog options={applyOptions} pending={applyPending} onApply={onApply} />}
|
||||
</div>
|
||||
@@ -440,6 +614,52 @@ function MailboxManagement({ mailboxes, applyOptions, applyPending, selectedMail
|
||||
{mailboxes.map((m) => <Card key={m.id} className={cn(selectedMailboxId === m.id && "border-primary")}><CardHeader><div className="flex items-start justify-between gap-3"><div className="min-w-0"><CardTitle className="truncate text-base">{m.address}</CardTitle></div>{selectedMailboxId === m.id && <Badge>当前</Badge>}</div></CardHeader><CardContent className="flex flex-wrap gap-2"><Button variant="outline" size="sm" onClick={() => onSelect(m.id)}>设为当前</Button><Button variant="outline" size="sm" onClick={() => onCopy(m.address)}><Copy className="h-4 w-4" />复制</Button><Button size="sm" onClick={() => onOpen(m.id)}>进入邮箱</Button></CardContent></Card>)}
|
||||
{mailboxes.length === 0 && <EmptyState text={canApply ? "暂无邮箱账号,点击申请邮箱创建" : "暂无邮箱账号"} />}
|
||||
</div>
|
||||
{externalImapEnabled && <Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>外部 IMAP 接入</CardTitle>
|
||||
<div className="mt-1 text-sm text-muted-foreground">接入其他邮箱,可选择同步到本地,或每次打开时直接从远端读取。</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ExternalImapOAuthDialog provider="gmail" selectedMailbox={selectedMailbox} disabled={!selectedMailbox} pending={externalPending} onStart={onStartExternalOAuth} />
|
||||
<ExternalImapOAuthDialog provider="outlook" selectedMailbox={selectedMailbox} disabled={!selectedMailbox} pending={externalPending} onStart={onStartExternalOAuth} />
|
||||
<ExternalImapDialog mailboxId={selectedMailboxId} disabled={!selectedMailbox} pending={externalPending} onSubmit={onCreateExternal} />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{!selectedMailbox && <EmptyState text="请先选择一个本地邮箱" />}
|
||||
{selectedMailbox && externalAccounts.length === 0 && <EmptyState text="暂无外部 IMAP 账号" />}
|
||||
{selectedMailbox && externalAccounts.map((account) => {
|
||||
const selectedForRuns = selectedExternalRunAccountId === account.id
|
||||
return (
|
||||
<div key={account.id} className="space-y-3 rounded-lg border p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="truncate font-medium">{account.name}</div>
|
||||
<Badge variant={account.enabled ? "secondary" : "outline"}>{account.enabled ? "已启用" : "已停用"}</Badge>
|
||||
<Badge variant="outline">{account.storageMode === "local" ? "本地存储" : "远端直连"}</Badge>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">{account.username} · {account.host}:{account.port} · {account.tlsMode.toUpperCase()}{account.authMode === "oauth2" ? ` · ${externalOAuthProviderLabel(account.oauthProvider)}` : ""}</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">状态:{externalStatusLabel(account.lastStatus)}{account.lastSyncAt ? ` · 最近同步 ${formatDateTime(account.lastSyncAt)}` : ""}{account.lastError ? ` · ${account.lastError}` : ""}</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" size="sm" disabled={externalPending} onClick={() => onTestExternal(account.id)}><Link2 className="h-4 w-4" />测试</Button>
|
||||
{account.storageMode === "local" && <Button type="button" variant="outline" size="sm" disabled={externalPending} onClick={() => onSyncExternal(account.id)}><RefreshCcw className="h-4 w-4" />同步</Button>}
|
||||
{account.storageMode === "local" && <Button type="button" variant="ghost" size="sm" onClick={() => onSelectExternalRunAccount(selectedForRuns ? "" : account.id)}>历史</Button>}
|
||||
<ExternalImapDialog account={account} mailboxId={selectedMailboxId} pending={externalPending} onSubmit={(payload) => onUpdateExternal(account.id, payload)} />
|
||||
<Button type="button" variant="outline" size="sm" disabled={externalPending} onClick={() => onUpdateExternal(account.id, { ...externalPayloadFromAccount(account), enabled: !account.enabled })}>{account.enabled ? "停用" : "启用"}</Button>
|
||||
<Button type="button" variant="destructive" size="sm" disabled={externalPending} onClick={() => onDeleteExternal(account.id)}>删除</Button>
|
||||
</div>
|
||||
</div>
|
||||
{selectedForRuns && <ExternalImapSyncPanel account={account} folders={externalRunFolders} runs={externalSyncRuns} pending={externalPending} onSyncFolder={onSyncExternalFolder} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -490,6 +710,182 @@ function ApplyMailboxDialog({ options, pending, onApply }: { options: MailboxApp
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function ExternalImapOAuthDialog({ provider, selectedMailbox, disabled, pending, onStart }: { provider: ExternalImapOAuthProvider; selectedMailbox?: Mailbox; disabled?: boolean; pending: boolean; onStart: (provider: ExternalImapOAuthProvider, payload: { mailboxId: string; email: string; storageMode: ExternalImapStorageMode }) => void }) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [storageMode, setStorageMode] = React.useState<ExternalImapStorageMode>("local")
|
||||
const label = provider === "gmail" ? "Gmail OAuth" : "Microsoft 365 / Outlook OAuth"
|
||||
|
||||
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
if (!selectedMailbox) return
|
||||
const form = new FormData(event.currentTarget)
|
||||
onStart(provider, {
|
||||
mailboxId: selectedMailbox.id,
|
||||
email: String(form.get("email") || ""),
|
||||
storageMode,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Button type="button" variant="outline" disabled={disabled || pending} onClick={() => setOpen(true)}>{label}</Button>
|
||||
<DialogContent className="max-h-[92dvh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader><DialogTitle>{label}</DialogTitle></DialogHeader>
|
||||
<form className="space-y-4" onSubmit={submit}>
|
||||
<div className="rounded-lg border bg-muted/30 p-3 text-sm text-muted-foreground">
|
||||
OAuth 只适用于 {provider === "gmail" ? "Google Gmail" : "Microsoft 365 / Outlook / Exchange Online"} 托管邮箱。自建域名邮箱请使用“添加外部邮箱”的普通 IMAP 方式。
|
||||
</div>
|
||||
<Field label="外部邮箱地址(可选)"><Input name="email" type="email" placeholder={selectedMailbox?.address || "name@example.com"} /></Field>
|
||||
<div className="text-xs text-muted-foreground">留空时会以 OAuth 服务商返回的真实授权邮箱为准;填写后,回调时会校验它和真实授权邮箱一致。</div>
|
||||
<Field label="存储模式">
|
||||
<Select value={storageMode} onValueChange={(value) => setStorageMode(value as ExternalImapStorageMode)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="local">同步到本地</SelectItem><SelectItem value="remote">远端直连</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<DialogFooter className="gap-2 [&>button]:w-full sm:[&>button]:w-auto">
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>取消</Button>
|
||||
<Button disabled={pending || !selectedMailbox}>{pending ? "跳转中..." : "前往授权"}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function ExternalImapDialog({ account, mailboxId, disabled, pending, onSubmit }: { account?: ExternalImapAccount; mailboxId: string; disabled?: boolean; pending: boolean; onSubmit: (payload: ExternalImapAccountPayload) => void }) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [tlsMode, setTlsMode] = React.useState<ExternalImapTlsMode>(account?.tlsMode || "tls")
|
||||
const [storageMode, setStorageMode] = React.useState<ExternalImapStorageMode>(account?.storageMode || "local")
|
||||
const [syncReadState, setSyncReadState] = React.useState(account?.syncReadState ?? true)
|
||||
const [enabled, setEnabled] = React.useState(account?.enabled ?? true)
|
||||
React.useEffect(() => {
|
||||
if (!open) return
|
||||
setTlsMode(account?.tlsMode || "tls")
|
||||
setStorageMode(account?.storageMode || "local")
|
||||
setSyncReadState(account?.syncReadState ?? true)
|
||||
setEnabled(account?.enabled ?? true)
|
||||
}, [account, open])
|
||||
|
||||
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
const form = new FormData(event.currentTarget)
|
||||
const payload: ExternalImapAccountPayload = {
|
||||
mailboxId,
|
||||
name: String(form.get("name") || ""),
|
||||
host: String(form.get("host") || ""),
|
||||
port: Number(form.get("port") || (tlsMode === "tls" ? 993 : 143)),
|
||||
tlsMode,
|
||||
username: String(form.get("username") || ""),
|
||||
password: String(form.get("password") || ""),
|
||||
storageMode,
|
||||
syncReadState,
|
||||
enabled,
|
||||
}
|
||||
onSubmit(payload)
|
||||
if (!pending) setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Button type="button" variant={account ? "outline" : "default"} size={account ? "sm" : "default"} disabled={disabled} onClick={() => setOpen(true)}>
|
||||
{account ? "编辑" : <><Plus className="h-4 w-4" />添加外部邮箱</>}
|
||||
</Button>
|
||||
<DialogContent className="max-h-[92dvh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader><DialogTitle>{account ? "编辑外部 IMAP" : "添加外部 IMAP"}</DialogTitle></DialogHeader>
|
||||
<form className="space-y-4" onSubmit={submit}>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="显示名称"><Input name="name" defaultValue={account?.name || ""} placeholder="Gmail / 工作邮箱" /></Field>
|
||||
<Field label="用户名"><Input name="username" defaultValue={account?.username || ""} required placeholder="name@example.com" /></Field>
|
||||
<Field label="服务器"><Input name="host" defaultValue={account?.host || ""} required placeholder="imap.example.com" /></Field>
|
||||
<Field label="端口"><Input name="port" type="number" min={1} max={65535} defaultValue={account?.port || (tlsMode === "tls" ? 993 : 143)} /></Field>
|
||||
<Field label="加密方式">
|
||||
<Select value={tlsMode} onValueChange={(value) => setTlsMode(value as ExternalImapTlsMode)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="tls">SSL/TLS</SelectItem><SelectItem value="starttls">STARTTLS</SelectItem><SelectItem value="plain">不加密</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="存储模式">
|
||||
<Select value={storageMode} onValueChange={(value) => setStorageMode(value as ExternalImapStorageMode)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="local">同步到本地</SelectItem><SelectItem value="remote">远端直连</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label={account ? "密码(留空则不修改)" : "密码"}><PasswordInput name="password" required={!account} placeholder={account ? "不修改请留空" : "外部邮箱密码或授权码"} /></Field>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="flex items-center gap-2 rounded-lg border p-3 text-sm"><Checkbox checked={syncReadState} onCheckedChange={(checked) => setSyncReadState(checked === true)} />同步已读状态</label>
|
||||
<label className="flex items-center gap-2 rounded-lg border p-3 text-sm"><Checkbox checked={enabled} onCheckedChange={(checked) => setEnabled(checked === true)} />启用此账号</label>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 [&>button]:w-full sm:[&>button]:w-auto">
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>取消</Button>
|
||||
<Button disabled={pending || !mailboxId}>{pending ? "保存中..." : "保存"}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function externalPayloadFromAccount(account: ExternalImapAccount): ExternalImapAccountPayload {
|
||||
return { mailboxId: account.mailboxId, name: account.name, host: account.host, port: account.port, tlsMode: account.tlsMode, username: account.username, password: "", storageMode: account.storageMode, syncReadState: account.syncReadState, enabled: account.enabled }
|
||||
}
|
||||
|
||||
function externalStatusLabel(status: string) {
|
||||
return ({ idle: "未同步", ok: "正常", partial: "部分成功", error: "错误", running: "同步中" } as Record<string, string>)[status] || status || "未知"
|
||||
}
|
||||
|
||||
function externalOAuthProviderLabel(provider?: ExternalImapOAuthProvider) {
|
||||
return provider === "gmail" ? "Gmail OAuth" : provider === "outlook" ? "Microsoft 365 / Outlook OAuth" : "OAuth"
|
||||
}
|
||||
|
||||
function ExternalImapSyncPanel({ account, folders, runs, pending, onSyncFolder }: { account: ExternalImapAccount; folders: ExternalImapFolder[]; runs: ExternalImapSyncRun[]; pending: boolean; onSyncFolder: (id: string, folder: string) => void }) {
|
||||
const [folder, setFolder] = React.useState("")
|
||||
React.useEffect(() => {
|
||||
if (folder && folders.some((item) => item.name === folder)) return
|
||||
setFolder(folders[0]?.name || "INBOX")
|
||||
}, [folder, folders])
|
||||
return (
|
||||
<div className="rounded-lg bg-muted/40 p-3">
|
||||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
|
||||
<Field label="单文件夹同步">
|
||||
<Select value={folder} onValueChange={setFolder}>
|
||||
<SelectTrigger><SelectValue placeholder="选择远端文件夹" /></SelectTrigger>
|
||||
<SelectContent>{folders.map((item) => <SelectItem key={item.name} value={item.name}>{folderLabel(item.name)}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Button type="button" variant="outline" disabled={pending || !folder} onClick={() => onSyncFolder(account.id, folder)}><RefreshCcw className="h-4 w-4" />同步文件夹</Button>
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">最近同步记录</div>
|
||||
{runs.length === 0 && <div className="rounded-md border bg-background p-3 text-sm text-muted-foreground">暂无同步记录</div>}
|
||||
{runs.slice(0, 6).map((run) => (
|
||||
<div key={run.id} className="grid gap-2 rounded-md border bg-background p-3 text-sm md:grid-cols-[minmax(0,1fr)_auto] md:items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={run.status === "ok" ? "secondary" : run.status === "failed" ? "destructive" : "outline"}>{externalStatusLabel(run.status)}</Badge>
|
||||
<span className="truncate">{run.folder ? folderLabel(run.folder) : "全部文件夹"}</span>
|
||||
</div>
|
||||
{run.error && <div className="mt-1 truncate text-xs text-destructive">{run.error}</div>}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground md:text-right">
|
||||
<div>导入 {run.imported} · 跳过 {run.skipped} · 失败 {run.failed}</div>
|
||||
<div>{formatDateTime(run.startedAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
function ClientSettingsSection({ mailboxes, selectedMailboxId, hostname, onSelectMailbox, onCopy }: { mailboxes: Mailbox[]; selectedMailboxId: string; hostname?: string; onSelectMailbox: (id: string) => void; onCopy: (text: string) => void }) {
|
||||
const selected = mailboxes.find((item) => item.id === selectedMailboxId) || mailboxes[0]
|
||||
const server = clientServerHost(hostname, selected?.address)
|
||||
@@ -718,7 +1114,7 @@ function ContactsSection({ items, loading, onCreate, onDelete, onCopy, pending }
|
||||
)
|
||||
}
|
||||
|
||||
function CleanupSection({ mailbox, stats, pending, onCleanup }: { mailbox?: Mailbox; stats?: MailStats; pending: boolean; onCleanup: (target: "empty-trash" | "empty-spam" | "archive-read-inbox") => void }) {
|
||||
function CleanupSection({ mailbox, stats, showStats, pending, onCleanup }: { mailbox?: Mailbox; stats?: MailStats; showStats: boolean; pending: boolean; onCleanup: (target: "empty-trash" | "empty-spam" | "archive-read-inbox") => void }) {
|
||||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||
function confirmCleanup(target: "empty-trash" | "empty-spam" | "archive-read-inbox", title: string, destructive = false) {
|
||||
setPendingConfirm({
|
||||
@@ -731,7 +1127,7 @@ function CleanupSection({ mailbox, stats, pending, onCleanup }: { mailbox?: Mail
|
||||
}
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<StatsSummary stats={stats} />
|
||||
{showStats && <StatsSummary stats={stats} />}
|
||||
<Card>
|
||||
<CardHeader><CardTitle>清理当前邮箱</CardTitle></CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-3">
|
||||
@@ -756,8 +1152,15 @@ type RuleCreatePayload = {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const conditionFieldLabels: Record<MailRuleCondition["field"], string> = { from: "发件人地址", to: "收件人地址", subject: "邮件主题", body: "邮件正文" }
|
||||
const conditionOperatorLabels: Record<MailRuleCondition["operator"], string> = { contains: "包含", "not-contains": "不包含", equals: "等于", "not-equals": "不等于", "starts-with": "开头是", "ends-with": "结尾是" }
|
||||
type RuleConditionField = NonNullable<MailRuleCondition["field"]>
|
||||
type RuleConditionOperator = NonNullable<MailRuleCondition["operator"]>
|
||||
const conditionFieldLabels: Record<RuleConditionField, string> = { from: "发件人地址", to: "收件人地址", cc: "抄送地址", subject: "邮件主题", body: "邮件正文", attachment: "附件名称", size: "邮件大小", date: "收信日期" }
|
||||
const conditionOperatorLabels: Record<RuleConditionOperator, string> = { contains: "包含", "not-contains": "不包含", equals: "等于", "not-equals": "不等于", "starts-with": "开头是", "ends-with": "结尾是", gt: "大于", gte: "大于等于", lt: "小于", lte: "小于等于", before: "早于", after: "晚于", on: "当天" }
|
||||
const textConditionOperators: RuleConditionOperator[] = ["contains", "not-contains", "equals", "not-equals", "starts-with", "ends-with"]
|
||||
const sizeConditionOperators: RuleConditionOperator[] = ["gt", "gte", "lt", "lte", "equals", "not-equals"]
|
||||
const dateConditionOperators: RuleConditionOperator[] = ["before", "after", "on", "equals", "not-equals"]
|
||||
const conditionFields = Object.keys(conditionFieldLabels) as RuleConditionField[]
|
||||
const commonRuleFolders = ["Inbox", "Archive", "Spam", "Trash"]
|
||||
const ruleActionLabels: Record<MailRuleAction["type"], string> = { archive: "移入归档", trash: "移入回收站", star: "添加星标", "mark-read": "标记已读", label: "添加标签", move: "移动到" }
|
||||
|
||||
function RulesSection({ items, mailboxes, labels, open, onOpenChange, onCreate, onDelete, pending }: { items: MailRule[]; mailboxes: Mailbox[]; labels: MailLabel[]; open: boolean; onOpenChange: (open: boolean) => void; onCreate: (payload: RuleCreatePayload) => void; onDelete: (id: string) => void; pending: boolean }) {
|
||||
@@ -804,7 +1207,14 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
|
||||
}, [open, labels])
|
||||
|
||||
function updateCondition(index: number, patch: Partial<MailRuleCondition>) {
|
||||
setConditions((items) => items.map((item, i) => i === index ? { ...item, ...patch } : item))
|
||||
setConditions((items) => items.map((item, i) => {
|
||||
if (i !== index) return item
|
||||
const next = { ...item, ...patch }
|
||||
if (patch.field && !conditionOperatorsForField(patch.field).includes(next.operator || "contains")) {
|
||||
next.operator = defaultConditionOperator(patch.field)
|
||||
}
|
||||
return next
|
||||
}))
|
||||
}
|
||||
function updateAction(index: number, patch: Partial<MailRuleAction>) {
|
||||
setActions((items) => items.map((item, i) => i === index ? normalizeDraftAction({ ...item, ...patch }, availableLabels) : item))
|
||||
@@ -814,7 +1224,7 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
|
||||
function removeCondition(index: number) { setConditions((items) => items.length > 1 ? items.filter((_, i) => i !== index) : items) }
|
||||
function removeAction(index: number) { setActions((items) => items.length > 1 ? items.filter((_, i) => i !== index) : items) }
|
||||
|
||||
const validConditions = conditions.map((item) => ({ ...item, value: item.value.trim() })).filter((item) => item.value)
|
||||
const validConditions = conditions.map((item) => ({ ...item, value: (item.value || "").trim() })).filter((item) => item.field && item.operator && item.value)
|
||||
const validActions = actions.map((item) => normalizeDraftAction(item, availableLabels)).filter((item) => item.type !== "label" || item.value || item.labelId).filter((item) => item.type !== "move" || item.value)
|
||||
const canCreate = validConditions.length > 0 && validActions.length > 0 && !pending
|
||||
|
||||
@@ -846,15 +1256,15 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
|
||||
<div className="space-y-3">
|
||||
{conditions.map((condition, index) => (
|
||||
<div key={index} className="grid gap-3 md:grid-cols-[220px_150px_minmax(0,1fr)_auto_auto]">
|
||||
<Select value={condition.field} onValueChange={(value) => updateCondition(index, { field: value as MailRuleCondition["field"] })}>
|
||||
<Select value={condition.field || "from"} onValueChange={(value) => updateCondition(index, { field: value as RuleConditionField })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{(Object.keys(conditionFieldLabels) as MailRuleCondition["field"][]).map((value) => <SelectItem key={value} value={value}>{conditionFieldLabels[value]}</SelectItem>)}</SelectContent>
|
||||
<SelectContent>{conditionFields.map((value) => <SelectItem key={value} value={value}>{conditionFieldLabels[value]}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Select value={condition.operator} onValueChange={(value) => updateCondition(index, { operator: value as MailRuleCondition["operator"] })}>
|
||||
<Select value={condition.operator || defaultConditionOperator(condition.field)} onValueChange={(value) => updateCondition(index, { operator: value as RuleConditionOperator })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{(Object.keys(conditionOperatorLabels) as MailRuleCondition["operator"][]).map((value) => <SelectItem key={value} value={value}>{conditionOperatorLabels[value]}</SelectItem>)}</SelectContent>
|
||||
<SelectContent>{conditionOperatorsForField(condition.field).map((value) => <SelectItem key={value} value={value}>{conditionOperatorLabels[value]}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Input value={condition.value} onChange={(event) => updateCondition(index, { value: event.target.value })} placeholder="输入值" />
|
||||
<Input type={condition.field === "date" ? "date" : "text"} value={condition.value || ""} onChange={(event) => updateCondition(index, { value: event.target.value })} placeholder={conditionPlaceholder(condition.field)} />
|
||||
<Button type="button" variant="ghost" size="icon" className="text-muted-foreground" onClick={() => removeCondition(index)} disabled={conditions.length === 1}><X className="h-4 w-4" /></Button>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={addCondition}><Plus className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
@@ -913,11 +1323,21 @@ function RuleActionValue({ action, labels, onChange }: { action: MailRuleAction;
|
||||
return <Input value={action.value || ""} onChange={(event) => onChange({ value: event.target.value, labelId: "" })} placeholder="标签名称" />
|
||||
}
|
||||
if (action.type === "move") {
|
||||
const value = action.value || "Archive"
|
||||
return (
|
||||
<Select value={action.value || "Archive"} onValueChange={(value) => onChange({ value })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="Inbox">收件箱</SelectItem><SelectItem value="Archive">归档</SelectItem><SelectItem value="Spam">垃圾邮件</SelectItem><SelectItem value="Trash">回收站</SelectItem></SelectContent>
|
||||
</Select>
|
||||
<div className="grid gap-2 md:grid-cols-[180px_minmax(0,1fr)]">
|
||||
<Select value={commonRuleFolders.includes(value) ? value : "__custom"} onValueChange={(next) => onChange({ value: next === "__custom" ? "" : next })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Inbox">收件箱</SelectItem>
|
||||
<SelectItem value="Archive">归档</SelectItem>
|
||||
<SelectItem value="Spam">垃圾邮件</SelectItem>
|
||||
<SelectItem value="Trash">回收站</SelectItem>
|
||||
<SelectItem value="__custom">自定义文件夹</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input value={value} onChange={(event) => onChange({ value: event.target.value })} placeholder="输入或选择文件夹名" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <Input value="无需填写" readOnly />
|
||||
@@ -956,9 +1376,38 @@ function normalizeDraftAction(action: MailRuleAction, labels: MailLabel[]): Mail
|
||||
return { type: action.type }
|
||||
}
|
||||
|
||||
function conditionOperatorsForField(field?: MailRuleCondition["field"]) {
|
||||
if (field === "size") return sizeConditionOperators
|
||||
if (field === "date") return dateConditionOperators
|
||||
return textConditionOperators
|
||||
}
|
||||
|
||||
function defaultConditionOperator(field?: MailRuleCondition["field"]): RuleConditionOperator {
|
||||
if (field === "size") return "gte"
|
||||
if (field === "date") return "on"
|
||||
return "contains"
|
||||
}
|
||||
|
||||
function conditionPlaceholder(field?: MailRuleCondition["field"]) {
|
||||
if (field === "size") return "例如 10mb"
|
||||
if (field === "date") return "选择日期"
|
||||
if (field === "attachment") return "输入附件名或扩展名"
|
||||
return "输入值"
|
||||
}
|
||||
|
||||
function conditionSummary(conditions: MailRuleCondition[] = [], fromContains = "", subjectContains = "") {
|
||||
const items = conditions.length > 0 ? conditions : [fromContains ? { field: "from", operator: "contains", value: fromContains } as MailRuleCondition : undefined, subjectContains ? { field: "subject", operator: "contains", value: subjectContains } as MailRuleCondition : undefined].filter(Boolean) as MailRuleCondition[]
|
||||
return items.map((item) => `${conditionFieldLabels[item.field]} ${conditionOperatorLabels[item.operator]} ${item.value}`).join(";") || "无条件"
|
||||
return items.map(conditionItemSummary).join(";") || "无条件"
|
||||
}
|
||||
|
||||
function conditionItemSummary(item: MailRuleCondition): string {
|
||||
if (item.conditions?.length) {
|
||||
const mode = item.matchMode === "any" ? "任一" : "全部"
|
||||
return `${mode}(${item.conditions.map(conditionItemSummary).join(";")})`
|
||||
}
|
||||
const field = item.field || "from"
|
||||
const operator = item.operator || defaultConditionOperator(field)
|
||||
return `${conditionFieldLabels[field]} ${conditionOperatorLabels[operator]} ${item.value || ""}`
|
||||
}
|
||||
|
||||
function actionSummary(action: MailRuleAction) {
|
||||
@@ -1007,7 +1456,8 @@ function StatsSection({ stats, mailbox, onRefresh }: { stats?: MailStats; mailbo
|
||||
}
|
||||
|
||||
function StatsSummary({ stats }: { stats?: MailStats }) {
|
||||
const cards = [{ label: "总邮件", value: stats?.totalMessages || 0 }, { label: "未读", value: stats?.unreadMessages || 0 }, { label: "星标", value: stats?.starredMessages || 0 }, { label: "附件", value: stats?.attachmentCount || 0 }, { label: "容量", value: formatBytes(stats?.storageBytes || 0) }]
|
||||
const quotaLabel = stats?.quotaBytes ? `${formatBytes(stats.storageBytes || 0)} / ${formatBytes(stats.quotaBytes)}` : formatBytes(stats?.storageBytes || 0)
|
||||
const cards = [{ label: "总邮件", value: stats?.totalMessages || 0 }, { label: "未读", value: stats?.unreadMessages || 0 }, { label: "星标", value: stats?.starredMessages || 0 }, { label: "附件", value: `${stats?.attachmentCount || 0} / ${formatBytes(stats?.attachmentBytes || 0)}` }, { label: stats?.quotaBytes ? `容量 ${Math.min(stats.quotaUsedPct || 0, 999).toFixed(1)}%` : "容量", value: quotaLabel }]
|
||||
return <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">{cards.map((c) => <Card key={c.label}><CardContent className="p-4"><div className="text-2xl font-semibold tracking-tight">{c.value}</div><div className="text-xs text-muted-foreground">{c.label}</div></CardContent></Card>)}</div>
|
||||
}
|
||||
|
||||
|
||||
Vendored
+6
-1
@@ -1,4 +1,9 @@
|
||||
declare module "dompurify" {
|
||||
const DOMPurify: { sanitize: (source: string) => string }
|
||||
type SanitizeConfig = {
|
||||
ADD_ATTR?: string[]
|
||||
ADD_TAGS?: string[]
|
||||
WHOLE_DOCUMENT?: boolean
|
||||
}
|
||||
const DOMPurify: { sanitize: (source: string, config?: SanitizeConfig) => string }
|
||||
export default DOMPurify
|
||||
}
|
||||
|
||||
+30
-1
@@ -28,7 +28,7 @@ LANQIN_PUBLIC_BASE_URL=https://mail.example.com
|
||||
|
||||
# 邮件客户端 TLS 证书。用于 SMTP 465/587、IMAP 993、POP3 995。
|
||||
# 生产环境请挂载真实证书,并确保域名包含 LANQIN_PUBLIC_HOSTNAME。
|
||||
# 留空时会使用容器自带 localhost 自签证书,第三方客户端会提示证书不匹配。
|
||||
# 留空时 Dovecot/Postfix 会使用容器自带 localhost 自签证书;LanQin API 的 SMTP submission 不会启用。
|
||||
LANQIN_TLS_CERT_FILE=
|
||||
LANQIN_TLS_KEY_FILE=
|
||||
|
||||
@@ -84,15 +84,24 @@ LANQIN_TURNSTILE_SECRET_KEY=
|
||||
# SMTP 发信
|
||||
# =========================
|
||||
# 单容器部署默认提交给容器内 Postfix。
|
||||
# Split stack 会由 docker-compose.stack.yml 默认覆盖为 postfix:25。
|
||||
# 如需在 split stack 使用外部 SMTP,可设置 LANQIN_STACK_SMTP_HOST / LANQIN_STACK_SMTP_PORT。
|
||||
# 如果要走外部 SMTP,把 Host/Port/Username/Password 改成外部服务配置。
|
||||
LANQIN_SMTP_HOST=127.0.0.1
|
||||
LANQIN_SMTP_PORT=25
|
||||
LANQIN_STACK_SMTP_HOST=
|
||||
LANQIN_STACK_SMTP_PORT=
|
||||
LANQIN_SMTP_USERNAME=
|
||||
LANQIN_SMTP_PASSWORD=
|
||||
|
||||
# 外部 SMTP 要求 STARTTLS / TLS 时改 true;本机 Postfix 默认 false。
|
||||
LANQIN_SMTP_REQUIRE_TLS=false
|
||||
|
||||
# 第三方客户端 SMTP 提交,由 LanQin API 监听 587/465;启用前必须配置可读 TLS 证书。
|
||||
LANQIN_SUBMISSION_ADDR=
|
||||
LANQIN_SUBMISSION_TLS_ADDR=
|
||||
LANQIN_SUBMISSION_MAX_MESSAGE_MB=35
|
||||
|
||||
# =========================
|
||||
# 收件 / Maildir 同步
|
||||
# =========================
|
||||
@@ -123,6 +132,26 @@ 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=
|
||||
|
||||
# =========================
|
||||
# 系统
|
||||
# =========================
|
||||
|
||||
+12
-4
@@ -128,18 +128,22 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up
|
||||
- Rspamd 会周期性从 SQLite 导出域名 DKIM 私钥到容器内 `/var/lib/rspamd/dkim`。
|
||||
- Go API 是 Webmail 和管理后台入口;浏览器不直接连接 SMTP/IMAP/POP3。
|
||||
- Go API 会读取 `LANQIN_MAILDIR_ROOT=/var/mail/vhosts`,周期扫描 Maildir,把 Postfix/Dovecot 入站邮件同步成 Webmail 索引。
|
||||
- 第三方客户端通过 SMTP `465/587` 发信时,Postfix 会把已认证发件人的邮件自动 BCC 到 `发件人+Sent@域名`,Dovecot LMTP 会保存到该邮箱的 `Sent` 文件夹,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 反代的证书。
|
||||
如果第三方客户端连接 `465/587/993/995` 时提示证书是 `localhost`,说明 Postfix/Dovecot 仍在使用容器自带的测试证书。
|
||||
如果第三方客户端连接 `993/995` 时提示证书是 `localhost`,说明 Dovecot 仍在使用容器自带的测试证书。LanQin API 的 SMTP `465/587` submission 不会使用自签测试证书;启用前必须配置可读的真实证书。
|
||||
|
||||
生产环境请把域名证书挂载进容器,并在 `.env` 指向证书文件:
|
||||
|
||||
```env
|
||||
LANQIN_TLS_CERT_FILE=/certs/fullchain.pem
|
||||
LANQIN_TLS_KEY_FILE=/certs/privkey.pem
|
||||
LANQIN_SUBMISSION_ADDR=:587
|
||||
LANQIN_SUBMISSION_TLS_ADDR=:465
|
||||
```
|
||||
|
||||
单容器示例:
|
||||
@@ -170,12 +174,16 @@ LANQIN_SMTP_PORT=25
|
||||
LANQIN_SMTP_REQUIRE_TLS=false
|
||||
```
|
||||
|
||||
如果页面提示 `smtp delivery failed: EOF`,通常是 Postfix 会话被中断。优先检查:
|
||||
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 配置错误。优先检查:
|
||||
|
||||
```bash
|
||||
docker compose exec lanqin-email supervisorctl status
|
||||
docker compose exec lanqin-email postconf -M smtp/inet submission/inet
|
||||
docker compose exec lanqin-email postconf -M smtp/inet
|
||||
# SMTP 提交 465/587 由 LanQin API 提供,不再由 Postfix 监听。
|
||||
docker compose exec lanqin-email sqlite3 /data/lanqin.db "select key,value from system_settings where key like 'smtp%' order by key;"
|
||||
docker compose exec lanqin-email sqlite3 /data/lanqin.db "select status,attempt_count,last_error from send_queue order by created_at desc limit 10;"
|
||||
docker compose logs --tail=200 lanqin-email
|
||||
```
|
||||
|
||||
|
||||
@@ -7,11 +7,14 @@ set -eu
|
||||
: "${LANQIN_ADDR:=127.0.0.1:8080}"
|
||||
: "${LANQIN_SMTP_HOST:=127.0.0.1}"
|
||||
: "${LANQIN_SMTP_PORT:=25}"
|
||||
: "${LANQIN_SUBMISSION_ADDR:=}"
|
||||
: "${LANQIN_SUBMISSION_TLS_ADDR:=}"
|
||||
: "${LANQIN_SUBMISSION_MAX_MESSAGE_MB:=35}"
|
||||
: "${LANQIN_MAILDIR_ROOT:=/var/mail/vhosts}"
|
||||
: "${LANQIN_TLS_CERT_FILE:=}"
|
||||
: "${LANQIN_TLS_KEY_FILE:=}"
|
||||
|
||||
export LANQIN_DATA_DIR LANQIN_DB_PATH LANQIN_ADDR LANQIN_SMTP_HOST LANQIN_SMTP_PORT LANQIN_MAILDIR_ROOT
|
||||
export LANQIN_DATA_DIR LANQIN_DB_PATH LANQIN_ADDR LANQIN_SMTP_HOST LANQIN_SMTP_PORT LANQIN_SUBMISSION_ADDR LANQIN_SUBMISSION_TLS_ADDR LANQIN_SUBMISSION_MAX_MESSAGE_MB LANQIN_MAILDIR_ROOT LANQIN_TLS_CERT_FILE LANQIN_TLS_KEY_FILE
|
||||
|
||||
addgroup --system --gid 5000 vmail 2>/dev/null || true
|
||||
adduser --system --uid 5000 --gid 5000 --home /var/mail/vhosts --no-create-home vmail 2>/dev/null || true
|
||||
@@ -23,28 +26,44 @@ elif id rspamd >/dev/null 2>&1; then
|
||||
chown -R rspamd:rspamd /run/rspamd /var/lib/rspamd 2>/dev/null || true
|
||||
fi
|
||||
|
||||
AUTH_POLICY_NONCE_FILE="${LANQIN_AUTH_POLICY_NONCE_FILE:-/data/dovecot-auth-policy-nonce}"
|
||||
mkdir -p "$(dirname "$AUTH_POLICY_NONCE_FILE")"
|
||||
if [ ! -s "$AUTH_POLICY_NONCE_FILE" ]; then
|
||||
od -An -tx1 -N32 /dev/urandom | tr -d ' \n' > "$AUTH_POLICY_NONCE_FILE"
|
||||
fi
|
||||
chmod 600 "$AUTH_POLICY_NONCE_FILE" 2>/dev/null || true
|
||||
AUTH_POLICY_HASH_NONCE="$(cat "$AUTH_POLICY_NONCE_FILE")"
|
||||
|
||||
TLS_CERT=/etc/ssl/certs/ssl-cert-snakeoil.pem
|
||||
TLS_KEY=/etc/ssl/private/ssl-cert-snakeoil.key
|
||||
if [ -n "$LANQIN_TLS_CERT_FILE" ] || [ -n "$LANQIN_TLS_KEY_FILE" ]; then
|
||||
if [ -f "$LANQIN_TLS_CERT_FILE" ] && [ -f "$LANQIN_TLS_KEY_FILE" ]; then
|
||||
TLS_CERT="$LANQIN_TLS_CERT_FILE"
|
||||
TLS_KEY="$LANQIN_TLS_KEY_FILE"
|
||||
: "${LANQIN_SUBMISSION_ADDR:=:587}"
|
||||
: "${LANQIN_SUBMISSION_TLS_ADDR:=:465}"
|
||||
else
|
||||
echo "warning: LANQIN_TLS_CERT_FILE/LANQIN_TLS_KEY_FILE not readable; using snakeoil localhost certificate" >&2
|
||||
fi
|
||||
fi
|
||||
if [ -n "$LANQIN_SUBMISSION_ADDR$LANQIN_SUBMISSION_TLS_ADDR" ] && { [ "$TLS_CERT" = "/etc/ssl/certs/ssl-cert-snakeoil.pem" ] || [ "$TLS_KEY" = "/etc/ssl/private/ssl-cert-snakeoil.key" ]; }; then
|
||||
echo "warning: SMTP submission disabled because LANQIN_TLS_CERT_FILE/LANQIN_TLS_KEY_FILE are not configured with readable certificate files" >&2
|
||||
LANQIN_SUBMISSION_ADDR=""
|
||||
LANQIN_SUBMISSION_TLS_ADDR=""
|
||||
fi
|
||||
export LANQIN_SUBMISSION_ADDR LANQIN_SUBMISSION_TLS_ADDR
|
||||
|
||||
postconf -e "myhostname = ${LANQIN_PUBLIC_HOSTNAME}"
|
||||
postconf -e "myorigin = ${LANQIN_PUBLIC_HOSTNAME}"
|
||||
postconf -e "smtpd_tls_cert_file = ${TLS_CERT}"
|
||||
postconf -e "smtpd_tls_key_file = ${TLS_KEY}"
|
||||
postconf -e "virtual_transport = lmtp:inet:127.0.0.1:24"
|
||||
postconf -e "smtpd_sasl_path = inet:127.0.0.1:12345"
|
||||
postconf -e "milter_mail_macros = i {mail_addr} {client_addr} {client_name} {auth_authen}"
|
||||
postconf -e "smtpd_milters = inet:127.0.0.1:11332"
|
||||
postconf -e "non_smtpd_milters = inet:127.0.0.1:11332"
|
||||
sed -i "s#^ssl_cert = <.*#ssl_cert = <${TLS_CERT}#" /etc/dovecot/dovecot.conf
|
||||
sed -i "s#^ssl_key = <.*#ssl_key = <${TLS_KEY}#" /etc/dovecot/dovecot.conf
|
||||
sed -i "s#^auth_policy_hash_nonce = .*#auth_policy_hash_nonce = ${AUTH_POLICY_HASH_NONCE}#" /etc/dovecot/dovecot.conf
|
||||
|
||||
# Rspamd DKIM keys are exported after API seed/migrations create the SQLite DB.
|
||||
/usr/local/bin/lanqin-api >/tmp/lanqin-api-bootstrap.log 2>&1 &
|
||||
|
||||
@@ -17,5 +17,5 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
apt-get update && apt-get install -y --no-install-recommends ca-certificates tzdata
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/lanqin-api /usr/local/bin/lanqin-api
|
||||
EXPOSE 8080
|
||||
EXPOSE 8080 465 587
|
||||
CMD ["lanqin-api"]
|
||||
|
||||
@@ -2,9 +2,21 @@ services:
|
||||
api:
|
||||
image: ${LANQIN_API_IMAGE:-ghcr.io/lanqin996/lanqin-email-api:latest}
|
||||
env_file: .env
|
||||
environment:
|
||||
LANQIN_SMTP_HOST: ${LANQIN_STACK_SMTP_HOST:-postfix}
|
||||
LANQIN_SMTP_PORT: ${LANQIN_STACK_SMTP_PORT:-25}
|
||||
LANQIN_SUBMISSION_ADDR: ${LANQIN_SUBMISSION_ADDR:-}
|
||||
LANQIN_SUBMISSION_TLS_ADDR: ${LANQIN_SUBMISSION_TLS_ADDR:-}
|
||||
volumes:
|
||||
- ./data:/data:ro
|
||||
- ./data:/data
|
||||
- ./mail:/var/mail/vhosts:ro
|
||||
# 生产环境如需第三方客户端校验证书,请取消下面挂载的注释,并在 .env 配置:
|
||||
# LANQIN_TLS_CERT_FILE=/certs/fullchain.pem
|
||||
# LANQIN_TLS_KEY_FILE=/certs/privkey.pem
|
||||
# - /etc/letsencrypt/live/${LANQIN_PUBLIC_HOSTNAME}:/certs:ro
|
||||
ports:
|
||||
- "465:465"
|
||||
- "587:587"
|
||||
depends_on:
|
||||
- dovecot
|
||||
- postfix
|
||||
@@ -36,8 +48,6 @@ services:
|
||||
# - /etc/letsencrypt:/etc/letsencrypt:ro
|
||||
ports:
|
||||
- "25:25"
|
||||
- "465:465"
|
||||
- "587:587"
|
||||
depends_on:
|
||||
- dovecot
|
||||
- rspamd
|
||||
|
||||
@@ -2,4 +2,4 @@ driver = sqlite
|
||||
connect = /data/lanqin.db
|
||||
default_pass_scheme = BLF-CRYPT
|
||||
password_query = SELECT address AS user, password_hash AS password FROM mailboxes WHERE address = '%u' AND status = 'active'
|
||||
user_query = SELECT '/var/mail/vhosts/' || d.name || '/' || m.local_part AS home, 'maildir:/var/mail/vhosts/' || d.name || '/' || m.local_part || '/Maildir' AS mail, 5000 AS uid, 5000 AS gid FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE d.name=lower(substr('%u', instr('%u', '@') + 1)) AND m.local_part=lower(CASE WHEN instr(substr('%u', 1, instr('%u', '@') - 1), '+') > 0 THEN substr(substr('%u', 1, instr('%u', '@') - 1), 1, instr(substr('%u', 1, instr('%u', '@') - 1), '+') - 1) ELSE substr('%u', 1, instr('%u', '@') - 1) END) AND m.status='active' AND d.status='active' UNION SELECT '/var/mail/vhosts/' || lower(substr('%u', instr('%u', '@') + 1)) || '/__unregistered__' AS home, 'maildir:/var/mail/vhosts/' || lower(substr('%u', instr('%u', '@') + 1)) || '/__unregistered__/Maildir' AS mail, 5000 AS uid, 5000 AS gid WHERE EXISTS (SELECT 1 FROM system_settings WHERE key='catchAllEnabled' AND value='true') AND EXISTS (SELECT 1 FROM domains WHERE name=lower(substr('%u', instr('%u', '@') + 1)) AND status='active') AND NOT EXISTS (SELECT 1 FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE d.name=lower(substr('%u', instr('%u', '@') + 1)) AND m.local_part=lower(CASE WHEN instr(substr('%u', 1, instr('%u', '@') - 1), '+') > 0 THEN substr(substr('%u', 1, instr('%u', '@') - 1), 1, instr(substr('%u', 1, instr('%u', '@') - 1), '+') - 1) ELSE substr('%u', 1, instr('%u', '@') - 1) END) AND m.status='active')
|
||||
user_query = SELECT '/var/mail/vhosts/' || d.name || '/' || m.local_part AS home, 'maildir:/var/mail/vhosts/' || d.name || '/' || m.local_part || '/Maildir' AS mail, 5000 AS uid, 5000 AS gid, '*:storage=' || CAST(m.quota_mb AS TEXT) || 'M' AS quota_rule FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE d.name=lower(substr('%u', instr('%u', '@') + 1)) AND m.local_part=lower(CASE WHEN instr(substr('%u', 1, instr('%u', '@') - 1), '+') > 0 THEN substr(substr('%u', 1, instr('%u', '@') - 1), 1, instr(substr('%u', 1, instr('%u', '@') - 1), '+') - 1) ELSE substr('%u', 1, instr('%u', '@') - 1) END) AND m.status='active' AND d.status='active' UNION SELECT '/var/mail/vhosts/' || lower(substr('%u', instr('%u', '@') + 1)) || '/__unregistered__' AS home, 'maildir:/var/mail/vhosts/' || lower(substr('%u', instr('%u', '@') + 1)) || '/__unregistered__/Maildir' AS mail, 5000 AS uid, 5000 AS gid, '*:storage=1024M' AS quota_rule WHERE EXISTS (SELECT 1 FROM system_settings WHERE key='catchAllEnabled' AND value='true') AND EXISTS (SELECT 1 FROM domains WHERE name=lower(substr('%u', instr('%u', '@') + 1)) AND status='active') AND NOT EXISTS (SELECT 1 FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE d.name=lower(substr('%u', instr('%u', '@') + 1)) AND m.local_part=lower(CASE WHEN instr(substr('%u', 1, instr('%u', '@') - 1), '+') > 0 THEN substr(substr('%u', 1, instr('%u', '@') - 1), 1, instr(substr('%u', 1, instr('%u', '@') - 1), '+') - 1) ELSE substr('%u', 1, instr('%u', '@') - 1) END) AND m.status='active')
|
||||
|
||||
@@ -10,21 +10,42 @@ ssl_cert = </etc/ssl/certs/ssl-cert-snakeoil.pem
|
||||
ssl_key = </etc/ssl/private/ssl-cert-snakeoil.key
|
||||
|
||||
mail_home = /var/mail/vhosts/%d/%n
|
||||
mail_max_userip_connections = 10
|
||||
recipient_delimiter = +
|
||||
|
||||
plugin {
|
||||
quota = maildir:User quota
|
||||
quota_rule = *:storage=1G
|
||||
# auth_policy_server is configured via dovecot-sql.conf.ext user_query
|
||||
}
|
||||
|
||||
auth_policy_server_url = http://127.0.0.1:8080/auth-policy
|
||||
auth_policy_server_api_header = Content-Type: application/json
|
||||
auth_policy_hash_mech = sha256
|
||||
auth_policy_hash_truncate = 12
|
||||
auth_policy_hash_nonce = __LANQIN_AUTH_POLICY_HASH_NONCE__
|
||||
auth_policy_request_attributes = login=%{requested_username} remote=%{rip} protocol=%s
|
||||
|
||||
namespace inbox {
|
||||
inbox = yes
|
||||
mailbox Drafts {
|
||||
auto = subscribe
|
||||
special_use = \Drafts
|
||||
}
|
||||
mailbox Sent {
|
||||
auto = subscribe
|
||||
special_use = \Sent
|
||||
}
|
||||
mailbox Trash {
|
||||
auto = subscribe
|
||||
special_use = \Trash
|
||||
}
|
||||
mailbox Archive {
|
||||
auto = subscribe
|
||||
special_use = \Archive
|
||||
}
|
||||
mailbox Spam {
|
||||
auto = subscribe
|
||||
special_use = \Junk
|
||||
}
|
||||
}
|
||||
@@ -38,6 +59,14 @@ userdb {
|
||||
args = /etc/dovecot/dovecot-sql.conf.ext
|
||||
}
|
||||
|
||||
protocol imap {
|
||||
mail_plugins = quota imap_quota
|
||||
}
|
||||
|
||||
protocol pop3 {
|
||||
mail_plugins = quota
|
||||
}
|
||||
|
||||
service imap-login {
|
||||
inet_listener imaps {
|
||||
port = 993
|
||||
|
||||
@@ -4,8 +4,15 @@ set -eu
|
||||
: "${LANQIN_TLS_KEY_FILE:=}"
|
||||
addgroup --system --gid 5000 vmail 2>/dev/null || true
|
||||
adduser --system --uid 5000 --gid 5000 --home /var/mail/vhosts --no-create-home vmail 2>/dev/null || true
|
||||
mkdir -p /var/mail/vhosts
|
||||
mkdir -p /data /var/mail/vhosts
|
||||
chown -R 5000:5000 /var/mail/vhosts
|
||||
AUTH_POLICY_NONCE_FILE="${LANQIN_AUTH_POLICY_NONCE_FILE:-/data/dovecot-auth-policy-nonce}"
|
||||
mkdir -p "$(dirname "$AUTH_POLICY_NONCE_FILE")"
|
||||
if [ ! -s "$AUTH_POLICY_NONCE_FILE" ]; then
|
||||
od -An -tx1 -N32 /dev/urandom | tr -d ' \n' > "$AUTH_POLICY_NONCE_FILE"
|
||||
fi
|
||||
chmod 600 "$AUTH_POLICY_NONCE_FILE" 2>/dev/null || true
|
||||
AUTH_POLICY_HASH_NONCE="$(cat "$AUTH_POLICY_NONCE_FILE")"
|
||||
TLS_CERT=/etc/ssl/certs/ssl-cert-snakeoil.pem
|
||||
TLS_KEY=/etc/ssl/private/ssl-cert-snakeoil.key
|
||||
if [ -n "$LANQIN_TLS_CERT_FILE" ] || [ -n "$LANQIN_TLS_KEY_FILE" ]; then
|
||||
@@ -18,4 +25,5 @@ if [ -n "$LANQIN_TLS_CERT_FILE" ] || [ -n "$LANQIN_TLS_KEY_FILE" ]; then
|
||||
fi
|
||||
sed -i "s#^ssl_cert = <.*#ssl_cert = <${TLS_CERT}#" /etc/dovecot/dovecot.conf
|
||||
sed -i "s#^ssl_key = <.*#ssl_key = <${TLS_KEY}#" /etc/dovecot/dovecot.conf
|
||||
sed -i "s#^auth_policy_hash_nonce = .*#auth_policy_hash_nonce = ${AUTH_POLICY_HASH_NONCE}#" /etc/dovecot/dovecot.conf
|
||||
exec dovecot -F
|
||||
|
||||
@@ -11,5 +11,5 @@ COPY master.cf /etc/postfix/master.cf
|
||||
COPY sqlite-*.cf /etc/postfix/
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
EXPOSE 25 465 587
|
||||
EXPOSE 25
|
||||
CMD ["/entrypoint.sh"]
|
||||
|
||||
@@ -14,13 +14,11 @@ virtual_transport = lmtp:inet:dovecot:24
|
||||
virtual_mailbox_base = /var/mail/vhosts
|
||||
|
||||
smtpd_banner = $myhostname ESMTP LanQin Email
|
||||
smtpd_relay_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination
|
||||
smtpd_recipient_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination
|
||||
smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination
|
||||
smtpd_recipient_restrictions = permit_mynetworks, reject_unauth_destination
|
||||
|
||||
# Submission auth via Dovecot.
|
||||
smtpd_sasl_type = dovecot
|
||||
smtpd_sasl_path = inet:dovecot:12345
|
||||
smtpd_sasl_auth_enable = yes
|
||||
# 465/587 提交由 LanQin API 处理;Postfix 25 只负责入站和内部 relay。
|
||||
smtpd_sasl_auth_enable = no
|
||||
smtpd_tls_cert_file = /etc/ssl/certs/ssl-cert-snakeoil.pem
|
||||
smtpd_tls_key_file = /etc/ssl/private/ssl-cert-snakeoil.key
|
||||
smtpd_tls_security_level = may
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
smtp inet n - n - - smtpd
|
||||
submission inet n - n - - smtpd
|
||||
-o syslog_name=postfix/submission
|
||||
-o smtpd_tls_security_level=may
|
||||
-o smtpd_sasl_auth_enable=yes
|
||||
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
|
||||
-o sender_bcc_maps=sqlite:/etc/postfix/sqlite-sender-bcc.cf
|
||||
smtps inet n - n - - smtpd
|
||||
-o syslog_name=postfix/smtps
|
||||
-o smtpd_tls_wrappermode=yes
|
||||
-o smtpd_sasl_auth_enable=yes
|
||||
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
|
||||
-o sender_bcc_maps=sqlite:/etc/postfix/sqlite-sender-bcc.cf
|
||||
pickup unix n - n 60 1 pickup
|
||||
cleanup unix n - n - 0 cleanup
|
||||
qmgr unix n - n 300 1 qmgr
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
dbpath = /data/lanqin.db
|
||||
query = SELECT local_part || '+Sent@' || substr(address, instr(address, '@') + 1) FROM mailboxes WHERE lower(address)=lower('%s') AND status='active'
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
Reference in New Issue
Block a user