Implement internal mail skill MVP
This commit is contained in:
28
.env.example
Normal file
28
.env.example
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
MAIL_ACCOUNT_ID=default
|
||||||
|
|
||||||
|
POP3_HOST=
|
||||||
|
POP3_PORT=110
|
||||||
|
POP3_USERNAME=
|
||||||
|
POP3_PASSWORD=
|
||||||
|
POP3_USE_TLS=false
|
||||||
|
POP3_TIMEOUT_SECONDS=30
|
||||||
|
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=25
|
||||||
|
SMTP_USERNAME=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_USE_TLS=false
|
||||||
|
SMTP_USE_STARTTLS=false
|
||||||
|
SMTP_AUTH_REQUIRED=false
|
||||||
|
SMTP_ALLOW_INSECURE_AUTH=false
|
||||||
|
SMTP_FROM_ADDRESS=
|
||||||
|
SMTP_FROM_NAME=
|
||||||
|
SMTP_TIMEOUT_SECONDS=30
|
||||||
|
|
||||||
|
DATA_DIR=./data
|
||||||
|
DATABASE_PATH=./data/internal_mail.db
|
||||||
|
RAW_MAIL_DIR=./data/raw
|
||||||
|
ATTACHMENT_DIR=./data/attachments
|
||||||
|
|
||||||
|
HTTP_HOST=127.0.0.1
|
||||||
|
HTTP_PORT=8765
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
dist/
|
||||||
|
data/*
|
||||||
|
!data/.gitkeep
|
||||||
18
Makefile
Normal file
18
Makefile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
.PHONY: test e2e build cross-build clean
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
e2e:
|
||||||
|
go test ./internal/e2e -count=1 -v
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build ./cmd/server
|
||||||
|
|
||||||
|
cross-build:
|
||||||
|
mkdir -p dist
|
||||||
|
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o dist/internal-mail-skill.exe ./cmd/server
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o dist/internal-mail-skill-linux-amd64 ./cmd/server
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf dist server
|
||||||
317
README.md
Normal file
317
README.md
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
# 内网邮箱 Agent Skill MVP
|
||||||
|
|
||||||
|
这是一个本地运行的邮箱 Skill Adapter,面向 Agent 暴露结构化 HTTP 接口,底层使用 POP3 收信、SMTP 发信、SQLite 保存本地索引。
|
||||||
|
|
||||||
|
## 重要边界
|
||||||
|
|
||||||
|
- 本项目是 `POP3 + SMTP` 模式,不依赖 IMAP、网页自动化或邮箱数据库。
|
||||||
|
- POP3 不能可靠同步服务器侧已读、未读、文件夹、标签、归档、草稿状态。
|
||||||
|
- 已读、已处理、归档、删除、已回复、标签、草稿、已发送记录都是本地 SQLite 状态。
|
||||||
|
- SMTP 发出的邮件不一定会出现在 Webmail 的“已发送”里。
|
||||||
|
- 如果将来需要和 Webmail 已发送一致,可以改成调用 Webmail HTTP API,或者发送时 BCC 自己。
|
||||||
|
- 默认绑定 `127.0.0.1`,不要把服务直接暴露到公网或共享网段。
|
||||||
|
- 附件真实路径不会返回给 Agent,接口只返回 `attachment_id` 和附件元数据。
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
复制示例配置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
按内网邮箱配置 POP3 和 SMTP:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
MAIL_ACCOUNT_ID=default
|
||||||
|
|
||||||
|
POP3_HOST=mail.example.local
|
||||||
|
POP3_PORT=110
|
||||||
|
POP3_USERNAME=your-name
|
||||||
|
POP3_PASSWORD=your-password
|
||||||
|
POP3_USE_TLS=false
|
||||||
|
POP3_TIMEOUT_SECONDS=30
|
||||||
|
|
||||||
|
SMTP_HOST=mail.example.local
|
||||||
|
SMTP_PORT=25
|
||||||
|
SMTP_USERNAME=your-name
|
||||||
|
SMTP_PASSWORD=your-password
|
||||||
|
SMTP_USE_TLS=false
|
||||||
|
SMTP_USE_STARTTLS=false
|
||||||
|
SMTP_AUTH_REQUIRED=false
|
||||||
|
SMTP_ALLOW_INSECURE_AUTH=false
|
||||||
|
SMTP_FROM_ADDRESS=your-name@example.local
|
||||||
|
SMTP_FROM_NAME=Your Name
|
||||||
|
SMTP_TIMEOUT_SECONDS=30
|
||||||
|
```
|
||||||
|
|
||||||
|
不要把真实账号密码提交到 Git。服务日志不会打印密码。
|
||||||
|
|
||||||
|
如果内网 SMTP 服务器要求在 25 端口上使用明文 SMTP AUTH,需要显式设置 `SMTP_ALLOW_INSECURE_AUTH=true`。这会把认证信息发送在未加密连接上,只应在可信内网里使用;公网服务商通常应使用 `SMTP_USE_TLS=true` 或 `SMTP_USE_STARTTLS=true`。
|
||||||
|
|
||||||
|
## 启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/server
|
||||||
|
```
|
||||||
|
|
||||||
|
默认监听:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:8765
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以指定配置文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CONFIG_PATH=/path/to/.env go run ./cmd/server
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常用接口示例
|
||||||
|
|
||||||
|
健康检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8765/health
|
||||||
|
```
|
||||||
|
|
||||||
|
从 POP3 同步邮件。同步使用 `UIDL` 去重,不默认删除服务器邮件;单封邮件失败会进入 `errors`,不会中断整次同步:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8765/sync
|
||||||
|
```
|
||||||
|
|
||||||
|
搜索本地邮件,只返回摘要:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://127.0.0.1:8765/emails/search?q=合同&from=alice@example.local&has_attachment=true&limit=20"
|
||||||
|
```
|
||||||
|
|
||||||
|
读取邮件全文:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8765/emails/1
|
||||||
|
```
|
||||||
|
|
||||||
|
读取本地线程:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8765/emails/1/thread
|
||||||
|
```
|
||||||
|
|
||||||
|
修改本地状态,不会同步回 POP3 服务器:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8765/emails/1/mark \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"read":true,"processed":true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
创建标签并应用到邮件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8765/labels \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"name":"待处理","color":"#336699"}'
|
||||||
|
|
||||||
|
curl -X POST http://127.0.0.1:8765/emails/labels/apply \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"email_ids":[1],"label_ids":[1],"op":"add"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
创建草稿:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8765/drafts \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"to":["bob@example.local"],"cc":[],"bcc":[],"subject":"测试","body":"你好"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
列出草稿:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8765/drafts
|
||||||
|
```
|
||||||
|
|
||||||
|
修改草稿:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PATCH http://127.0.0.1:8765/drafts/1 \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"to":["bob@example.local"],"subject":"更新后的主题","body":"更新后的正文"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
发送草稿。Bcc 不写入邮件头,但会参与 SMTP 实际发送;发送成功写入 `sent_messages`,回复邮件会把原邮件本地标记为 `replied=true`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8765/drafts/1/send
|
||||||
|
```
|
||||||
|
|
||||||
|
直接发送新邮件,内部等价于创建草稿并发送:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8765/send \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"to":["bob@example.local"],"bcc":["audit@example.local"],"subject":"直接发送","body":"正文"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
转发邮件。MVP 暂不转发附件,`include_attachments=true` 会返回结构化错误:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8765/emails/1/forward \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"to":["bob@example.local"],"subject":"Fwd: 原邮件","body":"请看下面原文","include_attachments":false}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 数据目录
|
||||||
|
|
||||||
|
默认路径:
|
||||||
|
|
||||||
|
- SQLite: `./data/internal_mail.db`
|
||||||
|
- 原始邮件: `./data/raw`
|
||||||
|
- 附件文件: `./data/attachments`
|
||||||
|
|
||||||
|
这些运行时数据默认不提交到 Git。
|
||||||
|
|
||||||
|
## 构建与测试
|
||||||
|
|
||||||
|
运行测试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
只跑本地端到端测试:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/e2e -count=1 -v
|
||||||
|
```
|
||||||
|
|
||||||
|
`internal/e2e` 会在测试进程里启动最小 fake POP3 和 fake SMTP 服务,然后通过 HTTP API 验证:
|
||||||
|
|
||||||
|
- `GET /health`
|
||||||
|
- `POST /sync`
|
||||||
|
- POP3 `UIDL` 去重
|
||||||
|
- MIME 正文和附件解析
|
||||||
|
- 附件只暴露 `attachment_id`,不暴露真实路径
|
||||||
|
- `GET /emails/search`
|
||||||
|
- `GET /emails/{id}`
|
||||||
|
- 标签创建与应用
|
||||||
|
- 本地已读、已处理、已回复状态
|
||||||
|
- 草稿回复发送
|
||||||
|
- `POST /send` 直接发送
|
||||||
|
- SMTP envelope 包含 To、Cc、Bcc
|
||||||
|
- Bcc 不写入邮件头
|
||||||
|
- 发送成功写入 `sent_messages`
|
||||||
|
- 发送失败写入 `sent_messages(status=failed)`,不误标记草稿为 `sent`
|
||||||
|
|
||||||
|
本机编译:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build ./cmd/server
|
||||||
|
```
|
||||||
|
|
||||||
|
交叉编译 Windows:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p dist
|
||||||
|
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o dist/internal-mail-skill.exe ./cmd/server
|
||||||
|
```
|
||||||
|
|
||||||
|
交叉编译 Linux:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p dist
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o dist/internal-mail-skill-linux-amd64 ./cmd/server
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以使用 Makefile:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test
|
||||||
|
make e2e
|
||||||
|
make build
|
||||||
|
make cross-build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 本地验收建议
|
||||||
|
|
||||||
|
外部邮箱注册经常受风控影响,因此交接前建议先跑本地可重复验收:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./internal/e2e -count=1 -v
|
||||||
|
go test ./...
|
||||||
|
go build ./cmd/server
|
||||||
|
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o dist/internal-mail-skill.exe ./cmd/server
|
||||||
|
```
|
||||||
|
|
||||||
|
本地 E2E 不依赖真实邮箱账号,不会发出公网邮件;它验证的是本项目对 POP3、SMTP、MIME、SQLite 和 HTTP API 的主链路处理能力。
|
||||||
|
|
||||||
|
## 已完成的公网邮箱试测记录
|
||||||
|
|
||||||
|
以下记录用于交接背景,不包含任何密码。
|
||||||
|
|
||||||
|
- VFEmail:POP3 同步成功,SMTP 发送成功,自发自收闭环成功,邮件可通过搜索接口查到。
|
||||||
|
- Runbox:POP3 同步成功;SMTP 587 STARTTLS 和 465 TLS 均返回 `535 Incorrect authentication data`;外部投递到该账号返回 `550 Unknown account`。
|
||||||
|
- GMX、eclipso、Fastmail、Purelymail、mailbox.org:公开 POP3/SMTP 端口握手可达,但注册或试用受风控、手机号、试用限制影响,未形成账号级完整闭环。
|
||||||
|
|
||||||
|
结论:代码主链路已用本地 E2E 和 VFEmail 真实账号闭环验证;内网交接时应重点用公司 POP3/SMTP 账号做最终验证。
|
||||||
|
|
||||||
|
## 内网接手测试清单
|
||||||
|
|
||||||
|
1. 复制 `.env.example` 为 `.env`,填写内网 POP3/SMTP 参数。
|
||||||
|
2. 确认 `HTTP_HOST=127.0.0.1`,不要直接暴露到公网或共享网段。
|
||||||
|
3. 启动服务:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/server
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 健康检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8765/health
|
||||||
|
```
|
||||||
|
|
||||||
|
5. 同步邮件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://127.0.0.1:8765/sync
|
||||||
|
```
|
||||||
|
|
||||||
|
6. 再次同步,确认重复邮件进入 `skipped`,不会重复入库。
|
||||||
|
7. 搜索一封已知邮件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "http://127.0.0.1:8765/emails/search?q=关键字&limit=10"
|
||||||
|
```
|
||||||
|
|
||||||
|
8. 读取邮件全文,确认正文、附件元数据和本地状态正常。
|
||||||
|
9. 创建草稿并发送到测试收件人。
|
||||||
|
10. 如果是回复邮件,确认 `In-Reply-To`、`References` 尽量写入,且原邮件本地 `replied=true`。
|
||||||
|
11. 如果 SMTP 发出后 Webmail 的“已发送”里看不到,这是 POP3+SMTP 模式的正常限制;需要一致性时可 BCC 自己或后续接 Webmail HTTP API。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### SMTP 25 端口能连但发不出去
|
||||||
|
|
||||||
|
确认内网服务器是否需要 SMTP AUTH。如果需要明文 AUTH,必须显式设置:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
SMTP_AUTH_REQUIRED=true
|
||||||
|
SMTP_ALLOW_INSECURE_AUTH=true
|
||||||
|
```
|
||||||
|
|
||||||
|
公网邮箱通常不应使用明文 AUTH,应使用 `SMTP_USE_TLS=true` 或 `SMTP_USE_STARTTLS=true`。
|
||||||
|
|
||||||
|
### 同步后 Webmail 里还是未读
|
||||||
|
|
||||||
|
这是预期行为。POP3 无法可靠同步服务器侧已读、标签、归档、文件夹等状态;本项目只维护本地状态。
|
||||||
|
|
||||||
|
### SMTP 发出的邮件没有出现在 Webmail 已发送
|
||||||
|
|
||||||
|
这是预期行为。SMTP 只负责投递,不一定写入 Webmail 已发送文件夹。需要一致性时,可让系统 BCC 自己,或者后续接入 Webmail HTTP API。
|
||||||
|
|
||||||
|
### 附件在哪里
|
||||||
|
|
||||||
|
附件真实路径只保存在 SQLite 中,不通过 API 返回给 Agent。API 只返回 `attachment_id`、文件名、类型和大小。
|
||||||
97
cmd/server/main.go
Normal file
97
cmd/server/main.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"internal-mail-skill/internal/api"
|
||||||
|
"internal-mail-skill/internal/config"
|
||||||
|
"internal-mail-skill/internal/db"
|
||||||
|
"internal-mail-skill/internal/pop3"
|
||||||
|
"internal-mail-skill/internal/service"
|
||||||
|
mailsmtp "internal-mail-skill/internal/smtp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := run(); err != nil {
|
||||||
|
slog.Error("server stopped", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
cfgPath := os.Getenv("CONFIG_PATH")
|
||||||
|
if cfgPath == "" {
|
||||||
|
cfgPath = ".env"
|
||||||
|
}
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := ensureDataDirs(cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := db.Open(cfg.Data.DatabasePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := store.Migrate(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
popFetcher := pop3.NewFetcher(cfg.POP3)
|
||||||
|
smtpSender := mailsmtp.NewSender(cfg.SMTP)
|
||||||
|
mailService := service.NewWithOptions(store, popFetcher, smtpSender, cfg.AccountID, service.Options{
|
||||||
|
RawDir: cfg.Data.RawMailDir,
|
||||||
|
AttachmentDir: cfg.Data.AttachmentDir,
|
||||||
|
FromAddress: cfg.SMTP.FromAddress,
|
||||||
|
FromName: cfg.SMTP.FromName,
|
||||||
|
})
|
||||||
|
apiServer := api.New(cfg, store, mailService)
|
||||||
|
|
||||||
|
httpServer := &http.Server{
|
||||||
|
Addr: api.ListenAddr(cfg),
|
||||||
|
Handler: apiServer.Handler(),
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
slog.Info("internal mail skill listening", "addr", httpServer.Addr, "account_id", cfg.AccountID)
|
||||||
|
errCh <- httpServer.ListenAndServe()
|
||||||
|
}()
|
||||||
|
|
||||||
|
stopCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(stopCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
select {
|
||||||
|
case sig := <-stopCh:
|
||||||
|
slog.Info("shutdown requested", "signal", sig.String())
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return httpServer.Shutdown(shutdownCtx)
|
||||||
|
case err := <-errCh:
|
||||||
|
if errors.Is(err, http.ErrServerClosed) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureDataDirs(cfg *config.Config) error {
|
||||||
|
for _, dir := range []string{cfg.Data.DataDir, cfg.Data.RawMailDir, cfg.Data.AttachmentDir} {
|
||||||
|
if err := os.MkdirAll(dir, 0750); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
1
data/.gitkeep
Normal file
1
data/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
24
go.mod
Normal file
24
go.mod
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
module internal-mail-skill
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/knadh/go-pop3 v1.0.2
|
||||||
|
golang.org/x/net v0.47.0
|
||||||
|
modernc.org/sqlite v1.40.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/emersion/go-message v0.18.2 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
|
golang.org/x/text v0.31.0 // indirect
|
||||||
|
modernc.org/libc v1.66.10 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
88
go.sum
Normal file
88
go.sum
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
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-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
|
||||||
|
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/knadh/go-pop3 v1.0.2 h1:gbdtwzEYedLVos/vpebM2d73NTyZxEgjgRJ4S77HlzM=
|
||||||
|
github.com/knadh/go-pop3 v1.0.2/go.mod h1:3gKw2jmrEa1lYLVtP1yEoo6bkkJ4XHDySPy8xaSjG0s=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
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/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||||
|
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.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||||
|
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||||
|
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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
|
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||||
|
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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||||
|
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
|
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||||
|
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||||
|
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.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||||
|
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||||
|
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||||
|
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||||
|
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||||
|
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||||
|
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
|
||||||
|
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
331
internal/api/api.go
Normal file
331
internal/api/api.go
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"internal-mail-skill/internal/config"
|
||||||
|
"internal-mail-skill/internal/db"
|
||||||
|
"internal-mail-skill/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Version = "0.1.0"
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
cfg *config.Config
|
||||||
|
store *db.Store
|
||||||
|
svc *service.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg *config.Config, store *db.Store, svc *service.Service) *Server {
|
||||||
|
return &Server{cfg: cfg, store: store, svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) Handler() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /health", s.health)
|
||||||
|
mux.HandleFunc("POST /sync", s.sync)
|
||||||
|
mux.HandleFunc("GET /emails/search", s.searchEmails)
|
||||||
|
mux.HandleFunc("GET /emails/{id}", s.getEmail)
|
||||||
|
mux.HandleFunc("GET /emails/{id}/thread", s.getThread)
|
||||||
|
mux.HandleFunc("POST /emails/{id}/mark", s.markEmail)
|
||||||
|
mux.HandleFunc("POST /labels", s.createLabel)
|
||||||
|
mux.HandleFunc("POST /emails/labels/apply", s.applyLabels)
|
||||||
|
mux.HandleFunc("POST /drafts", s.createDraft)
|
||||||
|
mux.HandleFunc("GET /drafts", s.listDrafts)
|
||||||
|
mux.HandleFunc("PATCH /drafts/{id}", s.updateDraft)
|
||||||
|
mux.HandleFunc("POST /drafts/{id}/send", s.sendDraft)
|
||||||
|
mux.HandleFunc("POST /send", s.sendDirect)
|
||||||
|
mux.HandleFunc("POST /emails/{id}/forward", s.forwardEmail)
|
||||||
|
return withJSON(mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
|
||||||
|
dbStatus := "ok"
|
||||||
|
if err := s.store.Ping(r.Context()); err != nil {
|
||||||
|
dbStatus = "down"
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"status": dbStatus,
|
||||||
|
"version": Version,
|
||||||
|
"database": dbStatus,
|
||||||
|
"account_id": s.cfg.AccountID,
|
||||||
|
"time": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) sync(w http.ResponseWriter, r *http.Request) {
|
||||||
|
result, err := s.svc.Sync(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadGateway, "sync_failed", err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) searchEmails(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
filters := service.SearchFilters{
|
||||||
|
Q: q.Get("q"),
|
||||||
|
From: q.Get("from"),
|
||||||
|
To: q.Get("to"),
|
||||||
|
After: q.Get("after"),
|
||||||
|
Before: q.Get("before"),
|
||||||
|
Limit: intQuery(q.Get("limit"), 50),
|
||||||
|
Offset: intQuery(q.Get("offset"), 0),
|
||||||
|
}
|
||||||
|
filters.HasAttachment = boolQuery(q.Get("has_attachment"))
|
||||||
|
filters.Read = boolQuery(q.Get("read"))
|
||||||
|
filters.Processed = boolQuery(q.Get("processed"))
|
||||||
|
filters.Archived = boolQuery(q.Get("archived"))
|
||||||
|
filters.Replied = boolQuery(q.Get("replied"))
|
||||||
|
results, err := s.svc.SearchEmails(r.Context(), filters)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "search_failed", err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"emails": results, "limit": filters.Limit, "offset": filters.Offset})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
email, err := s.svc.GetEmail(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
handleServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, email)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getThread(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
thread, err := s.svc.GetThread(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
handleServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"thread": thread})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) markEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input service.MarkInput
|
||||||
|
if !decodeBody(w, r, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.svc.MarkEmail(r.Context(), id, input); err != nil {
|
||||||
|
handleServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createLabel(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Color string `json:"color"`
|
||||||
|
}
|
||||||
|
if !decodeBody(w, r, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
label, err := s.svc.CreateLabel(r.Context(), input.Name, input.Color)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "label_failed", err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) applyLabels(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input struct {
|
||||||
|
EmailIDs []int64 `json:"email_ids"`
|
||||||
|
LabelIDs []int64 `json:"label_ids"`
|
||||||
|
Op string `json:"op"`
|
||||||
|
}
|
||||||
|
if !decodeBody(w, r, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.svc.ApplyLabels(r.Context(), input.EmailIDs, input.LabelIDs, input.Op); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "label_apply_failed", err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) createDraft(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input service.DraftInput
|
||||||
|
if !decodeBody(w, r, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draft, err := s.svc.CreateDraft(r.Context(), input)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "draft_create_failed", err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, draft)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) listDrafts(w http.ResponseWriter, r *http.Request) {
|
||||||
|
drafts, err := s.svc.ListDrafts(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "draft_list_failed", err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"drafts": drafts})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) updateDraft(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input service.DraftInput
|
||||||
|
if !decodeBody(w, r, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draft, err := s.svc.UpdateDraft(r.Context(), id, input)
|
||||||
|
if err != nil {
|
||||||
|
handleServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, draft)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) sendDraft(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
outcome, err := s.svc.SendDraft(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadGateway, "send_failed", err.Error(), outcome)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) sendDirect(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var input service.DraftInput
|
||||||
|
if !decodeBody(w, r, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
outcome, err := s.svc.SendDirect(r.Context(), input)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadGateway, "send_failed", err.Error(), outcome)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) forwardEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := pathID(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input service.ForwardInput
|
||||||
|
if !decodeBody(w, r, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
outcome, err := s.svc.ForwardEmail(r.Context(), id, input)
|
||||||
|
if err != nil {
|
||||||
|
handleServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeBody(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||||
|
defer r.Body.Close()
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_json", err.Error(), nil)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_id", "id must be a positive integer", nil)
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func intQuery(value string, def int) int {
|
||||||
|
if value == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(value)
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolQuery(value string) *bool {
|
||||||
|
if value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch value {
|
||||||
|
case "1", "true", "TRUE", "yes", "on":
|
||||||
|
v := true
|
||||||
|
return &v
|
||||||
|
case "0", "false", "FALSE", "no", "off":
|
||||||
|
v := false
|
||||||
|
return &v
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleServiceError(w http.ResponseWriter, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrNotFound):
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", err.Error(), nil)
|
||||||
|
case errors.Is(err, service.ErrAttachmentsForwardUnsupported):
|
||||||
|
writeError(w, http.StatusBadRequest, "attachments_forward_unsupported", err.Error(), nil)
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", err.Error(), nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, code, message string, details any) {
|
||||||
|
writeJSON(w, status, map[string]any{
|
||||||
|
"error": map[string]any{
|
||||||
|
"code": code,
|
||||||
|
"message": message,
|
||||||
|
"details": details,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func withJSON(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ListenAddr(cfg *config.Config) string {
|
||||||
|
return fmt.Sprintf("%s:%d", cfg.HTTP.Host, cfg.HTTP.Port)
|
||||||
|
}
|
||||||
177
internal/config/config.go
Normal file
177
internal/config/config.go
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
AccountID string
|
||||||
|
POP3 POP3Config
|
||||||
|
SMTP SMTPConfig
|
||||||
|
Data DataConfig
|
||||||
|
HTTP HTTPConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type POP3Config struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
UseTLS bool
|
||||||
|
TimeoutSeconds int
|
||||||
|
}
|
||||||
|
|
||||||
|
type SMTPConfig struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
UseTLS bool
|
||||||
|
UseSTARTTLS bool
|
||||||
|
AuthRequired bool
|
||||||
|
AllowInsecureAuth bool
|
||||||
|
FromAddress string
|
||||||
|
FromName string
|
||||||
|
TimeoutSeconds int
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataConfig struct {
|
||||||
|
DataDir string
|
||||||
|
DatabasePath string
|
||||||
|
RawMailDir string
|
||||||
|
AttachmentDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTTPConfig struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c POP3Config) Timeout() time.Duration {
|
||||||
|
return time.Duration(c.TimeoutSeconds) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c SMTPConfig) Timeout() time.Duration {
|
||||||
|
return time.Duration(c.TimeoutSeconds) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load(envPath string) (*Config, error) {
|
||||||
|
values := map[string]string{}
|
||||||
|
if envPath == "" {
|
||||||
|
envPath = ".env"
|
||||||
|
}
|
||||||
|
if err := readEnvFile(envPath, values); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, item := range os.Environ() {
|
||||||
|
key, val, ok := strings.Cut(item, "=")
|
||||||
|
if ok {
|
||||||
|
values[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dataDir := getString(values, "DATA_DIR", "./data")
|
||||||
|
cfg := &Config{
|
||||||
|
AccountID: getString(values, "MAIL_ACCOUNT_ID", "default"),
|
||||||
|
POP3: POP3Config{
|
||||||
|
Host: getString(values, "POP3_HOST", ""),
|
||||||
|
Port: getInt(values, "POP3_PORT", 110),
|
||||||
|
Username: getString(values, "POP3_USERNAME", ""),
|
||||||
|
Password: getString(values, "POP3_PASSWORD", ""),
|
||||||
|
UseTLS: getBool(values, "POP3_USE_TLS", false),
|
||||||
|
TimeoutSeconds: getInt(values, "POP3_TIMEOUT_SECONDS", 30),
|
||||||
|
},
|
||||||
|
SMTP: SMTPConfig{
|
||||||
|
Host: getString(values, "SMTP_HOST", ""),
|
||||||
|
Port: getInt(values, "SMTP_PORT", 25),
|
||||||
|
Username: getString(values, "SMTP_USERNAME", ""),
|
||||||
|
Password: getString(values, "SMTP_PASSWORD", ""),
|
||||||
|
UseTLS: getBool(values, "SMTP_USE_TLS", false),
|
||||||
|
UseSTARTTLS: getBool(values, "SMTP_USE_STARTTLS", false),
|
||||||
|
AuthRequired: getBool(values, "SMTP_AUTH_REQUIRED", false),
|
||||||
|
AllowInsecureAuth: getBool(values, "SMTP_ALLOW_INSECURE_AUTH", false),
|
||||||
|
FromAddress: getString(values, "SMTP_FROM_ADDRESS", ""),
|
||||||
|
FromName: getString(values, "SMTP_FROM_NAME", ""),
|
||||||
|
TimeoutSeconds: getInt(values, "SMTP_TIMEOUT_SECONDS", 30),
|
||||||
|
},
|
||||||
|
Data: DataConfig{
|
||||||
|
DataDir: dataDir,
|
||||||
|
DatabasePath: getString(values, "DATABASE_PATH", filepath.Join(dataDir, "internal_mail.db")),
|
||||||
|
RawMailDir: getString(values, "RAW_MAIL_DIR", filepath.Join(dataDir, "raw")),
|
||||||
|
AttachmentDir: getString(values, "ATTACHMENT_DIR", filepath.Join(dataDir, "attachments")),
|
||||||
|
},
|
||||||
|
HTTP: HTTPConfig{
|
||||||
|
Host: getString(values, "HTTP_HOST", "127.0.0.1"),
|
||||||
|
Port: getInt(values, "HTTP_PORT", 8765),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readEnvFile(path string, values map[string]string) error {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("read env file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, value, ok := strings.Cut(line, "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
value = strings.Trim(value, `"'`)
|
||||||
|
values[key] = value
|
||||||
|
}
|
||||||
|
return scanner.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getString(values map[string]string, key, def string) string {
|
||||||
|
if v, ok := values[key]; ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func getInt(values map[string]string, key string, def int) int {
|
||||||
|
v, ok := values[key]
|
||||||
|
if !ok || strings.TrimSpace(v) == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBool(values map[string]string, key string, def bool) bool {
|
||||||
|
v, ok := values[key]
|
||||||
|
if !ok || strings.TrimSpace(v) == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||||
|
case "1", "true", "yes", "y", "on":
|
||||||
|
return true
|
||||||
|
case "0", "false", "no", "n", "off":
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
}
|
||||||
42
internal/config/config_test.go
Normal file
42
internal/config/config_test.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadDefaultsAndEnvFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
envPath := filepath.Join(dir, ".env")
|
||||||
|
if err := os.WriteFile(envPath, []byte("POP3_HOST=mail.local\nSMTP_PORT=2525\nSMTP_AUTH_REQUIRED=true\nSMTP_ALLOW_INSECURE_AUTH=true\nDATA_DIR="+dir+"\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := Load(envPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.AccountID != "default" {
|
||||||
|
t.Fatalf("AccountID = %q, want default", cfg.AccountID)
|
||||||
|
}
|
||||||
|
if cfg.POP3.Host != "mail.local" {
|
||||||
|
t.Fatalf("POP3.Host = %q", cfg.POP3.Host)
|
||||||
|
}
|
||||||
|
if cfg.POP3.Port != 110 {
|
||||||
|
t.Fatalf("POP3.Port = %d, want 110", cfg.POP3.Port)
|
||||||
|
}
|
||||||
|
if cfg.SMTP.Port != 2525 {
|
||||||
|
t.Fatalf("SMTP.Port = %d, want 2525", cfg.SMTP.Port)
|
||||||
|
}
|
||||||
|
if !cfg.SMTP.AuthRequired {
|
||||||
|
t.Fatal("SMTP.AuthRequired = false, want true")
|
||||||
|
}
|
||||||
|
if !cfg.SMTP.AllowInsecureAuth {
|
||||||
|
t.Fatal("SMTP.AllowInsecureAuth = false, want true")
|
||||||
|
}
|
||||||
|
if cfg.HTTP.Host != "127.0.0.1" || cfg.HTTP.Port != 8765 {
|
||||||
|
t.Fatalf("HTTP default = %s:%d", cfg.HTTP.Host, cfg.HTTP.Port)
|
||||||
|
}
|
||||||
|
}
|
||||||
164
internal/db/db.go
Normal file
164
internal/db/db.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func Open(path string) (*Store, error) {
|
||||||
|
if path != ":memory:" {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil {
|
||||||
|
return nil, fmt.Errorf("create database directory: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, fmt.Errorf("enable foreign keys: %w", err)
|
||||||
|
}
|
||||||
|
if path == ":memory:" {
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
}
|
||||||
|
return &Store{DB: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Close() error {
|
||||||
|
if s == nil || s.DB == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.DB.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Ping(ctx context.Context) error {
|
||||||
|
return s.DB.PingContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) Migrate(ctx context.Context) error {
|
||||||
|
_, err := s.DB.ExecContext(ctx, schemaSQL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("migrate sqlite: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const schemaSQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS mail_messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
account_id TEXT NOT NULL,
|
||||||
|
pop3_uid TEXT NOT NULL,
|
||||||
|
message_id_header TEXT,
|
||||||
|
in_reply_to TEXT,
|
||||||
|
references_header TEXT,
|
||||||
|
subject TEXT,
|
||||||
|
sender_name TEXT,
|
||||||
|
sender_email TEXT,
|
||||||
|
to_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
cc_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
date_header TEXT,
|
||||||
|
sent_at TEXT,
|
||||||
|
received_at TEXT NOT NULL,
|
||||||
|
body_text TEXT,
|
||||||
|
body_html TEXT,
|
||||||
|
raw_eml_path TEXT,
|
||||||
|
has_attachment INTEGER NOT NULL DEFAULT 0,
|
||||||
|
local_read INTEGER NOT NULL DEFAULT 0,
|
||||||
|
local_processed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
local_archived INTEGER NOT NULL DEFAULT 0,
|
||||||
|
local_deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
local_replied INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE(account_id, pop3_uid)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mail_messages_account_uid ON mail_messages(account_id, pop3_uid);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mail_messages_message_id ON mail_messages(message_id_header);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mail_messages_sender ON mail_messages(sender_email);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mail_messages_sent_at ON mail_messages(sent_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mail_messages_status ON mail_messages(local_read, local_processed, local_archived, local_deleted, local_replied);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS attachments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
message_id INTEGER NOT NULL,
|
||||||
|
file_name TEXT NOT NULL,
|
||||||
|
content_type TEXT,
|
||||||
|
content_id TEXT,
|
||||||
|
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
storage_path TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(message_id) REFERENCES mail_messages(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS labels (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
color TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS message_labels (
|
||||||
|
message_id INTEGER NOT NULL,
|
||||||
|
label_id INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(message_id, label_id),
|
||||||
|
FOREIGN KEY(message_id) REFERENCES mail_messages(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(label_id) REFERENCES labels(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_message_labels_label ON message_labels(label_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS drafts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
to_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
cc_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
bcc_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
subject TEXT,
|
||||||
|
body TEXT,
|
||||||
|
reply_to_email_id INTEGER,
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
last_error TEXT,
|
||||||
|
sent_message_id INTEGER,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(reply_to_email_id) REFERENCES mail_messages(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_drafts_status ON drafts(status);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sent_messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
draft_id INTEGER,
|
||||||
|
to_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
cc_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
bcc_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
subject TEXT,
|
||||||
|
body TEXT,
|
||||||
|
message_id_header TEXT,
|
||||||
|
in_reply_to TEXT,
|
||||||
|
references_header TEXT,
|
||||||
|
smtp_from TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
error_message TEXT,
|
||||||
|
sent_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(draft_id) REFERENCES drafts(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sent_messages_draft ON sent_messages(draft_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sent_messages_status ON sent_messages(status);
|
||||||
|
`
|
||||||
33
internal/db/db_test.go
Normal file
33
internal/db/db_test.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOpenAndMigrateCreatesCoreTables(t *testing.T) {
|
||||||
|
store, err := Open(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open() error = %v", err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
|
||||||
|
if err := store.Migrate(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Migrate() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var name string
|
||||||
|
err = store.DB.QueryRowContext(context.Background(), "SELECT name FROM sqlite_master WHERE type='table' AND name='mail_messages'").Scan(&name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mail_messages table missing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = store.DB.QueryRowContext(context.Background(), "SELECT name FROM sqlite_master WHERE type='table' AND name='sent_messages'").Scan(&name)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
t.Fatalf("sent_messages lookup failed: %v", err)
|
||||||
|
}
|
||||||
|
if name != "sent_messages" {
|
||||||
|
t.Fatalf("sent_messages table missing, got %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
571
internal/e2e/local_e2e_test.go
Normal file
571
internal/e2e/local_e2e_test.go
Normal file
@@ -0,0 +1,571 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/textproto"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"internal-mail-skill/internal/api"
|
||||||
|
"internal-mail-skill/internal/config"
|
||||||
|
"internal-mail-skill/internal/db"
|
||||||
|
"internal-mail-skill/internal/pop3"
|
||||||
|
"internal-mail-skill/internal/service"
|
||||||
|
mailsmtp "internal-mail-skill/internal/smtp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLocalPOP3SMTPHTTPFlow(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Date(2026, 6, 6, 9, 30, 0, 0, time.UTC)
|
||||||
|
raw := buildTestMessage(t, now)
|
||||||
|
popServer := newFakePOP3Server(t, []popMessage{{uid: "uid-local-1", raw: raw}})
|
||||||
|
smtpServer := newFakeSMTPServer(t)
|
||||||
|
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
store, err := db.Open(filepath.Join(dataDir, "mail.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = store.Close() })
|
||||||
|
if err := store.Migrate(ctx); err != nil {
|
||||||
|
t.Fatalf("migrate db: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
AccountID: "local-e2e",
|
||||||
|
POP3: config.POP3Config{
|
||||||
|
Host: popServer.host,
|
||||||
|
Port: popServer.port,
|
||||||
|
Username: "local-user",
|
||||||
|
Password: "local-pass",
|
||||||
|
TimeoutSeconds: 5,
|
||||||
|
},
|
||||||
|
SMTP: config.SMTPConfig{
|
||||||
|
Host: smtpServer.host,
|
||||||
|
Port: smtpServer.port,
|
||||||
|
FromAddress: "agent@example.local",
|
||||||
|
FromName: "Local Agent",
|
||||||
|
TimeoutSeconds: 5,
|
||||||
|
AuthRequired: true,
|
||||||
|
Username: "smtp-user",
|
||||||
|
Password: "smtp-pass",
|
||||||
|
AllowInsecureAuth: true,
|
||||||
|
},
|
||||||
|
Data: config.DataConfig{
|
||||||
|
DataDir: dataDir,
|
||||||
|
DatabasePath: filepath.Join(dataDir, "mail.db"),
|
||||||
|
RawMailDir: filepath.Join(dataDir, "raw"),
|
||||||
|
AttachmentDir: filepath.Join(dataDir, "attachments"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
svc := service.NewWithOptions(
|
||||||
|
store,
|
||||||
|
pop3.NewFetcher(cfg.POP3),
|
||||||
|
mailsmtp.NewSender(cfg.SMTP),
|
||||||
|
cfg.AccountID,
|
||||||
|
service.Options{RawDir: cfg.Data.RawMailDir, AttachmentDir: cfg.Data.AttachmentDir, FromAddress: cfg.SMTP.FromAddress, FromName: cfg.SMTP.FromName},
|
||||||
|
)
|
||||||
|
server := httptest.NewServer(api.New(cfg, store, svc).Handler())
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
health := getJSON[map[string]any](t, server.URL+"/health")
|
||||||
|
if health["status"] != "ok" || health["database"] != "ok" {
|
||||||
|
t.Fatalf("unexpected health: %#v", health)
|
||||||
|
}
|
||||||
|
|
||||||
|
syncResult := postJSON[map[string]any](t, server.URL+"/sync", nil, http.StatusOK)
|
||||||
|
if got := int(syncResult["stored"].(float64)); got != 1 {
|
||||||
|
t.Fatalf("stored = %d, want 1; result=%#v", got, syncResult)
|
||||||
|
}
|
||||||
|
syncAgain := postJSON[map[string]any](t, server.URL+"/sync", nil, http.StatusOK)
|
||||||
|
if got := int(syncAgain["skipped"].(float64)); got != 1 {
|
||||||
|
t.Fatalf("skipped after second sync = %d, want 1; result=%#v", got, syncAgain)
|
||||||
|
}
|
||||||
|
|
||||||
|
search := getJSON[map[string]any](t, server.URL+"/emails/search?q=Quarterly+Report&has_attachment=true")
|
||||||
|
emails := search["emails"].([]any)
|
||||||
|
if len(emails) != 1 {
|
||||||
|
t.Fatalf("search returned %d emails, want 1: %#v", len(emails), search)
|
||||||
|
}
|
||||||
|
emailID := int64(emails[0].(map[string]any)["id"].(float64))
|
||||||
|
|
||||||
|
detail := getJSON[map[string]any](t, fmt.Sprintf("%s/emails/%d", server.URL, emailID))
|
||||||
|
if detail["subject"] != "Quarterly Report" {
|
||||||
|
t.Fatalf("subject = %q", detail["subject"])
|
||||||
|
}
|
||||||
|
if !strings.Contains(detail["body_text"].(string), "The report is attached.") {
|
||||||
|
t.Fatalf("body_text missing expected content: %q", detail["body_text"])
|
||||||
|
}
|
||||||
|
attachments := detail["attachments"].([]any)
|
||||||
|
if len(attachments) != 1 {
|
||||||
|
t.Fatalf("attachments = %d, want 1", len(attachments))
|
||||||
|
}
|
||||||
|
if _, ok := attachments[0].(map[string]any)["attachment_id"]; !ok {
|
||||||
|
t.Fatalf("attachment id not exposed in detail: %#v", attachments[0])
|
||||||
|
}
|
||||||
|
if _, ok := attachments[0].(map[string]any)["storage_path"]; ok {
|
||||||
|
t.Fatalf("attachment storage path leaked: %#v", attachments[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
label := postJSON[map[string]any](t, server.URL+"/labels", map[string]any{"name": "Local E2E", "color": "#336699"}, http.StatusCreated)
|
||||||
|
postJSON[map[string]any](t, server.URL+"/emails/labels/apply", map[string]any{
|
||||||
|
"email_ids": []int64{emailID},
|
||||||
|
"label_ids": []int64{int64(label["id"].(float64))},
|
||||||
|
"op": "add",
|
||||||
|
}, http.StatusOK)
|
||||||
|
postJSON[map[string]any](t, fmt.Sprintf("%s/emails/%d/mark", server.URL, emailID), map[string]any{"read": true, "processed": true}, http.StatusOK)
|
||||||
|
|
||||||
|
marked := getJSON[map[string]any](t, fmt.Sprintf("%s/emails/%d", server.URL, emailID))
|
||||||
|
status := marked["local_status"].(map[string]any)
|
||||||
|
if status["read"] != true || status["processed"] != true {
|
||||||
|
t.Fatalf("local status not updated: %#v", status)
|
||||||
|
}
|
||||||
|
if got := len(marked["labels"].([]any)); got != 1 {
|
||||||
|
t.Fatalf("labels after apply = %d, want 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
draft := postJSON[map[string]any](t, server.URL+"/drafts", map[string]any{
|
||||||
|
"to": []string{"recipient@example.local"},
|
||||||
|
"cc": []string{"copy@example.local"},
|
||||||
|
"bcc": []string{"audit@example.local"},
|
||||||
|
"subject": "Re: Quarterly Report",
|
||||||
|
"body": "Thanks, received.",
|
||||||
|
"reply_to_email_id": emailID,
|
||||||
|
}, http.StatusCreated)
|
||||||
|
outcome := postJSON[map[string]any](t, fmt.Sprintf("%s/drafts/%d/send", server.URL, int64(draft["id"].(float64))), nil, http.StatusOK)
|
||||||
|
if sent := outcome["sent_message"].(map[string]any); sent["status"] != "sent" {
|
||||||
|
t.Fatalf("draft send status = %#v", sent)
|
||||||
|
}
|
||||||
|
|
||||||
|
sentMessages := smtpServer.messages()
|
||||||
|
if len(sentMessages) != 1 {
|
||||||
|
t.Fatalf("smtp captured %d messages, want 1", len(sentMessages))
|
||||||
|
}
|
||||||
|
if !containsAll(sentMessages[0].recipients, []string{"recipient@example.local", "copy@example.local", "audit@example.local"}) {
|
||||||
|
t.Fatalf("smtp recipients = %#v", sentMessages[0].recipients)
|
||||||
|
}
|
||||||
|
rawSent := string(sentMessages[0].data)
|
||||||
|
if strings.Contains(rawSent, "audit@example.local") {
|
||||||
|
t.Fatalf("Bcc recipient leaked into message headers/body:\n%s", rawSent)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rawSent, "In-Reply-To: <local-1@example.local>") {
|
||||||
|
t.Fatalf("reply headers missing from SMTP message:\n%s", rawSent)
|
||||||
|
}
|
||||||
|
|
||||||
|
replied := getJSON[map[string]any](t, fmt.Sprintf("%s/emails/%d", server.URL, emailID))
|
||||||
|
if replied["local_status"].(map[string]any)["replied"] != true {
|
||||||
|
t.Fatalf("original message was not marked replied: %#v", replied["local_status"])
|
||||||
|
}
|
||||||
|
|
||||||
|
postJSON[map[string]any](t, server.URL+"/send", map[string]any{
|
||||||
|
"to": []string{"new@example.local"},
|
||||||
|
"subject": "Direct local send",
|
||||||
|
"body": "Hello from direct send.",
|
||||||
|
}, http.StatusOK)
|
||||||
|
if len(smtpServer.messages()) != 2 {
|
||||||
|
t.Fatalf("smtp captured %d messages after /send, want 2", len(smtpServer.messages()))
|
||||||
|
}
|
||||||
|
|
||||||
|
var sentCount int
|
||||||
|
if err := store.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM sent_messages WHERE status = 'sent'").Scan(&sentCount); err != nil {
|
||||||
|
t.Fatalf("count sent messages: %v", err)
|
||||||
|
}
|
||||||
|
if sentCount != 2 {
|
||||||
|
t.Fatalf("sent_messages rows = %d, want 2", sentCount)
|
||||||
|
}
|
||||||
|
assertNoStoredAttachmentPathInAPI(t, detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTestMessage(t *testing.T, date time.Time) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var body bytes.Buffer
|
||||||
|
writer := multipart.NewWriter(&body)
|
||||||
|
textPart, err := writer.CreatePart(textproto.MIMEHeader{
|
||||||
|
"Content-Type": {"text/plain; charset=utf-8"},
|
||||||
|
"Content-Transfer-Encoding": {"quoted-printable"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create text part: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(textPart, "Hello Agent,\r\nThe report is attached.\r\n")
|
||||||
|
attachmentPart, err := writer.CreatePart(textproto.MIMEHeader{
|
||||||
|
"Content-Type": {"text/plain; charset=utf-8"},
|
||||||
|
"Content-Disposition": {`attachment; filename="../report.txt"`},
|
||||||
|
"Content-Transfer-Encoding": {"base64"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create attachment part: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = io.WriteString(attachmentPart, "cmVwb3J0LWJvZHk=")
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
t.Fatalf("close multipart: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
headers := strings.Join([]string{
|
||||||
|
"Message-ID: <local-1@example.local>",
|
||||||
|
"From: Alice Example <alice@example.local>",
|
||||||
|
"To: Agent <agent@example.local>",
|
||||||
|
"Subject: Quarterly Report",
|
||||||
|
"Date: " + date.Format(time.RFC1123Z),
|
||||||
|
"Content-Type: multipart/mixed; boundary=" + writer.Boundary(),
|
||||||
|
"MIME-Version: 1.0",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
}, "\r\n")
|
||||||
|
return []byte(headers + body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
type popMessage struct {
|
||||||
|
uid string
|
||||||
|
raw []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakePOP3Server struct {
|
||||||
|
host string
|
||||||
|
port int
|
||||||
|
ln net.Listener
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakePOP3Server(t *testing.T, messages []popMessage) *fakePOP3Server {
|
||||||
|
t.Helper()
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen pop3: %v", err)
|
||||||
|
}
|
||||||
|
host, portString, _ := net.SplitHostPort(ln.Addr().String())
|
||||||
|
port, _ := strconv.Atoi(portString)
|
||||||
|
server := &fakePOP3Server{host: host, port: port, ln: ln}
|
||||||
|
t.Cleanup(func() { _ = ln.Close() })
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go handlePOP3Conn(conn, messages)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlePOP3Conn(conn net.Conn, messages []popMessage) {
|
||||||
|
defer conn.Close()
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
reader := bufio.NewReader(conn)
|
||||||
|
writer := bufio.NewWriter(conn)
|
||||||
|
writeLine(writer, "+OK local POP3 ready")
|
||||||
|
for {
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
line = strings.TrimRight(line, "\r\n")
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) == 0 {
|
||||||
|
writeLine(writer, "-ERR empty command")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch strings.ToUpper(parts[0]) {
|
||||||
|
case "USER", "PASS", "NOOP":
|
||||||
|
writeLine(writer, "+OK")
|
||||||
|
case "UIDL":
|
||||||
|
if len(parts) > 1 {
|
||||||
|
idx, _ := strconv.Atoi(parts[1])
|
||||||
|
if idx <= 0 || idx > len(messages) {
|
||||||
|
writeLine(writer, "-ERR no such message")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
writeLine(writer, fmt.Sprintf("+OK %d %s", idx, messages[idx-1].uid))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
writeLine(writer, "+OK uid list follows")
|
||||||
|
for i, msg := range messages {
|
||||||
|
writeLine(writer, fmt.Sprintf("%d %s", i+1, msg.uid))
|
||||||
|
}
|
||||||
|
writeLine(writer, ".")
|
||||||
|
case "RETR":
|
||||||
|
if len(parts) < 2 {
|
||||||
|
writeLine(writer, "-ERR message id required")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx, _ := strconv.Atoi(parts[1])
|
||||||
|
if idx <= 0 || idx > len(messages) {
|
||||||
|
writeLine(writer, "-ERR no such message")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
writeLine(writer, fmt.Sprintf("+OK %d octets", len(messages[idx-1].raw)))
|
||||||
|
writeMultiline(writer, messages[idx-1].raw)
|
||||||
|
case "QUIT":
|
||||||
|
writeLine(writer, "+OK bye")
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
writeLine(writer, "-ERR unsupported command")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeLine(w *bufio.Writer, line string) {
|
||||||
|
_, _ = w.WriteString(line + "\r\n")
|
||||||
|
_ = w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMultiline(w *bufio.Writer, raw []byte) {
|
||||||
|
for _, line := range strings.Split(strings.ReplaceAll(string(raw), "\r\n", "\n"), "\n") {
|
||||||
|
if strings.HasPrefix(line, ".") {
|
||||||
|
line = "." + line
|
||||||
|
}
|
||||||
|
_, _ = w.WriteString(line + "\r\n")
|
||||||
|
}
|
||||||
|
_, _ = w.WriteString(".\r\n")
|
||||||
|
_ = w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
type smtpMessage struct {
|
||||||
|
from string
|
||||||
|
recipients []string
|
||||||
|
data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeSMTPServer struct {
|
||||||
|
host string
|
||||||
|
port int
|
||||||
|
ln net.Listener
|
||||||
|
mu sync.Mutex
|
||||||
|
sent []smtpMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeSMTPServer(t *testing.T) *fakeSMTPServer {
|
||||||
|
t.Helper()
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen smtp: %v", err)
|
||||||
|
}
|
||||||
|
host, portString, _ := net.SplitHostPort(ln.Addr().String())
|
||||||
|
port, _ := strconv.Atoi(portString)
|
||||||
|
server := &fakeSMTPServer{host: host, port: port, ln: ln}
|
||||||
|
t.Cleanup(func() { _ = ln.Close() })
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go server.handleConn(conn)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeSMTPServer) messages() []smtpMessage {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
out := make([]smtpMessage, len(s.sent))
|
||||||
|
copy(out, s.sent)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeSMTPServer) handleConn(conn net.Conn) {
|
||||||
|
defer conn.Close()
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
reader := bufio.NewReader(conn)
|
||||||
|
writer := bufio.NewWriter(conn)
|
||||||
|
writeLine(writer, "220 local SMTP ready")
|
||||||
|
var current smtpMessage
|
||||||
|
for {
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
line = strings.TrimRight(line, "\r\n")
|
||||||
|
upper := strings.ToUpper(line)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(upper, "EHLO ") || strings.HasPrefix(upper, "HELO "):
|
||||||
|
writeLine(writer, "250-localhost")
|
||||||
|
writeLine(writer, "250-AUTH PLAIN LOGIN")
|
||||||
|
writeLine(writer, "250 HELP")
|
||||||
|
case strings.HasPrefix(upper, "AUTH "):
|
||||||
|
writeLine(writer, "235 2.7.0 Authentication successful")
|
||||||
|
case strings.HasPrefix(upper, "MAIL FROM:"):
|
||||||
|
current = smtpMessage{from: extractSMTPPath(line)}
|
||||||
|
writeLine(writer, "250 OK")
|
||||||
|
case strings.HasPrefix(upper, "RCPT TO:"):
|
||||||
|
current.recipients = append(current.recipients, extractSMTPPath(line))
|
||||||
|
writeLine(writer, "250 OK")
|
||||||
|
case upper == "DATA":
|
||||||
|
writeLine(writer, "354 End data with <CR><LF>.<CR><LF>")
|
||||||
|
data, err := readSMTPData(reader)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current.data = data
|
||||||
|
s.mu.Lock()
|
||||||
|
s.sent = append(s.sent, current)
|
||||||
|
s.mu.Unlock()
|
||||||
|
writeLine(writer, "250 OK queued")
|
||||||
|
case upper == "RSET":
|
||||||
|
current = smtpMessage{}
|
||||||
|
writeLine(writer, "250 OK")
|
||||||
|
case upper == "QUIT":
|
||||||
|
writeLine(writer, "221 bye")
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
writeLine(writer, "250 OK")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractSMTPPath(line string) string {
|
||||||
|
_, value, ok := strings.Cut(line, ":")
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
value = strings.TrimPrefix(value, "<")
|
||||||
|
value = strings.TrimSuffix(value, ">")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSMTPData(reader *bufio.Reader) ([]byte, error) {
|
||||||
|
var out bytes.Buffer
|
||||||
|
for {
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if line == ".\r\n" || line == ".\n" {
|
||||||
|
return out.Bytes(), nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "..") {
|
||||||
|
line = line[1:]
|
||||||
|
}
|
||||||
|
out.WriteString(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getJSON[T any](t *testing.T, url string) T {
|
||||||
|
t.Helper()
|
||||||
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new GET request: %v", err)
|
||||||
|
}
|
||||||
|
return doJSON[T](t, req, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func postJSON[T any](t *testing.T, url string, body any, status int) T {
|
||||||
|
t.Helper()
|
||||||
|
var reader io.Reader
|
||||||
|
if body != nil {
|
||||||
|
payload, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal body: %v", err)
|
||||||
|
}
|
||||||
|
reader = bytes.NewReader(payload)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(http.MethodPost, url, reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new POST request: %v", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
return doJSON[T](t, req, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func doJSON[T any](t *testing.T, req *http.Request, status int) T {
|
||||||
|
t.Helper()
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s %s: %v", req.Method, req.URL, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
payload, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read response body: %v", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != status {
|
||||||
|
t.Fatalf("%s %s status=%d want=%d body=%s", req.Method, req.URL, resp.StatusCode, status, payload)
|
||||||
|
}
|
||||||
|
var out T
|
||||||
|
if err := json.Unmarshal(payload, &out); err != nil {
|
||||||
|
t.Fatalf("decode response JSON: %v body=%s", err, payload)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsAll(got, want []string) bool {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, item := range got {
|
||||||
|
seen[item] = true
|
||||||
|
}
|
||||||
|
for _, item := range want {
|
||||||
|
if !seen[item] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNoStoredAttachmentPathInAPI(t *testing.T, detail map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
encoded, err := json.Marshal(detail)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal detail: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(encoded), string(os.PathSeparator)+"attachments"+string(os.PathSeparator)) {
|
||||||
|
t.Fatalf("API response appears to expose attachment storage path: %s", encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSentMessagesTableStoresFailedSendWithoutMarkingDraftSent(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
store, err := db.Open(filepath.Join(dataDir, "mail.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = store.Close() })
|
||||||
|
if err := store.Migrate(ctx); err != nil {
|
||||||
|
t.Fatalf("migrate db: %v", err)
|
||||||
|
}
|
||||||
|
cfg := &config.Config{AccountID: "local-fail"}
|
||||||
|
svc := service.NewWithOptions(store, nil, failingMailer{}, cfg.AccountID, service.Options{FromAddress: "agent@example.local"})
|
||||||
|
server := httptest.NewServer(api.New(cfg, store, svc).Handler())
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
draft := postJSON[map[string]any](t, server.URL+"/drafts", map[string]any{
|
||||||
|
"to": []string{"recipient@example.local"},
|
||||||
|
"subject": "Will fail",
|
||||||
|
"body": "Body",
|
||||||
|
}, http.StatusCreated)
|
||||||
|
postJSON[map[string]any](t, fmt.Sprintf("%s/drafts/%d/send", server.URL, int64(draft["id"].(float64))), nil, http.StatusBadGateway)
|
||||||
|
|
||||||
|
var status, lastError, sentStatus, errorMessage string
|
||||||
|
err = store.DB.QueryRowContext(ctx, `
|
||||||
|
SELECT d.status, COALESCE(d.last_error, ''), sm.status, COALESCE(sm.error_message, '')
|
||||||
|
FROM drafts d JOIN sent_messages sm ON sm.draft_id = d.id
|
||||||
|
WHERE d.id = ?`, int64(draft["id"].(float64))).Scan(&status, &lastError, &sentStatus, &errorMessage)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query failed send rows: %v", err)
|
||||||
|
}
|
||||||
|
if status != "draft" || sentStatus != "failed" || lastError == "" || errorMessage == "" {
|
||||||
|
t.Fatalf("unexpected failed send persistence: draft=%q lastError=%q sent=%q error=%q", status, lastError, sentStatus, errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type failingMailer struct{}
|
||||||
|
|
||||||
|
func (failingMailer) Send(context.Context, mailsmtp.SendRequest) (mailsmtp.SendResult, error) {
|
||||||
|
return mailsmtp.SendResult{}, fmt.Errorf("local smtp failure")
|
||||||
|
}
|
||||||
124
internal/model/model.go
Normal file
124
internal/model/model.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Address struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParsedAttachment struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
ContentID string `json:"content_id,omitempty"`
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
Data []byte `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParsedEmail struct {
|
||||||
|
MessageIDHeader string `json:"message_id_header"`
|
||||||
|
InReplyTo string `json:"in_reply_to"`
|
||||||
|
References string `json:"references_header"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
SenderName string `json:"sender_name"`
|
||||||
|
SenderEmail string `json:"sender_email"`
|
||||||
|
To []Address `json:"to"`
|
||||||
|
Cc []Address `json:"cc"`
|
||||||
|
DateHeader string `json:"date_header"`
|
||||||
|
SentAt *time.Time `json:"sent_at,omitempty"`
|
||||||
|
BodyText string `json:"body_text"`
|
||||||
|
BodyHTML string `json:"body_html"`
|
||||||
|
Attachments []ParsedAttachment `json:"attachments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Attachment struct {
|
||||||
|
ID int64 `json:"attachment_id"`
|
||||||
|
MessageID int64 `json:"-"`
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
ContentID string `json:"content_id,omitempty"`
|
||||||
|
SizeBytes int64 `json:"size_bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Label struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Color string `json:"color,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LocalStatus struct {
|
||||||
|
Read bool `json:"read"`
|
||||||
|
Processed bool `json:"processed"`
|
||||||
|
Archived bool `json:"archived"`
|
||||||
|
Deleted bool `json:"deleted"`
|
||||||
|
Replied bool `json:"replied"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailSummary struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
SenderName string `json:"sender_name"`
|
||||||
|
SenderEmail string `json:"sender_email"`
|
||||||
|
To []Address `json:"to"`
|
||||||
|
Cc []Address `json:"cc,omitempty"`
|
||||||
|
DateHeader string `json:"date"`
|
||||||
|
SentAt *time.Time `json:"sent_at,omitempty"`
|
||||||
|
ReceivedAt *time.Time `json:"received_at,omitempty"`
|
||||||
|
Snippet string `json:"snippet"`
|
||||||
|
HasAttachment bool `json:"has_attachment"`
|
||||||
|
AttachmentCount int `json:"attachment_count"`
|
||||||
|
LocalStatus LocalStatus `json:"local_status"`
|
||||||
|
Labels []Label `json:"labels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailDetail struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
MessageIDHeader string `json:"message_id_header"`
|
||||||
|
InReplyTo string `json:"in_reply_to,omitempty"`
|
||||||
|
References string `json:"references_header,omitempty"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
SenderName string `json:"from_name"`
|
||||||
|
SenderEmail string `json:"from_email"`
|
||||||
|
To []Address `json:"to"`
|
||||||
|
Cc []Address `json:"cc"`
|
||||||
|
DateHeader string `json:"date"`
|
||||||
|
SentAt *time.Time `json:"sent_at,omitempty"`
|
||||||
|
ReceivedAt *time.Time `json:"received_at,omitempty"`
|
||||||
|
BodyText string `json:"body_text"`
|
||||||
|
BodyHTML string `json:"body_html"`
|
||||||
|
Attachments []Attachment `json:"attachments"`
|
||||||
|
LocalStatus LocalStatus `json:"local_status"`
|
||||||
|
Labels []Label `json:"labels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Draft struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
To []string `json:"to"`
|
||||||
|
Cc []string `json:"cc"`
|
||||||
|
Bcc []string `json:"bcc"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
ReplyToEmailID *int64 `json:"reply_to_email_id,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
SentMessageID *int64 `json:"sent_message_id,omitempty"`
|
||||||
|
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||||
|
UpdatedAt *time.Time `json:"updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FetchedMessage struct {
|
||||||
|
ServerID int `json:"server_id"`
|
||||||
|
UID string `json:"uid"`
|
||||||
|
Raw []byte `json:"-"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SentMessage struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
DraftID *int64 `json:"draft_id,omitempty"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
MessageIDHeader string `json:"message_id_header,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ErrorMessage string `json:"error_message,omitempty"`
|
||||||
|
SentAt *time.Time `json:"sent_at,omitempty"`
|
||||||
|
}
|
||||||
228
internal/parser/parser.go
Normal file
228
internal/parser/parser.go
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
|
"mime/quotedprintable"
|
||||||
|
"net/mail"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/net/html/charset"
|
||||||
|
|
||||||
|
"internal-mail-skill/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Parse(raw []byte) (model.ParsedEmail, error) {
|
||||||
|
msg, err := mail.ReadMessage(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return model.ParsedEmail{}, fmt.Errorf("read message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed := model.ParsedEmail{
|
||||||
|
MessageIDHeader: strings.TrimSpace(msg.Header.Get("Message-ID")),
|
||||||
|
InReplyTo: strings.TrimSpace(msg.Header.Get("In-Reply-To")),
|
||||||
|
References: strings.TrimSpace(msg.Header.Get("References")),
|
||||||
|
Subject: decodeHeader(msg.Header.Get("Subject")),
|
||||||
|
DateHeader: strings.TrimSpace(msg.Header.Get("Date")),
|
||||||
|
}
|
||||||
|
if sentAt, err := mail.ParseDate(parsed.DateHeader); err == nil {
|
||||||
|
parsed.SentAt = &sentAt
|
||||||
|
}
|
||||||
|
if from, err := mail.ParseAddress(msg.Header.Get("From")); err == nil {
|
||||||
|
parsed.SenderName = decodeHeader(from.Name)
|
||||||
|
parsed.SenderEmail = strings.ToLower(from.Address)
|
||||||
|
}
|
||||||
|
parsed.To = parseAddressList(msg.Header.Get("To"))
|
||||||
|
parsed.Cc = parseAddressList(msg.Header.Get("Cc"))
|
||||||
|
|
||||||
|
if err := parsePart(mail.Header(msg.Header), msg.Body, &parsed); err != nil {
|
||||||
|
return model.ParsedEmail{}, err
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePart(header mail.Header, body io.Reader, out *model.ParsedEmail) error {
|
||||||
|
mediaType, params, err := mime.ParseMediaType(header.Get("Content-Type"))
|
||||||
|
if err != nil || mediaType == "" {
|
||||||
|
mediaType = "text/plain"
|
||||||
|
}
|
||||||
|
mediaType = strings.ToLower(mediaType)
|
||||||
|
|
||||||
|
if strings.HasPrefix(mediaType, "multipart/") {
|
||||||
|
boundary := params["boundary"]
|
||||||
|
if boundary == "" {
|
||||||
|
return fmt.Errorf("multipart message missing boundary")
|
||||||
|
}
|
||||||
|
mr := multipart.NewReader(body, boundary)
|
||||||
|
for {
|
||||||
|
part, err := mr.NextPart()
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read multipart: %w", err)
|
||||||
|
}
|
||||||
|
if err := parsePart(mail.Header(part.Header), part, out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded := transferDecodedReader(header, body)
|
||||||
|
filename := partFilename(header)
|
||||||
|
disposition := strings.ToLower(header.Get("Content-Disposition"))
|
||||||
|
isAttachment := filename != "" || strings.Contains(disposition, "attachment")
|
||||||
|
|
||||||
|
if !isAttachment && strings.HasPrefix(mediaType, "text/") {
|
||||||
|
text, err := readText(decoded, params["charset"])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read text part: %w", err)
|
||||||
|
}
|
||||||
|
switch mediaType {
|
||||||
|
case "text/html":
|
||||||
|
appendText(&out.BodyHTML, text)
|
||||||
|
default:
|
||||||
|
appendText(&out.BodyText, text)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(decoded)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read attachment: %w", err)
|
||||||
|
}
|
||||||
|
if filename == "" {
|
||||||
|
filename = "attachment.bin"
|
||||||
|
}
|
||||||
|
out.Attachments = append(out.Attachments, model.ParsedAttachment{
|
||||||
|
Filename: sanitizeFilename(filename),
|
||||||
|
ContentType: mediaType,
|
||||||
|
ContentID: trimAngle(header.Get("Content-ID")),
|
||||||
|
SizeBytes: int64(len(data)),
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeHeader(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
decoder := &mime.WordDecoder{CharsetReader: charset.NewReaderLabel}
|
||||||
|
decoded, err := decoder.DecodeHeader(value)
|
||||||
|
if err != nil {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAddressList(value string) []model.Address {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
list, err := mail.ParseAddressList(value)
|
||||||
|
if err != nil {
|
||||||
|
decoded := decodeHeader(value)
|
||||||
|
list, err = mail.ParseAddressList(decoded)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]model.Address, 0, len(list))
|
||||||
|
for _, addr := range list {
|
||||||
|
out = append(out, model.Address{
|
||||||
|
Name: decodeHeader(addr.Name),
|
||||||
|
Email: strings.ToLower(addr.Address),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func transferDecodedReader(header mail.Header, r io.Reader) io.Reader {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(header.Get("Content-Transfer-Encoding"))) {
|
||||||
|
case "base64":
|
||||||
|
return base64.NewDecoder(base64.StdEncoding, r)
|
||||||
|
case "quoted-printable":
|
||||||
|
return quotedprintable.NewReader(r)
|
||||||
|
default:
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readText(r io.Reader, label string) (string, error) {
|
||||||
|
if label != "" {
|
||||||
|
cr, err := charset.NewReaderLabel(label, r)
|
||||||
|
if err == nil {
|
||||||
|
r = cr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(r)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func partFilename(header mail.Header) string {
|
||||||
|
if disp := header.Get("Content-Disposition"); disp != "" {
|
||||||
|
_, params, err := mime.ParseMediaType(disp)
|
||||||
|
if err == nil && params["filename"] != "" {
|
||||||
|
return decodeHeader(params["filename"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ct := header.Get("Content-Type"); ct != "" {
|
||||||
|
_, params, err := mime.ParseMediaType(ct)
|
||||||
|
if err == nil && params["name"] != "" {
|
||||||
|
return decodeHeader(params["name"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendText(dst *string, value string) {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(*dst) == "" {
|
||||||
|
*dst = value
|
||||||
|
return
|
||||||
|
}
|
||||||
|
*dst += "\n" + value
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeFilename(name string) string {
|
||||||
|
name = filepath.Base(strings.TrimSpace(name))
|
||||||
|
name = strings.ReplaceAll(name, "\x00", "")
|
||||||
|
name = strings.ReplaceAll(name, "/", "_")
|
||||||
|
name = strings.ReplaceAll(name, "\\", "_")
|
||||||
|
if name == "." || name == "" {
|
||||||
|
return "attachment.bin"
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimAngle(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
value = strings.TrimPrefix(value, "<")
|
||||||
|
value = strings.TrimSuffix(value, ">")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseTimeRFC3339(value string) *time.Time {
|
||||||
|
if value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339Nano, value)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &t
|
||||||
|
}
|
||||||
63
internal/parser/parser_test.go
Normal file
63
internal/parser/parser_test.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseMultipartEmailWithAttachment(t *testing.T) {
|
||||||
|
raw := strings.Join([]string{
|
||||||
|
"Message-ID: <m1@example.com>",
|
||||||
|
"Subject: =?UTF-8?B?5rWL6K+V6YKu5Lu2?=",
|
||||||
|
"From: =?UTF-8?B?5byg5LiJ?= <sender@example.com>",
|
||||||
|
"To: receiver@example.com",
|
||||||
|
"Cc: cc@example.com",
|
||||||
|
"Date: Tue, 04 Jun 2024 12:00:00 +0800",
|
||||||
|
"MIME-Version: 1.0",
|
||||||
|
"Content-Type: multipart/mixed; boundary=mix",
|
||||||
|
"",
|
||||||
|
"--mix",
|
||||||
|
"Content-Type: multipart/alternative; boundary=alt",
|
||||||
|
"",
|
||||||
|
"--alt",
|
||||||
|
"Content-Type: text/plain; charset=utf-8",
|
||||||
|
"",
|
||||||
|
"plain body",
|
||||||
|
"--alt",
|
||||||
|
"Content-Type: text/html; charset=utf-8",
|
||||||
|
"",
|
||||||
|
"<p>html body</p>",
|
||||||
|
"--alt--",
|
||||||
|
"--mix",
|
||||||
|
"Content-Type: text/plain; name=\"note.txt\"",
|
||||||
|
"Content-Disposition: attachment; filename=\"note.txt\"",
|
||||||
|
"Content-Transfer-Encoding: base64",
|
||||||
|
"",
|
||||||
|
"YXR0YWNobWVudA==",
|
||||||
|
"--mix--",
|
||||||
|
"",
|
||||||
|
}, "\r\n")
|
||||||
|
|
||||||
|
msg, err := Parse([]byte(raw))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse() error = %v", err)
|
||||||
|
}
|
||||||
|
if msg.Subject != "测试邮件" {
|
||||||
|
t.Fatalf("Subject = %q", msg.Subject)
|
||||||
|
}
|
||||||
|
if msg.SenderName != "张三" || msg.SenderEmail != "sender@example.com" {
|
||||||
|
t.Fatalf("sender = %q <%s>", msg.SenderName, msg.SenderEmail)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(msg.BodyText) != "plain body" {
|
||||||
|
t.Fatalf("BodyText = %q", msg.BodyText)
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg.BodyHTML, "html body") {
|
||||||
|
t.Fatalf("BodyHTML = %q", msg.BodyHTML)
|
||||||
|
}
|
||||||
|
if len(msg.Attachments) != 1 {
|
||||||
|
t.Fatalf("attachments = %d", len(msg.Attachments))
|
||||||
|
}
|
||||||
|
if msg.Attachments[0].Filename != "note.txt" || string(msg.Attachments[0].Data) != "attachment" {
|
||||||
|
t.Fatalf("attachment = %#v", msg.Attachments[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
69
internal/pop3/pop3.go
Normal file
69
internal/pop3/pop3.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package pop3
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
gopop3 "github.com/knadh/go-pop3"
|
||||||
|
|
||||||
|
"internal-mail-skill/internal/config"
|
||||||
|
"internal-mail-skill/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Fetcher struct {
|
||||||
|
cfg config.POP3Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFetcher(cfg config.POP3Config) *Fetcher {
|
||||||
|
return &Fetcher{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fetcher) Fetch(ctx context.Context) ([]model.FetchedMessage, error) {
|
||||||
|
if f.cfg.Host == "" {
|
||||||
|
return nil, fmt.Errorf("POP3_HOST is required")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
client := gopop3.New(gopop3.Opt{
|
||||||
|
Host: f.cfg.Host,
|
||||||
|
Port: f.cfg.Port,
|
||||||
|
TLSEnabled: f.cfg.UseTLS,
|
||||||
|
DialTimeout: f.cfg.Timeout(),
|
||||||
|
})
|
||||||
|
conn, err := client.NewConn()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect POP3: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Quit()
|
||||||
|
|
||||||
|
if err := conn.Auth(f.cfg.Username, f.cfg.Password); err != nil {
|
||||||
|
return nil, fmt.Errorf("POP3 auth: %w", err)
|
||||||
|
}
|
||||||
|
ids, err := conn.Uidl(0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("POP3 UIDL: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]model.FetchedMessage, 0, len(ids))
|
||||||
|
for _, item := range ids {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return out, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
raw, err := conn.RetrRaw(item.ID)
|
||||||
|
if err != nil {
|
||||||
|
out = append(out, model.FetchedMessage{ServerID: item.ID, UID: item.UID, Error: fmt.Sprintf("POP3 RETR %d: %v", item.ID, err)})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, model.FetchedMessage{
|
||||||
|
ServerID: item.ID,
|
||||||
|
UID: item.UID,
|
||||||
|
Raw: raw.Bytes(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
1127
internal/service/service.go
Normal file
1127
internal/service/service.go
Normal file
File diff suppressed because it is too large
Load Diff
210
internal/service/service_test.go
Normal file
210
internal/service/service_test.go
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"internal-mail-skill/internal/db"
|
||||||
|
"internal-mail-skill/internal/model"
|
||||||
|
mailsmtp "internal-mail-skill/internal/smtp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestThreadAssemblyLinksReferences(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store, err := db.Open(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
if err := store.Migrate(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
svc := New(store, nil, nil, "default")
|
||||||
|
|
||||||
|
root, err := svc.SaveParsedEmail(ctx, SaveEmailInput{
|
||||||
|
POP3UID: "uid-root",
|
||||||
|
Parsed: model.ParsedEmail{
|
||||||
|
MessageIDHeader: "<root@example.com>",
|
||||||
|
Subject: "Root",
|
||||||
|
SenderEmail: "a@example.com",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reply, err := svc.SaveParsedEmail(ctx, SaveEmailInput{
|
||||||
|
POP3UID: "uid-reply",
|
||||||
|
Parsed: model.ParsedEmail{
|
||||||
|
MessageIDHeader: "<reply@example.com>",
|
||||||
|
InReplyTo: "<root@example.com>",
|
||||||
|
References: "<root@example.com>",
|
||||||
|
Subject: "Re: Root",
|
||||||
|
SenderEmail: "b@example.com",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
thread, err := svc.GetThread(ctx, reply)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(thread) != 2 || thread[0].ID != root || thread[1].ID != reply {
|
||||||
|
t.Fatalf("thread = %#v", thread)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchMarkLabelsAndDraftLifecycle(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store, err := db.Open(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
if err := store.Migrate(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
svc := New(store, nil, nil, "default")
|
||||||
|
|
||||||
|
emailID, err := svc.SaveParsedEmail(ctx, SaveEmailInput{
|
||||||
|
POP3UID: "uid-1",
|
||||||
|
Parsed: model.ParsedEmail{
|
||||||
|
MessageIDHeader: "<m@example.com>",
|
||||||
|
Subject: "Quarterly Plan",
|
||||||
|
BodyText: "Please review",
|
||||||
|
SenderEmail: "boss@example.com",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := svc.MarkEmail(ctx, emailID, MarkInput{Read: boolPtr(true), Processed: boolPtr(true)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
label, err := svc.CreateLabel(ctx, "work", "#336699")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := svc.ApplyLabels(ctx, []int64{emailID}, []int64{label.ID}, "add"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
results, err := svc.SearchEmails(ctx, SearchFilters{Q: "Quarterly", Read: boolPtr(true), Limit: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(results) != 1 || results[0].Labels[0].Name != "work" {
|
||||||
|
t.Fatalf("search results = %#v", results)
|
||||||
|
}
|
||||||
|
|
||||||
|
draft, err := svc.CreateDraft(ctx, DraftInput{To: []string{"x@example.com"}, Subject: "Draft", Body: "Hi"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
updated, err := svc.UpdateDraft(ctx, draft.ID, DraftInput{To: []string{"x@example.com"}, Subject: "Updated", Body: "Hello"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if updated.Subject != "Updated" {
|
||||||
|
t.Fatalf("updated subject = %q", updated.Subject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveParsedEmailRollsBackWhenAttachmentStorageFails(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store, err := db.Open(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
if err := store.Migrate(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
blocker := filepath.Join(dir, "attachments-blocker")
|
||||||
|
if err := os.WriteFile(blocker, []byte("not a directory"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
svc := NewWithOptions(store, nil, nil, "default", Options{AttachmentDir: blocker})
|
||||||
|
|
||||||
|
_, err = svc.SaveParsedEmail(ctx, SaveEmailInput{
|
||||||
|
POP3UID: "uid-broken-attachment",
|
||||||
|
Parsed: model.ParsedEmail{
|
||||||
|
MessageIDHeader: "<broken@example.com>",
|
||||||
|
Subject: "Broken Attachment",
|
||||||
|
SenderEmail: "a@example.com",
|
||||||
|
Attachments: []model.ParsedAttachment{{
|
||||||
|
Filename: "note.txt",
|
||||||
|
ContentType: "text/plain",
|
||||||
|
SizeBytes: 4,
|
||||||
|
Data: []byte("nope"),
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("SaveParsedEmail() error = nil, want attachment storage failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := svc.SearchEmails(ctx, SearchFilters{Q: "Broken Attachment", Limit: 10})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(results) != 0 {
|
||||||
|
t.Fatalf("failed save left searchable message: %#v", results)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendDraftIsIdempotentAfterSuccess(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
store, err := db.Open(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
if err := store.Migrate(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mailer := &fakeMailer{}
|
||||||
|
svc := NewWithOptions(store, nil, mailer, "default", Options{FromAddress: "sender@example.com"})
|
||||||
|
|
||||||
|
draft, err := svc.CreateDraft(ctx, DraftInput{To: []string{"x@example.com"}, Subject: "Once", Body: "hello"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first, err := svc.SendDraft(ctx, draft.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := svc.SendDraft(ctx, draft.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if mailer.calls != 1 {
|
||||||
|
t.Fatalf("mailer calls = %d, want 1", mailer.calls)
|
||||||
|
}
|
||||||
|
if second.SentMessage.ID != first.SentMessage.ID {
|
||||||
|
t.Fatalf("second send message id = %d, want %d", second.SentMessage.ID, first.SentMessage.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolPtr(v bool) *bool { return &v }
|
||||||
|
|
||||||
|
type fakeMailer struct {
|
||||||
|
calls int
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeMailer) Send(ctx context.Context, req mailsmtp.SendRequest) (mailsmtp.SendResult, error) {
|
||||||
|
f.calls++
|
||||||
|
if f.err != nil {
|
||||||
|
return mailsmtp.SendResult{}, f.err
|
||||||
|
}
|
||||||
|
if req.FromAddress == "" {
|
||||||
|
return mailsmtp.SendResult{}, errors.New("from required")
|
||||||
|
}
|
||||||
|
return mailsmtp.SendResult{MessageIDHeader: "<sent@example.com>", SentAt: time.Now().UTC()}, nil
|
||||||
|
}
|
||||||
90
internal/smtp/message_test.go
Normal file
90
internal/smtp/message_test.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package smtp
|
||||||
|
|
||||||
|
import (
|
||||||
|
stdsmtp "net/smtp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"internal-mail-skill/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildMessageOmitsBccHeaderButKeepsRecipients(t *testing.T) {
|
||||||
|
req := SendRequest{
|
||||||
|
FromAddress: "agent@example.com",
|
||||||
|
FromName: "Agent",
|
||||||
|
To: []string{"to@example.com"},
|
||||||
|
Cc: []string{"cc@example.com"},
|
||||||
|
Bcc: []string{"hidden@example.com"},
|
||||||
|
Subject: "Hello",
|
||||||
|
Body: "Body",
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, recipients, err := BuildMessage(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildMessage() error = %v", err)
|
||||||
|
}
|
||||||
|
raw := string(msg)
|
||||||
|
if strings.Contains(raw, "Bcc:") || strings.Contains(raw, "hidden@example.com") {
|
||||||
|
t.Fatalf("raw message leaked Bcc: %s", raw)
|
||||||
|
}
|
||||||
|
if len(recipients) != 3 {
|
||||||
|
t.Fatalf("recipients = %#v", recipients)
|
||||||
|
}
|
||||||
|
if recipients[2] != "hidden@example.com" {
|
||||||
|
t.Fatalf("Bcc missing from envelope recipients: %#v", recipients)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthForConfigRequiresExplicitOptInForCleartextAuth(t *testing.T) {
|
||||||
|
cfg := config.SMTPConfig{
|
||||||
|
Host: "smtp.example.local",
|
||||||
|
Username: "user",
|
||||||
|
Password: "secret",
|
||||||
|
AuthRequired: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := authForConfig(cfg); err == nil {
|
||||||
|
t.Fatal("authForConfig() error = nil, want cleartext auth rejection")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.AllowInsecureAuth = true
|
||||||
|
auth, err := authForConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("authForConfig() with opt-in error = %v", err)
|
||||||
|
}
|
||||||
|
proto, _, err := auth.Start(&stdsmtp.ServerInfo{Name: cfg.Host, TLS: false})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insecure auth Start() error = %v", err)
|
||||||
|
}
|
||||||
|
if proto != "PLAIN" {
|
||||||
|
t.Fatalf("proto = %q, want PLAIN", proto)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.AllowInsecureAuth = false
|
||||||
|
cfg.UseSTARTTLS = true
|
||||||
|
auth, err = authForConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("authForConfig() over STARTTLS error = %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := auth.Start(&stdsmtp.ServerInfo{Name: cfg.Host, TLS: true}); err != nil {
|
||||||
|
t.Fatalf("TLS auth Start() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthForConfigAllowsImplicitTLSAuth(t *testing.T) {
|
||||||
|
cfg := config.SMTPConfig{
|
||||||
|
Host: "smtp.example.local",
|
||||||
|
Username: "user",
|
||||||
|
Password: "secret",
|
||||||
|
AuthRequired: true,
|
||||||
|
UseTLS: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
auth, err := authForConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("authForConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, _, err := auth.Start(&stdsmtp.ServerInfo{Name: cfg.Host, TLS: false}); err != nil {
|
||||||
|
t.Fatalf("implicit TLS auth Start() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
252
internal/smtp/smtp.go
Normal file
252
internal/smtp/smtp.go
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
package smtp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net"
|
||||||
|
"net/mail"
|
||||||
|
stdsmtp "net/smtp"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"internal-mail-skill/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SendRequest struct {
|
||||||
|
FromAddress string
|
||||||
|
FromName string
|
||||||
|
To []string
|
||||||
|
Cc []string
|
||||||
|
Bcc []string
|
||||||
|
Subject string
|
||||||
|
Body string
|
||||||
|
InReplyTo string
|
||||||
|
References string
|
||||||
|
MessageIDHeader string
|
||||||
|
}
|
||||||
|
|
||||||
|
type SendResult struct {
|
||||||
|
MessageIDHeader string
|
||||||
|
Raw []byte
|
||||||
|
Recipients []string
|
||||||
|
SentAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type Sender struct {
|
||||||
|
cfg config.SMTPConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSender(cfg config.SMTPConfig) *Sender {
|
||||||
|
return &Sender{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sender) Send(ctx context.Context, req SendRequest) (SendResult, error) {
|
||||||
|
if req.FromAddress == "" {
|
||||||
|
req.FromAddress = s.cfg.FromAddress
|
||||||
|
}
|
||||||
|
if req.FromName == "" {
|
||||||
|
req.FromName = s.cfg.FromName
|
||||||
|
}
|
||||||
|
raw, recipients, err := BuildMessage(req)
|
||||||
|
if err != nil {
|
||||||
|
return SendResult{}, err
|
||||||
|
}
|
||||||
|
if len(recipients) == 0 {
|
||||||
|
return SendResult{}, fmt.Errorf("no recipients")
|
||||||
|
}
|
||||||
|
if s.cfg.Host == "" {
|
||||||
|
return SendResult{}, fmt.Errorf("SMTP_HOST is required")
|
||||||
|
}
|
||||||
|
auth, err := authForConfig(s.cfg)
|
||||||
|
if err != nil {
|
||||||
|
return SendResult{}, err
|
||||||
|
}
|
||||||
|
if err := s.sendRaw(ctx, req.FromAddress, recipients, raw, auth); err != nil {
|
||||||
|
return SendResult{}, err
|
||||||
|
}
|
||||||
|
return SendResult{
|
||||||
|
MessageIDHeader: extractHeader(raw, "Message-ID"),
|
||||||
|
Raw: raw,
|
||||||
|
Recipients: recipients,
|
||||||
|
SentAt: time.Now().UTC(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildMessage(req SendRequest) ([]byte, []string, error) {
|
||||||
|
if req.FromAddress == "" {
|
||||||
|
return nil, nil, fmt.Errorf("from address is required")
|
||||||
|
}
|
||||||
|
recipients := append([]string{}, req.To...)
|
||||||
|
recipients = append(recipients, req.Cc...)
|
||||||
|
recipients = append(recipients, req.Bcc...)
|
||||||
|
if len(recipients) == 0 {
|
||||||
|
return nil, nil, fmt.Errorf("at least one recipient is required")
|
||||||
|
}
|
||||||
|
messageID := req.MessageIDHeader
|
||||||
|
if messageID == "" {
|
||||||
|
messageID = newMessageID()
|
||||||
|
}
|
||||||
|
var b bytes.Buffer
|
||||||
|
writeHeader(&b, "From", (&mail.Address{Name: req.FromName, Address: req.FromAddress}).String())
|
||||||
|
writeHeader(&b, "To", strings.Join(req.To, ", "))
|
||||||
|
if len(req.Cc) > 0 {
|
||||||
|
writeHeader(&b, "Cc", strings.Join(req.Cc, ", "))
|
||||||
|
}
|
||||||
|
writeHeader(&b, "Subject", mime.QEncoding.Encode("utf-8", req.Subject))
|
||||||
|
writeHeader(&b, "Date", time.Now().Format(time.RFC1123Z))
|
||||||
|
writeHeader(&b, "Message-ID", messageID)
|
||||||
|
if req.InReplyTo != "" {
|
||||||
|
writeHeader(&b, "In-Reply-To", req.InReplyTo)
|
||||||
|
}
|
||||||
|
if req.References != "" {
|
||||||
|
writeHeader(&b, "References", req.References)
|
||||||
|
}
|
||||||
|
writeHeader(&b, "MIME-Version", "1.0")
|
||||||
|
writeHeader(&b, "Content-Type", `text/plain; charset="utf-8"`)
|
||||||
|
writeHeader(&b, "Content-Transfer-Encoding", "8bit")
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
body := strings.ReplaceAll(req.Body, "\n", "\r\n")
|
||||||
|
body = strings.ReplaceAll(body, "\r\r\n", "\r\n")
|
||||||
|
b.WriteString(body)
|
||||||
|
if !strings.HasSuffix(body, "\r\n") {
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
}
|
||||||
|
return b.Bytes(), recipients, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sender) sendRaw(ctx context.Context, from string, recipients []string, raw []byte, auth stdsmtp.Auth) error {
|
||||||
|
timeout := s.cfg.Timeout()
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 30 * time.Second
|
||||||
|
}
|
||||||
|
address := net.JoinHostPort(s.cfg.Host, fmt.Sprintf("%d", s.cfg.Port))
|
||||||
|
dialer := &net.Dialer{Timeout: timeout}
|
||||||
|
var conn net.Conn
|
||||||
|
var err error
|
||||||
|
if s.cfg.UseTLS {
|
||||||
|
conn, err = tls.DialWithDialer(dialer, "tcp", address, &tls.Config{ServerName: s.cfg.Host, MinVersion: tls.VersionTLS12})
|
||||||
|
} else {
|
||||||
|
conn, err = dialer.DialContext(ctx, "tcp", address)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect SMTP: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
client, err := stdsmtp.NewClient(conn, s.cfg.Host)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create SMTP client: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
if s.cfg.UseSTARTTLS {
|
||||||
|
if err := client.StartTLS(&tls.Config{ServerName: s.cfg.Host, MinVersion: tls.VersionTLS12}); err != nil {
|
||||||
|
return fmt.Errorf("starttls: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if auth != nil {
|
||||||
|
if err := client.Auth(auth); err != nil {
|
||||||
|
return fmt.Errorf("smtp auth: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := client.Mail(from); err != nil {
|
||||||
|
return fmt.Errorf("smtp MAIL FROM: %w", err)
|
||||||
|
}
|
||||||
|
for _, recipient := range recipients {
|
||||||
|
if strings.TrimSpace(recipient) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := client.Rcpt(recipient); err != nil {
|
||||||
|
return fmt.Errorf("smtp RCPT TO %s: %w", recipient, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writer, err := client.Data()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("smtp DATA: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(writer, bytes.NewReader(raw)); err != nil {
|
||||||
|
_ = writer.Close()
|
||||||
|
return fmt.Errorf("write SMTP body: %w", err)
|
||||||
|
}
|
||||||
|
if err := writer.Close(); err != nil {
|
||||||
|
return fmt.Errorf("close SMTP body: %w", err)
|
||||||
|
}
|
||||||
|
if err := client.Quit(); err != nil {
|
||||||
|
return fmt.Errorf("smtp quit: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func authForConfig(cfg config.SMTPConfig) (stdsmtp.Auth, error) {
|
||||||
|
if !cfg.AuthRequired {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if !cfg.UseTLS && !cfg.UseSTARTTLS {
|
||||||
|
if cfg.AllowInsecureAuth {
|
||||||
|
return plainAuthNoTLSCheck{username: cfg.Username, password: cfg.Password}, nil
|
||||||
|
}
|
||||||
|
if cfg.Host != "localhost" && cfg.Host != "127.0.0.1" && cfg.Host != "::1" {
|
||||||
|
return nil, fmt.Errorf("SMTP auth over cleartext requires SMTP_USE_TLS=true, SMTP_USE_STARTTLS=true, or SMTP_ALLOW_INSECURE_AUTH=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.UseTLS {
|
||||||
|
return plainAuthNoTLSCheck{username: cfg.Username, password: cfg.Password}, nil
|
||||||
|
}
|
||||||
|
return stdsmtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type plainAuthNoTLSCheck struct {
|
||||||
|
username string
|
||||||
|
password string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a plainAuthNoTLSCheck) Start(server *stdsmtp.ServerInfo) (string, []byte, error) {
|
||||||
|
resp := []byte("\x00" + a.username + "\x00" + a.password)
|
||||||
|
return "PLAIN", resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a plainAuthNoTLSCheck) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||||
|
if more {
|
||||||
|
return nil, fmt.Errorf("unexpected server challenge for PLAIN auth")
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeHeader(b *bytes.Buffer, key, value string) {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.WriteString(key)
|
||||||
|
b.WriteString(": ")
|
||||||
|
b.WriteString(value)
|
||||||
|
b.WriteString("\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMessageID() string {
|
||||||
|
host, err := os.Hostname()
|
||||||
|
if err != nil || host == "" {
|
||||||
|
host = "localhost"
|
||||||
|
}
|
||||||
|
var random [8]byte
|
||||||
|
if _, err := rand.Read(random[:]); err != nil {
|
||||||
|
return fmt.Sprintf("<%d@%s>", time.Now().UnixNano(), host)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), hex.EncodeToString(random[:]), host)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractHeader(raw []byte, name string) string {
|
||||||
|
prefix := strings.ToLower(name) + ":"
|
||||||
|
for _, line := range strings.Split(string(raw), "\r\n") {
|
||||||
|
if strings.HasPrefix(strings.ToLower(line), prefix) {
|
||||||
|
return strings.TrimSpace(line[len(name)+1:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user