From 097df73495bb66dbcb43564e09b809148b367a26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E4=B9=89=E4=BB=91?= Date: Sat, 6 Jun 2026 22:28:36 +0800 Subject: [PATCH] Implement internal mail skill MVP --- .env.example | 28 + .gitignore | 5 + Makefile | 18 + README.md | 317 +++++++++ cmd/server/main.go | 97 +++ data/.gitkeep | 1 + go.mod | 24 + go.sum | 88 +++ internal/api/api.go | 331 +++++++++ internal/config/config.go | 177 +++++ internal/config/config_test.go | 42 ++ internal/db/db.go | 164 +++++ internal/db/db_test.go | 33 + internal/e2e/local_e2e_test.go | 571 +++++++++++++++ internal/model/model.go | 124 ++++ internal/parser/parser.go | 228 ++++++ internal/parser/parser_test.go | 63 ++ internal/pop3/pop3.go | 69 ++ internal/service/service.go | 1127 ++++++++++++++++++++++++++++++ internal/service/service_test.go | 210 ++++++ internal/smtp/message_test.go | 90 +++ internal/smtp/smtp.go | 252 +++++++ 22 files changed, 4059 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/server/main.go create mode 100644 data/.gitkeep create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/api.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/db/db.go create mode 100644 internal/db/db_test.go create mode 100644 internal/e2e/local_e2e_test.go create mode 100644 internal/model/model.go create mode 100644 internal/parser/parser.go create mode 100644 internal/parser/parser_test.go create mode 100644 internal/pop3/pop3.go create mode 100644 internal/service/service.go create mode 100644 internal/service/service_test.go create mode 100644 internal/smtp/message_test.go create mode 100644 internal/smtp/smtp.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e75c55a --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..660f66f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +.DS_Store +dist/ +data/* +!data/.gitkeep diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b882875 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..bcaa8cb --- /dev/null +++ b/README.md @@ -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`、文件名、类型和大小。 diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..e0038dd --- /dev/null +++ b/cmd/server/main.go @@ -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 +} diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/.gitkeep @@ -0,0 +1 @@ + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..70dea02 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..da84d4d --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..ed1e958 --- /dev/null +++ b/internal/api/api.go @@ -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) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..742cbc0 --- /dev/null +++ b/internal/config/config.go @@ -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 + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..23c208b --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..a37a74c --- /dev/null +++ b/internal/db/db.go @@ -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); +` diff --git a/internal/db/db_test.go b/internal/db/db_test.go new file mode 100644 index 0000000..d85572e --- /dev/null +++ b/internal/db/db_test.go @@ -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) + } +} diff --git a/internal/e2e/local_e2e_test.go b/internal/e2e/local_e2e_test.go new file mode 100644 index 0000000..ceca199 --- /dev/null +++ b/internal/e2e/local_e2e_test.go @@ -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: ") { + 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: ", + "From: Alice Example ", + "To: Agent ", + "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 .") + 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") +} diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..726e258 --- /dev/null +++ b/internal/model/model.go @@ -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"` +} diff --git a/internal/parser/parser.go b/internal/parser/parser.go new file mode 100644 index 0000000..688a06d --- /dev/null +++ b/internal/parser/parser.go @@ -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 +} diff --git a/internal/parser/parser_test.go b/internal/parser/parser_test.go new file mode 100644 index 0000000..ead00ea --- /dev/null +++ b/internal/parser/parser_test.go @@ -0,0 +1,63 @@ +package parser + +import ( + "strings" + "testing" +) + +func TestParseMultipartEmailWithAttachment(t *testing.T) { + raw := strings.Join([]string{ + "Message-ID: ", + "Subject: =?UTF-8?B?5rWL6K+V6YKu5Lu2?=", + "From: =?UTF-8?B?5byg5LiJ?= ", + "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", + "", + "

html body

", + "--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]) + } +} diff --git a/internal/pop3/pop3.go b/internal/pop3/pop3.go new file mode 100644 index 0000000..9647b41 --- /dev/null +++ b/internal/pop3/pop3.go @@ -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 +} diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..60cae59 --- /dev/null +++ b/internal/service/service.go @@ -0,0 +1,1127 @@ +package service + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "internal-mail-skill/internal/db" + "internal-mail-skill/internal/model" + "internal-mail-skill/internal/parser" + mailsmtp "internal-mail-skill/internal/smtp" +) + +type POP3Source interface { + Fetch(ctx context.Context) ([]model.FetchedMessage, error) +} + +type MailSender interface { + Send(ctx context.Context, req mailsmtp.SendRequest) (mailsmtp.SendResult, error) +} + +type Service struct { + store *db.Store + pop3 POP3Source + mailer MailSender + accountID string + rawDir string + attachmentDir string + fromAddress string + fromName string +} + +type Options struct { + RawDir string + AttachmentDir string + FromAddress string + FromName string +} + +func New(store *db.Store, pop3 POP3Source, mailer MailSender, accountID string) *Service { + return NewWithOptions(store, pop3, mailer, accountID, Options{}) +} + +func NewWithOptions(store *db.Store, pop3 POP3Source, mailer MailSender, accountID string, opts Options) *Service { + if accountID == "" { + accountID = "default" + } + return &Service{ + store: store, + pop3: pop3, + mailer: mailer, + accountID: accountID, + rawDir: opts.RawDir, + attachmentDir: opts.AttachmentDir, + fromAddress: opts.FromAddress, + fromName: opts.FromName, + } +} + +type SaveEmailInput struct { + POP3UID string + Raw []byte + Parsed model.ParsedEmail +} + +type SearchFilters struct { + Q string + From string + To string + After string + Before string + HasAttachment *bool + Read *bool + Processed *bool + Archived *bool + Replied *bool + Limit int + Offset int +} + +type MarkInput struct { + Read *bool `json:"read"` + Processed *bool `json:"processed"` + Archived *bool `json:"archived"` + Deleted *bool `json:"deleted"` + Replied *bool `json:"replied"` +} + +type DraftInput struct { + 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"` +} + +type SyncResult struct { + Total int `json:"total"` + Stored int `json:"stored"` + Skipped int `json:"skipped"` + Errors []SyncError `json:"errors"` +} + +type SyncError struct { + UID string `json:"uid,omitempty"` + ServerID int `json:"server_id,omitempty"` + Error string `json:"error"` +} + +type SendOutcome struct { + SentMessage model.SentMessage `json:"sent_message"` + Draft *model.Draft `json:"draft,omitempty"` +} + +func (s *Service) Health(ctx context.Context) string { + if err := s.store.Ping(ctx); err != nil { + return "down" + } + return "ok" +} + +func (s *Service) Sync(ctx context.Context) (SyncResult, error) { + if s.pop3 == nil { + return SyncResult{}, fmt.Errorf("POP3 client is not configured") + } + messages, err := s.pop3.Fetch(ctx) + if err != nil { + return SyncResult{}, err + } + result := SyncResult{Total: len(messages)} + for _, fetched := range messages { + if fetched.Error != "" { + result.Errors = append(result.Errors, SyncError{UID: fetched.UID, ServerID: fetched.ServerID, Error: fetched.Error}) + continue + } + exists, err := s.hasPOP3UID(ctx, fetched.UID) + if err != nil { + result.Errors = append(result.Errors, SyncError{UID: fetched.UID, ServerID: fetched.ServerID, Error: err.Error()}) + continue + } + if exists { + result.Skipped++ + continue + } + parsed, err := parser.Parse(fetched.Raw) + if err != nil { + result.Errors = append(result.Errors, SyncError{UID: fetched.UID, ServerID: fetched.ServerID, Error: err.Error()}) + continue + } + if _, err := s.SaveParsedEmail(ctx, SaveEmailInput{POP3UID: fetched.UID, Raw: fetched.Raw, Parsed: parsed}); err != nil { + result.Errors = append(result.Errors, SyncError{UID: fetched.UID, ServerID: fetched.ServerID, Error: err.Error()}) + continue + } + result.Stored++ + } + return result, nil +} + +func (s *Service) SaveParsedEmail(ctx context.Context, input SaveEmailInput) (int64, error) { + if input.POP3UID == "" { + input.POP3UID = "local-" + hashBytes([]byte(input.Parsed.MessageIDHeader+input.Parsed.Subject+time.Now().String())) + } + if id, ok, err := s.findByPOP3UID(ctx, input.POP3UID); err != nil { + return 0, err + } else if ok { + return id, nil + } + + now := time.Now().UTC() + rawPath := "" + if len(input.Raw) > 0 && s.rawDir != "" { + rawPath = filepath.Join(s.rawDir, s.accountID+"_"+hashBytes([]byte(input.POP3UID))+".eml") + } + + toJSON, err := marshalJSON(input.Parsed.To) + if err != nil { + return 0, err + } + ccJSON, err := marshalJSON(input.Parsed.Cc) + if err != nil { + return 0, err + } + sentAt := timePtrString(input.Parsed.SentAt) + + tx, err := s.store.DB.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + var writtenFiles []string + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + for _, path := range writtenFiles { + _ = os.Remove(path) + } + } + }() + + res, err := tx.ExecContext(ctx, ` + INSERT INTO mail_messages ( + account_id, pop3_uid, message_id_header, in_reply_to, references_header, + subject, sender_name, sender_email, to_json, cc_json, date_header, + sent_at, received_at, body_text, body_html, raw_eml_path, has_attachment, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + s.accountID, + input.POP3UID, + input.Parsed.MessageIDHeader, + input.Parsed.InReplyTo, + input.Parsed.References, + input.Parsed.Subject, + input.Parsed.SenderName, + input.Parsed.SenderEmail, + toJSON, + ccJSON, + input.Parsed.DateHeader, + sentAt, + formatTime(now), + input.Parsed.BodyText, + input.Parsed.BodyHTML, + rawPath, + boolInt(len(input.Parsed.Attachments) > 0), + formatTime(now), + formatTime(now), + ) + if err != nil { + return 0, fmt.Errorf("insert mail message: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return 0, err + } + if rawPath != "" { + if err := os.MkdirAll(s.rawDir, 0750); err != nil { + return 0, fmt.Errorf("create raw mail dir: %w", err) + } + if err := os.WriteFile(rawPath, input.Raw, 0600); err != nil { + return 0, fmt.Errorf("write raw eml: %w", err) + } + writtenFiles = append(writtenFiles, rawPath) + } + if err := s.saveAttachments(ctx, tx, id, input.Parsed.Attachments, now, &writtenFiles); err != nil { + return 0, err + } + if err := tx.Commit(); err != nil { + return 0, err + } + committed = true + return id, nil +} + +func (s *Service) SearchEmails(ctx context.Context, filters SearchFilters) ([]model.EmailSummary, error) { + where := []string{"account_id = ?"} + args := []any{s.accountID} + if filters.Q != "" { + where = append(where, "(subject LIKE ? OR sender_email LIKE ? OR body_text LIKE ? OR body_html LIKE ?)") + like := "%" + filters.Q + "%" + args = append(args, like, like, like, like) + } + if filters.From != "" { + where = append(where, "(sender_email LIKE ? OR sender_name LIKE ?)") + like := "%" + filters.From + "%" + args = append(args, like, like) + } + if filters.To != "" { + where = append(where, "to_json LIKE ?") + args = append(args, "%"+filters.To+"%") + } + if after := normalizeDateFilter(filters.After, false); after != "" { + where = append(where, "sent_at >= ?") + args = append(args, after) + } + if before := normalizeDateFilter(filters.Before, true); before != "" { + where = append(where, "sent_at <= ?") + args = append(args, before) + } + addBoolFilter := func(name string, val *bool) { + if val != nil { + where = append(where, name+" = ?") + args = append(args, boolInt(*val)) + } + } + addBoolFilter("has_attachment", filters.HasAttachment) + addBoolFilter("local_read", filters.Read) + addBoolFilter("local_processed", filters.Processed) + addBoolFilter("local_archived", filters.Archived) + addBoolFilter("local_replied", filters.Replied) + limit := filters.Limit + if limit <= 0 || limit > 100 { + limit = 50 + } + offset := filters.Offset + if offset < 0 { + offset = 0 + } + args = append(args, limit, offset) + + query := ` + SELECT id, subject, sender_name, sender_email, to_json, cc_json, date_header, + sent_at, received_at, body_text, has_attachment, local_read, + local_processed, local_archived, local_deleted, local_replied, + (SELECT COUNT(*) FROM attachments WHERE message_id = mail_messages.id) + FROM mail_messages + WHERE ` + strings.Join(where, " AND ") + ` + ORDER BY COALESCE(sent_at, received_at) DESC, id DESC + LIMIT ? OFFSET ?` + rows, err := s.store.DB.QueryContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []model.EmailSummary + for rows.Next() { + summary, err := scanSummary(rows) + if err != nil { + return nil, err + } + out = append(out, summary) + } + if err := rows.Err(); err != nil { + return nil, err + } + for i := range out { + out[i].Labels = s.labelsForMessage(ctx, out[i].ID) + } + return out, nil +} + +func (s *Service) GetEmail(ctx context.Context, id int64) (model.EmailDetail, error) { + row := s.store.DB.QueryRowContext(ctx, ` + SELECT id, message_id_header, in_reply_to, references_header, subject, + sender_name, sender_email, to_json, cc_json, date_header, sent_at, + received_at, body_text, body_html, local_read, local_processed, + local_archived, local_deleted, local_replied + FROM mail_messages WHERE account_id = ? AND id = ?`, s.accountID, id) + detail, err := scanDetail(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return model.EmailDetail{}, ErrNotFound + } + return model.EmailDetail{}, err + } + detail.Attachments = s.attachmentsForMessage(ctx, id) + detail.Labels = s.labelsForMessage(ctx, id) + return detail, nil +} + +func (s *Service) GetThread(ctx context.Context, id int64) ([]model.EmailSummary, error) { + candidates, err := s.threadCandidates(ctx) + if err != nil { + return nil, err + } + if _, ok := candidates[id]; !ok { + return nil, ErrNotFound + } + selected := map[int64]bool{id: true} + changed := true + for changed { + changed = false + for candidateID, candidate := range candidates { + if selected[candidateID] { + continue + } + for selectedID := range selected { + if linked(candidate, candidates[selectedID]) { + selected[candidateID] = true + changed = true + break + } + } + } + } + out := make([]model.EmailSummary, 0, len(selected)) + for selectedID := range selected { + summary := candidates[selectedID].summary + summary.Labels = s.labelsForMessage(ctx, selectedID) + out = append(out, summary) + } + sort.Slice(out, func(i, j int) bool { + ti := summaryTime(out[i]) + tj := summaryTime(out[j]) + if ti.Equal(tj) { + return out[i].ID < out[j].ID + } + return ti.Before(tj) + }) + return out, nil +} + +func (s *Service) MarkEmail(ctx context.Context, id int64, input MarkInput) error { + sets := []string{} + args := []any{} + add := func(field string, value *bool) { + if value != nil { + sets = append(sets, field+" = ?") + args = append(args, boolInt(*value)) + } + } + add("local_read", input.Read) + add("local_processed", input.Processed) + add("local_archived", input.Archived) + add("local_deleted", input.Deleted) + add("local_replied", input.Replied) + if len(sets) == 0 { + return nil + } + sets = append(sets, "updated_at = ?") + args = append(args, formatTime(time.Now().UTC()), s.accountID, id) + res, err := s.store.DB.ExecContext(ctx, "UPDATE mail_messages SET "+strings.Join(sets, ", ")+" WHERE account_id = ? AND id = ?", args...) + if err != nil { + return err + } + return requireAffected(res) +} + +func (s *Service) CreateLabel(ctx context.Context, name, color string) (model.Label, error) { + name = strings.TrimSpace(name) + if name == "" { + return model.Label{}, fmt.Errorf("label name is required") + } + now := formatTime(time.Now().UTC()) + _, err := s.store.DB.ExecContext(ctx, ` + INSERT INTO labels(name, color, created_at, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET color = excluded.color, updated_at = excluded.updated_at`, + name, color, now, now) + if err != nil { + return model.Label{}, err + } + var label model.Label + if err := s.store.DB.QueryRowContext(ctx, "SELECT id, name, color FROM labels WHERE name = ?", name).Scan(&label.ID, &label.Name, &label.Color); err != nil { + return model.Label{}, err + } + return label, nil +} + +func (s *Service) ApplyLabels(ctx context.Context, emailIDs, labelIDs []int64, op string) error { + if len(emailIDs) == 0 || len(labelIDs) == 0 { + return fmt.Errorf("email_ids and label_ids are required") + } + op = strings.ToLower(strings.TrimSpace(op)) + if op != "add" && op != "remove" { + return fmt.Errorf("op must be add or remove") + } + now := formatTime(time.Now().UTC()) + for _, emailID := range emailIDs { + for _, labelID := range labelIDs { + if op == "add" { + if _, err := s.store.DB.ExecContext(ctx, ` + INSERT OR IGNORE INTO message_labels(message_id, label_id, created_at) + VALUES (?, ?, ?)`, emailID, labelID, now); err != nil { + return err + } + } else { + if _, err := s.store.DB.ExecContext(ctx, `DELETE FROM message_labels WHERE message_id = ? AND label_id = ?`, emailID, labelID); err != nil { + return err + } + } + } + } + return nil +} + +func (s *Service) CreateDraft(ctx context.Context, input DraftInput) (model.Draft, error) { + now := formatTime(time.Now().UTC()) + toJSON, _ := marshalJSON(input.To) + ccJSON, _ := marshalJSON(input.Cc) + bccJSON, _ := marshalJSON(input.Bcc) + res, err := s.store.DB.ExecContext(ctx, ` + INSERT INTO drafts(to_json, cc_json, bcc_json, subject, body, reply_to_email_id, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 'draft', ?, ?)`, + toJSON, ccJSON, bccJSON, input.Subject, input.Body, input.ReplyToEmailID, now, now) + if err != nil { + return model.Draft{}, err + } + id, err := res.LastInsertId() + if err != nil { + return model.Draft{}, err + } + return s.GetDraft(ctx, id) +} + +func (s *Service) ListDrafts(ctx context.Context) ([]model.Draft, error) { + rows, err := s.store.DB.QueryContext(ctx, ` + SELECT id, to_json, cc_json, bcc_json, subject, body, reply_to_email_id, + status, last_error, sent_message_id, created_at, updated_at + FROM drafts ORDER BY updated_at DESC, id DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + var drafts []model.Draft + for rows.Next() { + draft, err := scanDraft(rows) + if err != nil { + return nil, err + } + drafts = append(drafts, draft) + } + return drafts, rows.Err() +} + +func (s *Service) GetDraft(ctx context.Context, id int64) (model.Draft, error) { + row := s.store.DB.QueryRowContext(ctx, ` + SELECT id, to_json, cc_json, bcc_json, subject, body, reply_to_email_id, + status, last_error, sent_message_id, created_at, updated_at + FROM drafts WHERE id = ?`, id) + draft, err := scanDraft(row) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return model.Draft{}, ErrNotFound + } + return model.Draft{}, err + } + return draft, nil +} + +func (s *Service) UpdateDraft(ctx context.Context, id int64, input DraftInput) (model.Draft, error) { + toJSON, _ := marshalJSON(input.To) + ccJSON, _ := marshalJSON(input.Cc) + bccJSON, _ := marshalJSON(input.Bcc) + res, err := s.store.DB.ExecContext(ctx, ` + UPDATE drafts SET to_json = ?, cc_json = ?, bcc_json = ?, subject = ?, + body = ?, reply_to_email_id = ?, updated_at = ? + WHERE id = ? AND status != 'sent'`, + toJSON, ccJSON, bccJSON, input.Subject, input.Body, input.ReplyToEmailID, formatTime(time.Now().UTC()), id) + if err != nil { + return model.Draft{}, err + } + if err := requireAffected(res); err != nil { + return model.Draft{}, err + } + return s.GetDraft(ctx, id) +} + +func (s *Service) SendDraft(ctx context.Context, id int64) (SendOutcome, error) { + draft, err := s.GetDraft(ctx, id) + if err != nil { + return SendOutcome{}, err + } + if draft.Status == "sent" { + if draft.SentMessageID == nil { + return SendOutcome{}, ErrDraftAlreadySent + } + sent, err := s.getSentMessage(ctx, *draft.SentMessageID) + if err != nil { + return SendOutcome{}, err + } + return SendOutcome{SentMessage: sent, Draft: &draft}, nil + } + req := mailsmtp.SendRequest{ + FromAddress: s.fromAddress, + FromName: s.fromName, + To: draft.To, + Cc: draft.Cc, + Bcc: draft.Bcc, + Subject: draft.Subject, + Body: draft.Body, + } + if draft.ReplyToEmailID != nil { + original, err := s.GetEmail(ctx, *draft.ReplyToEmailID) + if err != nil { + return SendOutcome{}, err + } + req.InReplyTo = original.MessageIDHeader + req.References = buildReplyReferences(original) + } + var result mailsmtp.SendResult + if s.mailer == nil { + err = fmt.Errorf("SMTP client is not configured") + } else { + result, err = s.mailer.Send(ctx, req) + } + if err != nil { + sent, recordErr := s.recordSentMessage(ctx, &id, draft, req, "failed", "", err.Error(), nil) + if recordErr != nil { + return SendOutcome{}, recordErr + } + _, _ = s.store.DB.ExecContext(ctx, "UPDATE drafts SET last_error = ?, updated_at = ? WHERE id = ?", err.Error(), formatTime(time.Now().UTC()), id) + return SendOutcome{SentMessage: sent, Draft: &draft}, err + } + sentAt := result.SentAt + sent, err := s.recordSentMessage(ctx, &id, draft, req, "sent", result.MessageIDHeader, "", &sentAt) + if err != nil { + return SendOutcome{}, err + } + _, err = s.store.DB.ExecContext(ctx, ` + UPDATE drafts SET status = 'sent', last_error = NULL, sent_message_id = ?, updated_at = ? + WHERE id = ?`, sent.ID, formatTime(time.Now().UTC()), id) + if err != nil { + return SendOutcome{}, err + } + if draft.ReplyToEmailID != nil { + _ = s.MarkEmail(ctx, *draft.ReplyToEmailID, MarkInput{Replied: ptrBool(true)}) + } + updated, _ := s.GetDraft(ctx, id) + return SendOutcome{SentMessage: sent, Draft: &updated}, nil +} + +func (s *Service) SendDirect(ctx context.Context, input DraftInput) (SendOutcome, error) { + draft, err := s.CreateDraft(ctx, input) + if err != nil { + return SendOutcome{}, err + } + return s.SendDraft(ctx, draft.ID) +} + +type ForwardInput struct { + To []string `json:"to"` + Cc []string `json:"cc"` + Bcc []string `json:"bcc"` + Subject string `json:"subject"` + Body string `json:"body"` + IncludeAttachments bool `json:"include_attachments"` +} + +func (s *Service) ForwardEmail(ctx context.Context, emailID int64, input ForwardInput) (SendOutcome, error) { + if input.IncludeAttachments { + return SendOutcome{}, ErrAttachmentsForwardUnsupported + } + original, err := s.GetEmail(ctx, emailID) + if err != nil { + return SendOutcome{}, err + } + subject := input.Subject + if strings.TrimSpace(subject) == "" { + subject = "Fwd: " + original.Subject + } + body := input.Body + if strings.TrimSpace(body) != "" { + body += "\n\n" + } + body += "---------- Forwarded message ----------\n" + body += "From: " + original.SenderEmail + "\n" + body += "Date: " + original.DateHeader + "\n" + body += "Subject: " + original.Subject + "\n\n" + if strings.TrimSpace(original.BodyText) != "" { + body += original.BodyText + } else { + body += original.BodyHTML + } + return s.SendDirect(ctx, DraftInput{To: input.To, Cc: input.Cc, Bcc: input.Bcc, Subject: subject, Body: body}) +} + +func (s *Service) hasPOP3UID(ctx context.Context, uid string) (bool, error) { + _, ok, err := s.findByPOP3UID(ctx, uid) + return ok, err +} + +func (s *Service) findByPOP3UID(ctx context.Context, uid string) (int64, bool, error) { + var id int64 + err := s.store.DB.QueryRowContext(ctx, `SELECT id FROM mail_messages WHERE account_id = ? AND pop3_uid = ?`, s.accountID, uid).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return 0, false, nil + } + if err != nil { + return 0, false, err + } + return id, true, nil +} + +func (s *Service) saveAttachments(ctx context.Context, execer interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) +}, messageID int64, attachments []model.ParsedAttachment, now time.Time, writtenFiles *[]string) error { + if len(attachments) == 0 { + return nil + } + if s.attachmentDir == "" { + for _, attachment := range attachments { + if _, err := execer.ExecContext(ctx, ` + INSERT INTO attachments(message_id, file_name, content_type, content_id, size_bytes, storage_path, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + messageID, attachment.Filename, attachment.ContentType, attachment.ContentID, attachment.SizeBytes, "", formatTime(now)); err != nil { + return err + } + } + return nil + } + dir := filepath.Join(s.attachmentDir, fmt.Sprintf("%d", messageID)) + if err := os.MkdirAll(dir, 0750); err != nil { + return fmt.Errorf("create attachment dir: %w", err) + } + for i, attachment := range attachments { + filename := fmt.Sprintf("%03d_%s", i+1, attachment.Filename) + path := filepath.Join(dir, filename) + if err := os.WriteFile(path, attachment.Data, 0600); err != nil { + return fmt.Errorf("write attachment: %w", err) + } + *writtenFiles = append(*writtenFiles, path) + if _, err := execer.ExecContext(ctx, ` + INSERT INTO attachments(message_id, file_name, content_type, content_id, size_bytes, storage_path, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + messageID, attachment.Filename, attachment.ContentType, attachment.ContentID, attachment.SizeBytes, path, formatTime(now)); err != nil { + return err + } + } + return nil +} + +func (s *Service) recordSentMessage(ctx context.Context, draftID *int64, draft model.Draft, req mailsmtp.SendRequest, status, messageID, errorMessage string, sentAt *time.Time) (model.SentMessage, error) { + toJSON, _ := marshalJSON(draft.To) + ccJSON, _ := marshalJSON(draft.Cc) + bccJSON, _ := marshalJSON(draft.Bcc) + sentAtValue := timePtrString(sentAt) + res, err := s.store.DB.ExecContext(ctx, ` + INSERT INTO sent_messages(draft_id, to_json, cc_json, bcc_json, subject, body, + message_id_header, in_reply_to, references_header, smtp_from, status, + error_message, sent_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + draftID, toJSON, ccJSON, bccJSON, draft.Subject, draft.Body, + messageID, req.InReplyTo, req.References, req.FromAddress, status, + errorMessage, sentAtValue, formatTime(time.Now().UTC())) + if err != nil { + return model.SentMessage{}, err + } + id, err := res.LastInsertId() + if err != nil { + return model.SentMessage{}, err + } + return model.SentMessage{ + ID: id, + DraftID: draftID, + Subject: draft.Subject, + MessageIDHeader: messageID, + Status: status, + ErrorMessage: errorMessage, + SentAt: sentAt, + }, nil +} + +func (s *Service) getSentMessage(ctx context.Context, id int64) (model.SentMessage, error) { + var sent model.SentMessage + var draftID sql.NullInt64 + var sentAt, errorMessage sql.NullString + err := s.store.DB.QueryRowContext(ctx, ` + SELECT id, draft_id, subject, message_id_header, status, error_message, sent_at + FROM sent_messages WHERE id = ?`, id).Scan( + &sent.ID, + &draftID, + &sent.Subject, + &sent.MessageIDHeader, + &sent.Status, + &errorMessage, + &sentAt, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return model.SentMessage{}, ErrNotFound + } + return model.SentMessage{}, err + } + if draftID.Valid { + sent.DraftID = &draftID.Int64 + } + if errorMessage.Valid { + sent.ErrorMessage = errorMessage.String + } + if sentAt.Valid { + sent.SentAt = parseStoredTime(sentAt.String) + } + return sent, nil +} + +func scanSummary(rows interface{ Scan(dest ...any) error }) (model.EmailSummary, error) { + var toJSON, ccJSON, sentAt, receivedAt, body string + var hasAttachment, read, processed, archived, deleted, replied int + var summary model.EmailSummary + err := rows.Scan( + &summary.ID, + &summary.Subject, + &summary.SenderName, + &summary.SenderEmail, + &toJSON, + &ccJSON, + &summary.DateHeader, + &sentAt, + &receivedAt, + &body, + &hasAttachment, + &read, + &processed, + &archived, + &deleted, + &replied, + &summary.AttachmentCount, + ) + if err != nil { + return model.EmailSummary{}, err + } + summary.To = decodeAddresses(toJSON) + summary.Cc = decodeAddresses(ccJSON) + summary.SentAt = parseStoredTime(sentAt) + summary.ReceivedAt = parseStoredTime(receivedAt) + summary.Snippet = snippet(body) + summary.HasAttachment = hasAttachment == 1 + summary.LocalStatus = model.LocalStatus{Read: read == 1, Processed: processed == 1, Archived: archived == 1, Deleted: deleted == 1, Replied: replied == 1} + return summary, nil +} + +func scanDetail(row interface{ Scan(dest ...any) error }) (model.EmailDetail, error) { + var toJSON, ccJSON, sentAt, receivedAt string + var read, processed, archived, deleted, replied int + var detail model.EmailDetail + err := row.Scan( + &detail.ID, + &detail.MessageIDHeader, + &detail.InReplyTo, + &detail.References, + &detail.Subject, + &detail.SenderName, + &detail.SenderEmail, + &toJSON, + &ccJSON, + &detail.DateHeader, + &sentAt, + &receivedAt, + &detail.BodyText, + &detail.BodyHTML, + &read, + &processed, + &archived, + &deleted, + &replied, + ) + if err != nil { + return model.EmailDetail{}, err + } + detail.To = decodeAddresses(toJSON) + detail.Cc = decodeAddresses(ccJSON) + detail.SentAt = parseStoredTime(sentAt) + detail.ReceivedAt = parseStoredTime(receivedAt) + detail.LocalStatus = model.LocalStatus{Read: read == 1, Processed: processed == 1, Archived: archived == 1, Deleted: deleted == 1, Replied: replied == 1} + return detail, nil +} + +func scanDraft(row interface{ Scan(dest ...any) error }) (model.Draft, error) { + var draft model.Draft + var toJSON, ccJSON, bccJSON string + var replyTo, sentMessage sql.NullInt64 + var lastError sql.NullString + var createdAt, updatedAt string + err := row.Scan( + &draft.ID, + &toJSON, + &ccJSON, + &bccJSON, + &draft.Subject, + &draft.Body, + &replyTo, + &draft.Status, + &lastError, + &sentMessage, + &createdAt, + &updatedAt, + ) + if err != nil { + return model.Draft{}, err + } + draft.To = decodeStrings(toJSON) + draft.Cc = decodeStrings(ccJSON) + draft.Bcc = decodeStrings(bccJSON) + if replyTo.Valid { + draft.ReplyToEmailID = &replyTo.Int64 + } + if sentMessage.Valid { + draft.SentMessageID = &sentMessage.Int64 + } + if lastError.Valid { + draft.LastError = lastError.String + } + draft.CreatedAt = parseStoredTime(createdAt) + draft.UpdatedAt = parseStoredTime(updatedAt) + return draft, nil +} + +type threadCandidate struct { + summary model.EmailSummary + messageID string + inReplyTo string + references string +} + +func (s *Service) threadCandidates(ctx context.Context) (map[int64]threadCandidate, error) { + rows, err := s.store.DB.QueryContext(ctx, ` + SELECT id, subject, sender_name, sender_email, to_json, cc_json, date_header, + sent_at, received_at, body_text, has_attachment, local_read, + local_processed, local_archived, local_deleted, local_replied, + (SELECT COUNT(*) FROM attachments WHERE message_id = mail_messages.id) + FROM mail_messages WHERE account_id = ?`, s.accountID) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int64]threadCandidate{} + for rows.Next() { + summary, err := scanSummary(rows) + if err != nil { + return nil, err + } + out[summary.ID] = threadCandidate{summary: summary} + } + if err := rows.Err(); err != nil { + return nil, err + } + refRows, err := s.store.DB.QueryContext(ctx, `SELECT id, message_id_header, in_reply_to, references_header FROM mail_messages WHERE account_id = ?`, s.accountID) + if err != nil { + return nil, err + } + defer refRows.Close() + for refRows.Next() { + var id int64 + var messageID, inReplyTo, refs string + if err := refRows.Scan(&id, &messageID, &inReplyTo, &refs); err != nil { + return nil, err + } + c := out[id] + c.messageID = messageID + c.inReplyTo = inReplyTo + c.references = refs + out[id] = c + } + return out, refRows.Err() +} + +func (s *Service) attachmentsForMessage(ctx context.Context, messageID int64) []model.Attachment { + rows, err := s.store.DB.QueryContext(ctx, `SELECT id, message_id, file_name, content_type, content_id, size_bytes FROM attachments WHERE message_id = ? ORDER BY id`, messageID) + if err != nil { + return nil + } + defer rows.Close() + var out []model.Attachment + for rows.Next() { + var item model.Attachment + if err := rows.Scan(&item.ID, &item.MessageID, &item.Filename, &item.ContentType, &item.ContentID, &item.SizeBytes); err == nil { + out = append(out, item) + } + } + return out +} + +func (s *Service) labelsForMessage(ctx context.Context, messageID int64) []model.Label { + rows, err := s.store.DB.QueryContext(ctx, ` + SELECT labels.id, labels.name, labels.color + FROM labels + JOIN message_labels ON message_labels.label_id = labels.id + WHERE message_labels.message_id = ? + ORDER BY labels.name`, messageID) + if err != nil { + return nil + } + defer rows.Close() + var out []model.Label + for rows.Next() { + var label model.Label + if err := rows.Scan(&label.ID, &label.Name, &label.Color); err == nil { + out = append(out, label) + } + } + return out +} + +var messageIDPattern = regexp.MustCompile(`<[^>]+>`) + +func linked(a, b threadCandidate) bool { + if a.messageID == "" || b.messageID == "" { + return false + } + return refsContain(a.inReplyTo+" "+a.references, b.messageID) || refsContain(b.inReplyTo+" "+b.references, a.messageID) +} + +func refsContain(refs, messageID string) bool { + for _, ref := range extractMessageIDs(refs) { + if ref == messageID { + return true + } + } + return false +} + +func extractMessageIDs(value string) []string { + matches := messageIDPattern.FindAllString(value, -1) + if len(matches) > 0 { + return matches + } + return strings.Fields(value) +} + +func buildReplyReferences(original model.EmailDetail) string { + refs := strings.TrimSpace(original.References) + if original.MessageIDHeader == "" { + return refs + } + if refs == "" { + return original.MessageIDHeader + } + if refsContain(refs, original.MessageIDHeader) { + return refs + } + return refs + " " + original.MessageIDHeader +} + +func normalizeDateFilter(value string, endOfDay bool) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + if t, err := time.Parse(time.RFC3339Nano, value); err == nil { + return formatTime(t.UTC()) + } + if t, err := time.Parse("2006-01-02", value); err == nil { + if endOfDay { + t = t.Add(24*time.Hour - time.Nanosecond) + } + return formatTime(t.UTC()) + } + return value +} + +func marshalJSON(v any) (string, error) { + data, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(data), nil +} + +func decodeAddresses(value string) []model.Address { + var out []model.Address + if err := json.Unmarshal([]byte(value), &out); err != nil { + return nil + } + return out +} + +func decodeStrings(value string) []string { + var out []string + if err := json.Unmarshal([]byte(value), &out); err != nil { + return nil + } + return out +} + +func boolInt(v bool) int { + if v { + return 1 + } + return 0 +} + +func ptrBool(v bool) *bool { + return &v +} + +func formatTime(t time.Time) string { + return t.UTC().Format(time.RFC3339Nano) +} + +func timePtrString(t *time.Time) string { + if t == nil { + return "" + } + return formatTime(*t) +} + +func parseStoredTime(value string) *time.Time { + if value == "" { + return nil + } + t, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + return nil + } + return &t +} + +func snippet(value string) string { + value = strings.Join(strings.Fields(value), " ") + if len(value) <= 160 { + return value + } + return value[:160] +} + +func hashBytes(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:16]) +} + +func requireAffected(res sql.Result) error { + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return ErrNotFound + } + return nil +} + +func summaryTime(summary model.EmailSummary) time.Time { + if summary.SentAt != nil { + return *summary.SentAt + } + if summary.ReceivedAt != nil { + return *summary.ReceivedAt + } + return time.Unix(0, summary.ID) +} + +var ( + ErrNotFound = errors.New("not found") + ErrAttachmentsForwardUnsupported = errors.New("forwarding attachments is not supported in MVP") + ErrDraftAlreadySent = errors.New("draft is already sent") +) diff --git a/internal/service/service_test.go b/internal/service/service_test.go new file mode 100644 index 0000000..2dfebc5 --- /dev/null +++ b/internal/service/service_test.go @@ -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: "", + 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: "", + InReplyTo: "", + References: "", + 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: "", + 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: "", + 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: "", SentAt: time.Now().UTC()}, nil +} diff --git a/internal/smtp/message_test.go b/internal/smtp/message_test.go new file mode 100644 index 0000000..31f13ba --- /dev/null +++ b/internal/smtp/message_test.go @@ -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) + } +} diff --git a/internal/smtp/smtp.go b/internal/smtp/smtp.go new file mode 100644 index 0000000..950685e --- /dev/null +++ b/internal/smtp/smtp.go @@ -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 "" +}