Implement internal mail skill MVP

This commit is contained in:
赵义仑
2026-06-06 22:28:36 +08:00
commit 097df73495
22 changed files with 4059 additions and 0 deletions

28
.env.example Normal file
View File

@@ -0,0 +1,28 @@
MAIL_ACCOUNT_ID=default
POP3_HOST=
POP3_PORT=110
POP3_USERNAME=
POP3_PASSWORD=
POP3_USE_TLS=false
POP3_TIMEOUT_SECONDS=30
SMTP_HOST=
SMTP_PORT=25
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_USE_TLS=false
SMTP_USE_STARTTLS=false
SMTP_AUTH_REQUIRED=false
SMTP_ALLOW_INSECURE_AUTH=false
SMTP_FROM_ADDRESS=
SMTP_FROM_NAME=
SMTP_TIMEOUT_SECONDS=30
DATA_DIR=./data
DATABASE_PATH=./data/internal_mail.db
RAW_MAIL_DIR=./data/raw
ATTACHMENT_DIR=./data/attachments
HTTP_HOST=127.0.0.1
HTTP_PORT=8765

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.env
.DS_Store
dist/
data/*
!data/.gitkeep

18
Makefile Normal file
View File

@@ -0,0 +1,18 @@
.PHONY: test e2e build cross-build clean
test:
go test ./...
e2e:
go test ./internal/e2e -count=1 -v
build:
go build ./cmd/server
cross-build:
mkdir -p dist
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o dist/internal-mail-skill.exe ./cmd/server
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o dist/internal-mail-skill-linux-amd64 ./cmd/server
clean:
rm -rf dist server

317
README.md Normal file
View File

@@ -0,0 +1,317 @@
# 内网邮箱 Agent Skill MVP
这是一个本地运行的邮箱 Skill Adapter面向 Agent 暴露结构化 HTTP 接口,底层使用 POP3 收信、SMTP 发信、SQLite 保存本地索引。
## 重要边界
- 本项目是 `POP3 + SMTP` 模式,不依赖 IMAP、网页自动化或邮箱数据库。
- POP3 不能可靠同步服务器侧已读、未读、文件夹、标签、归档、草稿状态。
- 已读、已处理、归档、删除、已回复、标签、草稿、已发送记录都是本地 SQLite 状态。
- SMTP 发出的邮件不一定会出现在 Webmail 的“已发送”里。
- 如果将来需要和 Webmail 已发送一致,可以改成调用 Webmail HTTP API或者发送时 BCC 自己。
- 默认绑定 `127.0.0.1`,不要把服务直接暴露到公网或共享网段。
- 附件真实路径不会返回给 Agent接口只返回 `attachment_id` 和附件元数据。
## 配置
复制示例配置:
```bash
cp .env.example .env
```
按内网邮箱配置 POP3 和 SMTP
```dotenv
MAIL_ACCOUNT_ID=default
POP3_HOST=mail.example.local
POP3_PORT=110
POP3_USERNAME=your-name
POP3_PASSWORD=your-password
POP3_USE_TLS=false
POP3_TIMEOUT_SECONDS=30
SMTP_HOST=mail.example.local
SMTP_PORT=25
SMTP_USERNAME=your-name
SMTP_PASSWORD=your-password
SMTP_USE_TLS=false
SMTP_USE_STARTTLS=false
SMTP_AUTH_REQUIRED=false
SMTP_ALLOW_INSECURE_AUTH=false
SMTP_FROM_ADDRESS=your-name@example.local
SMTP_FROM_NAME=Your Name
SMTP_TIMEOUT_SECONDS=30
```
不要把真实账号密码提交到 Git。服务日志不会打印密码。
如果内网 SMTP 服务器要求在 25 端口上使用明文 SMTP AUTH需要显式设置 `SMTP_ALLOW_INSECURE_AUTH=true`。这会把认证信息发送在未加密连接上,只应在可信内网里使用;公网服务商通常应使用 `SMTP_USE_TLS=true``SMTP_USE_STARTTLS=true`
## 启动
```bash
go run ./cmd/server
```
默认监听:
```text
http://127.0.0.1:8765
```
也可以指定配置文件:
```bash
CONFIG_PATH=/path/to/.env go run ./cmd/server
```
## 常用接口示例
健康检查:
```bash
curl http://127.0.0.1:8765/health
```
从 POP3 同步邮件。同步使用 `UIDL` 去重,不默认删除服务器邮件;单封邮件失败会进入 `errors`,不会中断整次同步:
```bash
curl -X POST http://127.0.0.1:8765/sync
```
搜索本地邮件,只返回摘要:
```bash
curl "http://127.0.0.1:8765/emails/search?q=合同&from=alice@example.local&has_attachment=true&limit=20"
```
读取邮件全文:
```bash
curl http://127.0.0.1:8765/emails/1
```
读取本地线程:
```bash
curl http://127.0.0.1:8765/emails/1/thread
```
修改本地状态,不会同步回 POP3 服务器:
```bash
curl -X POST http://127.0.0.1:8765/emails/1/mark \
-H 'Content-Type: application/json' \
-d '{"read":true,"processed":true}'
```
创建标签并应用到邮件:
```bash
curl -X POST http://127.0.0.1:8765/labels \
-H 'Content-Type: application/json' \
-d '{"name":"待处理","color":"#336699"}'
curl -X POST http://127.0.0.1:8765/emails/labels/apply \
-H 'Content-Type: application/json' \
-d '{"email_ids":[1],"label_ids":[1],"op":"add"}'
```
创建草稿:
```bash
curl -X POST http://127.0.0.1:8765/drafts \
-H 'Content-Type: application/json' \
-d '{"to":["bob@example.local"],"cc":[],"bcc":[],"subject":"测试","body":"你好"}'
```
列出草稿:
```bash
curl http://127.0.0.1:8765/drafts
```
修改草稿:
```bash
curl -X PATCH http://127.0.0.1:8765/drafts/1 \
-H 'Content-Type: application/json' \
-d '{"to":["bob@example.local"],"subject":"更新后的主题","body":"更新后的正文"}'
```
发送草稿。Bcc 不写入邮件头,但会参与 SMTP 实际发送;发送成功写入 `sent_messages`,回复邮件会把原邮件本地标记为 `replied=true`
```bash
curl -X POST http://127.0.0.1:8765/drafts/1/send
```
直接发送新邮件,内部等价于创建草稿并发送:
```bash
curl -X POST http://127.0.0.1:8765/send \
-H 'Content-Type: application/json' \
-d '{"to":["bob@example.local"],"bcc":["audit@example.local"],"subject":"直接发送","body":"正文"}'
```
转发邮件。MVP 暂不转发附件,`include_attachments=true` 会返回结构化错误:
```bash
curl -X POST http://127.0.0.1:8765/emails/1/forward \
-H 'Content-Type: application/json' \
-d '{"to":["bob@example.local"],"subject":"Fwd: 原邮件","body":"请看下面原文","include_attachments":false}'
```
## 数据目录
默认路径:
- SQLite: `./data/internal_mail.db`
- 原始邮件: `./data/raw`
- 附件文件: `./data/attachments`
这些运行时数据默认不提交到 Git。
## 构建与测试
运行测试:
```bash
go test ./...
```
只跑本地端到端测试:
```bash
go test ./internal/e2e -count=1 -v
```
`internal/e2e` 会在测试进程里启动最小 fake POP3 和 fake SMTP 服务,然后通过 HTTP API 验证:
- `GET /health`
- `POST /sync`
- POP3 `UIDL` 去重
- MIME 正文和附件解析
- 附件只暴露 `attachment_id`,不暴露真实路径
- `GET /emails/search`
- `GET /emails/{id}`
- 标签创建与应用
- 本地已读、已处理、已回复状态
- 草稿回复发送
- `POST /send` 直接发送
- SMTP envelope 包含 To、Cc、Bcc
- Bcc 不写入邮件头
- 发送成功写入 `sent_messages`
- 发送失败写入 `sent_messages(status=failed)`,不误标记草稿为 `sent`
本机编译:
```bash
go build ./cmd/server
```
交叉编译 Windows
```bash
mkdir -p dist
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o dist/internal-mail-skill.exe ./cmd/server
```
交叉编译 Linux
```bash
mkdir -p dist
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o dist/internal-mail-skill-linux-amd64 ./cmd/server
```
也可以使用 Makefile
```bash
make test
make e2e
make build
make cross-build
```
## 本地验收建议
外部邮箱注册经常受风控影响,因此交接前建议先跑本地可重复验收:
```bash
go test ./internal/e2e -count=1 -v
go test ./...
go build ./cmd/server
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o dist/internal-mail-skill.exe ./cmd/server
```
本地 E2E 不依赖真实邮箱账号,不会发出公网邮件;它验证的是本项目对 POP3、SMTP、MIME、SQLite 和 HTTP API 的主链路处理能力。
## 已完成的公网邮箱试测记录
以下记录用于交接背景,不包含任何密码。
- VFEmailPOP3 同步成功SMTP 发送成功,自发自收闭环成功,邮件可通过搜索接口查到。
- RunboxPOP3 同步成功SMTP 587 STARTTLS 和 465 TLS 均返回 `535 Incorrect authentication data`;外部投递到该账号返回 `550 Unknown account`
- GMX、eclipso、Fastmail、Purelymail、mailbox.org公开 POP3/SMTP 端口握手可达,但注册或试用受风控、手机号、试用限制影响,未形成账号级完整闭环。
结论:代码主链路已用本地 E2E 和 VFEmail 真实账号闭环验证;内网交接时应重点用公司 POP3/SMTP 账号做最终验证。
## 内网接手测试清单
1. 复制 `.env.example``.env`,填写内网 POP3/SMTP 参数。
2. 确认 `HTTP_HOST=127.0.0.1`,不要直接暴露到公网或共享网段。
3. 启动服务:
```bash
go run ./cmd/server
```
4. 健康检查:
```bash
curl http://127.0.0.1:8765/health
```
5. 同步邮件:
```bash
curl -X POST http://127.0.0.1:8765/sync
```
6. 再次同步,确认重复邮件进入 `skipped`,不会重复入库。
7. 搜索一封已知邮件:
```bash
curl "http://127.0.0.1:8765/emails/search?q=关键字&limit=10"
```
8. 读取邮件全文,确认正文、附件元数据和本地状态正常。
9. 创建草稿并发送到测试收件人。
10. 如果是回复邮件,确认 `In-Reply-To`、`References` 尽量写入,且原邮件本地 `replied=true`。
11. 如果 SMTP 发出后 Webmail 的“已发送”里看不到,这是 POP3+SMTP 模式的正常限制;需要一致性时可 BCC 自己或后续接 Webmail HTTP API。
## 常见问题
### SMTP 25 端口能连但发不出去
确认内网服务器是否需要 SMTP AUTH。如果需要明文 AUTH必须显式设置
```dotenv
SMTP_AUTH_REQUIRED=true
SMTP_ALLOW_INSECURE_AUTH=true
```
公网邮箱通常不应使用明文 AUTH应使用 `SMTP_USE_TLS=true` 或 `SMTP_USE_STARTTLS=true`。
### 同步后 Webmail 里还是未读
这是预期行为。POP3 无法可靠同步服务器侧已读、标签、归档、文件夹等状态;本项目只维护本地状态。
### SMTP 发出的邮件没有出现在 Webmail 已发送
这是预期行为。SMTP 只负责投递,不一定写入 Webmail 已发送文件夹。需要一致性时,可让系统 BCC 自己,或者后续接入 Webmail HTTP API。
### 附件在哪里
附件真实路径只保存在 SQLite 中,不通过 API 返回给 Agent。API 只返回 `attachment_id`、文件名、类型和大小。

97
cmd/server/main.go Normal file
View File

@@ -0,0 +1,97 @@
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"internal-mail-skill/internal/api"
"internal-mail-skill/internal/config"
"internal-mail-skill/internal/db"
"internal-mail-skill/internal/pop3"
"internal-mail-skill/internal/service"
mailsmtp "internal-mail-skill/internal/smtp"
)
func main() {
if err := run(); err != nil {
slog.Error("server stopped", "error", err)
os.Exit(1)
}
}
func run() error {
cfgPath := os.Getenv("CONFIG_PATH")
if cfgPath == "" {
cfgPath = ".env"
}
cfg, err := config.Load(cfgPath)
if err != nil {
return err
}
if err := ensureDataDirs(cfg); err != nil {
return err
}
store, err := db.Open(cfg.Data.DatabasePath)
if err != nil {
return err
}
defer store.Close()
ctx := context.Background()
if err := store.Migrate(ctx); err != nil {
return err
}
popFetcher := pop3.NewFetcher(cfg.POP3)
smtpSender := mailsmtp.NewSender(cfg.SMTP)
mailService := service.NewWithOptions(store, popFetcher, smtpSender, cfg.AccountID, service.Options{
RawDir: cfg.Data.RawMailDir,
AttachmentDir: cfg.Data.AttachmentDir,
FromAddress: cfg.SMTP.FromAddress,
FromName: cfg.SMTP.FromName,
})
apiServer := api.New(cfg, store, mailService)
httpServer := &http.Server{
Addr: api.ListenAddr(cfg),
Handler: apiServer.Handler(),
ReadHeaderTimeout: 10 * time.Second,
}
errCh := make(chan error, 1)
go func() {
slog.Info("internal mail skill listening", "addr", httpServer.Addr, "account_id", cfg.AccountID)
errCh <- httpServer.ListenAndServe()
}()
stopCh := make(chan os.Signal, 1)
signal.Notify(stopCh, syscall.SIGINT, syscall.SIGTERM)
select {
case sig := <-stopCh:
slog.Info("shutdown requested", "signal", sig.String())
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return httpServer.Shutdown(shutdownCtx)
case err := <-errCh:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}
}
func ensureDataDirs(cfg *config.Config) error {
for _, dir := range []string{cfg.Data.DataDir, cfg.Data.RawMailDir, cfg.Data.AttachmentDir} {
if err := os.MkdirAll(dir, 0750); err != nil {
return err
}
}
return nil
}

1
data/.gitkeep Normal file
View File

@@ -0,0 +1 @@

24
go.mod Normal file
View File

@@ -0,0 +1,24 @@
module internal-mail-skill
go 1.24.0
require (
github.com/knadh/go-pop3 v1.0.2
golang.org/x/net v0.47.0
modernc.org/sqlite v1.40.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.31.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

88
go.sum Normal file
View File

@@ -0,0 +1,88 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/knadh/go-pop3 v1.0.2 h1:gbdtwzEYedLVos/vpebM2d73NTyZxEgjgRJ4S77HlzM=
github.com/knadh/go-pop3 v1.0.2/go.mod h1:3gKw2jmrEa1lYLVtP1yEoo6bkkJ4XHDySPy8xaSjG0s=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

331
internal/api/api.go Normal file
View File

@@ -0,0 +1,331 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"time"
"internal-mail-skill/internal/config"
"internal-mail-skill/internal/db"
"internal-mail-skill/internal/service"
)
const Version = "0.1.0"
type Server struct {
cfg *config.Config
store *db.Store
svc *service.Service
}
func New(cfg *config.Config, store *db.Store, svc *service.Service) *Server {
return &Server{cfg: cfg, store: store, svc: svc}
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", s.health)
mux.HandleFunc("POST /sync", s.sync)
mux.HandleFunc("GET /emails/search", s.searchEmails)
mux.HandleFunc("GET /emails/{id}", s.getEmail)
mux.HandleFunc("GET /emails/{id}/thread", s.getThread)
mux.HandleFunc("POST /emails/{id}/mark", s.markEmail)
mux.HandleFunc("POST /labels", s.createLabel)
mux.HandleFunc("POST /emails/labels/apply", s.applyLabels)
mux.HandleFunc("POST /drafts", s.createDraft)
mux.HandleFunc("GET /drafts", s.listDrafts)
mux.HandleFunc("PATCH /drafts/{id}", s.updateDraft)
mux.HandleFunc("POST /drafts/{id}/send", s.sendDraft)
mux.HandleFunc("POST /send", s.sendDirect)
mux.HandleFunc("POST /emails/{id}/forward", s.forwardEmail)
return withJSON(mux)
}
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
dbStatus := "ok"
if err := s.store.Ping(r.Context()); err != nil {
dbStatus = "down"
}
writeJSON(w, http.StatusOK, map[string]any{
"status": dbStatus,
"version": Version,
"database": dbStatus,
"account_id": s.cfg.AccountID,
"time": time.Now().UTC().Format(time.RFC3339),
})
}
func (s *Server) sync(w http.ResponseWriter, r *http.Request) {
result, err := s.svc.Sync(r.Context())
if err != nil {
writeError(w, http.StatusBadGateway, "sync_failed", err.Error(), nil)
return
}
writeJSON(w, http.StatusOK, result)
}
func (s *Server) searchEmails(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
filters := service.SearchFilters{
Q: q.Get("q"),
From: q.Get("from"),
To: q.Get("to"),
After: q.Get("after"),
Before: q.Get("before"),
Limit: intQuery(q.Get("limit"), 50),
Offset: intQuery(q.Get("offset"), 0),
}
filters.HasAttachment = boolQuery(q.Get("has_attachment"))
filters.Read = boolQuery(q.Get("read"))
filters.Processed = boolQuery(q.Get("processed"))
filters.Archived = boolQuery(q.Get("archived"))
filters.Replied = boolQuery(q.Get("replied"))
results, err := s.svc.SearchEmails(r.Context(), filters)
if err != nil {
writeError(w, http.StatusInternalServerError, "search_failed", err.Error(), nil)
return
}
writeJSON(w, http.StatusOK, map[string]any{"emails": results, "limit": filters.Limit, "offset": filters.Offset})
}
func (s *Server) getEmail(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(w, r)
if !ok {
return
}
email, err := s.svc.GetEmail(r.Context(), id)
if err != nil {
handleServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, email)
}
func (s *Server) getThread(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(w, r)
if !ok {
return
}
thread, err := s.svc.GetThread(r.Context(), id)
if err != nil {
handleServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"thread": thread})
}
func (s *Server) markEmail(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(w, r)
if !ok {
return
}
var input service.MarkInput
if !decodeBody(w, r, &input) {
return
}
if err := s.svc.MarkEmail(r.Context(), id, input); err != nil {
handleServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) createLabel(w http.ResponseWriter, r *http.Request) {
var input struct {
Name string `json:"name"`
Color string `json:"color"`
}
if !decodeBody(w, r, &input) {
return
}
label, err := s.svc.CreateLabel(r.Context(), input.Name, input.Color)
if err != nil {
writeError(w, http.StatusBadRequest, "label_failed", err.Error(), nil)
return
}
writeJSON(w, http.StatusCreated, label)
}
func (s *Server) applyLabels(w http.ResponseWriter, r *http.Request) {
var input struct {
EmailIDs []int64 `json:"email_ids"`
LabelIDs []int64 `json:"label_ids"`
Op string `json:"op"`
}
if !decodeBody(w, r, &input) {
return
}
if err := s.svc.ApplyLabels(r.Context(), input.EmailIDs, input.LabelIDs, input.Op); err != nil {
writeError(w, http.StatusBadRequest, "label_apply_failed", err.Error(), nil)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *Server) createDraft(w http.ResponseWriter, r *http.Request) {
var input service.DraftInput
if !decodeBody(w, r, &input) {
return
}
draft, err := s.svc.CreateDraft(r.Context(), input)
if err != nil {
writeError(w, http.StatusBadRequest, "draft_create_failed", err.Error(), nil)
return
}
writeJSON(w, http.StatusCreated, draft)
}
func (s *Server) listDrafts(w http.ResponseWriter, r *http.Request) {
drafts, err := s.svc.ListDrafts(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "draft_list_failed", err.Error(), nil)
return
}
writeJSON(w, http.StatusOK, map[string]any{"drafts": drafts})
}
func (s *Server) updateDraft(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(w, r)
if !ok {
return
}
var input service.DraftInput
if !decodeBody(w, r, &input) {
return
}
draft, err := s.svc.UpdateDraft(r.Context(), id, input)
if err != nil {
handleServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, draft)
}
func (s *Server) sendDraft(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(w, r)
if !ok {
return
}
outcome, err := s.svc.SendDraft(r.Context(), id)
if err != nil {
writeError(w, http.StatusBadGateway, "send_failed", err.Error(), outcome)
return
}
writeJSON(w, http.StatusOK, outcome)
}
func (s *Server) sendDirect(w http.ResponseWriter, r *http.Request) {
var input service.DraftInput
if !decodeBody(w, r, &input) {
return
}
outcome, err := s.svc.SendDirect(r.Context(), input)
if err != nil {
writeError(w, http.StatusBadGateway, "send_failed", err.Error(), outcome)
return
}
writeJSON(w, http.StatusOK, outcome)
}
func (s *Server) forwardEmail(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(w, r)
if !ok {
return
}
var input service.ForwardInput
if !decodeBody(w, r, &input) {
return
}
outcome, err := s.svc.ForwardEmail(r.Context(), id, input)
if err != nil {
handleServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, outcome)
}
func decodeBody(w http.ResponseWriter, r *http.Request, dst any) bool {
defer r.Body.Close()
if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", err.Error(), nil)
return false
}
return true
}
func pathID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
writeError(w, http.StatusBadRequest, "invalid_id", "id must be a positive integer", nil)
return 0, false
}
return id, true
}
func intQuery(value string, def int) int {
if value == "" {
return def
}
n, err := strconv.Atoi(value)
if err != nil {
return def
}
return n
}
func boolQuery(value string) *bool {
if value == "" {
return nil
}
switch value {
case "1", "true", "TRUE", "yes", "on":
v := true
return &v
case "0", "false", "FALSE", "no", "off":
v := false
return &v
default:
return nil
}
}
func handleServiceError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, service.ErrNotFound):
writeError(w, http.StatusNotFound, "not_found", err.Error(), nil)
case errors.Is(err, service.ErrAttachmentsForwardUnsupported):
writeError(w, http.StatusBadRequest, "attachments_forward_unsupported", err.Error(), nil)
default:
writeError(w, http.StatusInternalServerError, "internal_error", err.Error(), nil)
}
}
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}
func writeError(w http.ResponseWriter, status int, code, message string, details any) {
writeJSON(w, status, map[string]any{
"error": map[string]any{
"code": code,
"message": message,
"details": details,
},
})
}
func withJSON(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
next.ServeHTTP(w, r)
})
}
func ListenAddr(cfg *config.Config) string {
return fmt.Sprintf("%s:%d", cfg.HTTP.Host, cfg.HTTP.Port)
}

177
internal/config/config.go Normal file
View File

@@ -0,0 +1,177 @@
package config
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
type Config struct {
AccountID string
POP3 POP3Config
SMTP SMTPConfig
Data DataConfig
HTTP HTTPConfig
}
type POP3Config struct {
Host string
Port int
Username string
Password string
UseTLS bool
TimeoutSeconds int
}
type SMTPConfig struct {
Host string
Port int
Username string
Password string
UseTLS bool
UseSTARTTLS bool
AuthRequired bool
AllowInsecureAuth bool
FromAddress string
FromName string
TimeoutSeconds int
}
type DataConfig struct {
DataDir string
DatabasePath string
RawMailDir string
AttachmentDir string
}
type HTTPConfig struct {
Host string
Port int
}
func (c POP3Config) Timeout() time.Duration {
return time.Duration(c.TimeoutSeconds) * time.Second
}
func (c SMTPConfig) Timeout() time.Duration {
return time.Duration(c.TimeoutSeconds) * time.Second
}
func Load(envPath string) (*Config, error) {
values := map[string]string{}
if envPath == "" {
envPath = ".env"
}
if err := readEnvFile(envPath, values); err != nil {
return nil, err
}
for _, item := range os.Environ() {
key, val, ok := strings.Cut(item, "=")
if ok {
values[key] = val
}
}
dataDir := getString(values, "DATA_DIR", "./data")
cfg := &Config{
AccountID: getString(values, "MAIL_ACCOUNT_ID", "default"),
POP3: POP3Config{
Host: getString(values, "POP3_HOST", ""),
Port: getInt(values, "POP3_PORT", 110),
Username: getString(values, "POP3_USERNAME", ""),
Password: getString(values, "POP3_PASSWORD", ""),
UseTLS: getBool(values, "POP3_USE_TLS", false),
TimeoutSeconds: getInt(values, "POP3_TIMEOUT_SECONDS", 30),
},
SMTP: SMTPConfig{
Host: getString(values, "SMTP_HOST", ""),
Port: getInt(values, "SMTP_PORT", 25),
Username: getString(values, "SMTP_USERNAME", ""),
Password: getString(values, "SMTP_PASSWORD", ""),
UseTLS: getBool(values, "SMTP_USE_TLS", false),
UseSTARTTLS: getBool(values, "SMTP_USE_STARTTLS", false),
AuthRequired: getBool(values, "SMTP_AUTH_REQUIRED", false),
AllowInsecureAuth: getBool(values, "SMTP_ALLOW_INSECURE_AUTH", false),
FromAddress: getString(values, "SMTP_FROM_ADDRESS", ""),
FromName: getString(values, "SMTP_FROM_NAME", ""),
TimeoutSeconds: getInt(values, "SMTP_TIMEOUT_SECONDS", 30),
},
Data: DataConfig{
DataDir: dataDir,
DatabasePath: getString(values, "DATABASE_PATH", filepath.Join(dataDir, "internal_mail.db")),
RawMailDir: getString(values, "RAW_MAIL_DIR", filepath.Join(dataDir, "raw")),
AttachmentDir: getString(values, "ATTACHMENT_DIR", filepath.Join(dataDir, "attachments")),
},
HTTP: HTTPConfig{
Host: getString(values, "HTTP_HOST", "127.0.0.1"),
Port: getInt(values, "HTTP_PORT", 8765),
},
}
return cfg, nil
}
func readEnvFile(path string, values map[string]string) error {
file, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("read env file: %w", err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
value = strings.Trim(value, `"'`)
values[key] = value
}
return scanner.Err()
}
func getString(values map[string]string, key, def string) string {
if v, ok := values[key]; ok {
return v
}
return def
}
func getInt(values map[string]string, key string, def int) int {
v, ok := values[key]
if !ok || strings.TrimSpace(v) == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
return def
}
return n
}
func getBool(values map[string]string, key string, def bool) bool {
v, ok := values[key]
if !ok || strings.TrimSpace(v) == "" {
return def
}
switch strings.ToLower(strings.TrimSpace(v)) {
case "1", "true", "yes", "y", "on":
return true
case "0", "false", "no", "n", "off":
return false
default:
return def
}
}

View File

@@ -0,0 +1,42 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadDefaultsAndEnvFile(t *testing.T) {
dir := t.TempDir()
envPath := filepath.Join(dir, ".env")
if err := os.WriteFile(envPath, []byte("POP3_HOST=mail.local\nSMTP_PORT=2525\nSMTP_AUTH_REQUIRED=true\nSMTP_ALLOW_INSECURE_AUTH=true\nDATA_DIR="+dir+"\n"), 0600); err != nil {
t.Fatal(err)
}
cfg, err := Load(envPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.AccountID != "default" {
t.Fatalf("AccountID = %q, want default", cfg.AccountID)
}
if cfg.POP3.Host != "mail.local" {
t.Fatalf("POP3.Host = %q", cfg.POP3.Host)
}
if cfg.POP3.Port != 110 {
t.Fatalf("POP3.Port = %d, want 110", cfg.POP3.Port)
}
if cfg.SMTP.Port != 2525 {
t.Fatalf("SMTP.Port = %d, want 2525", cfg.SMTP.Port)
}
if !cfg.SMTP.AuthRequired {
t.Fatal("SMTP.AuthRequired = false, want true")
}
if !cfg.SMTP.AllowInsecureAuth {
t.Fatal("SMTP.AllowInsecureAuth = false, want true")
}
if cfg.HTTP.Host != "127.0.0.1" || cfg.HTTP.Port != 8765 {
t.Fatalf("HTTP default = %s:%d", cfg.HTTP.Host, cfg.HTTP.Port)
}
}

164
internal/db/db.go Normal file
View File

@@ -0,0 +1,164 @@
package db
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
type Store struct {
DB *sql.DB
}
func Open(path string) (*Store, error) {
if path != ":memory:" {
if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil {
return nil, fmt.Errorf("create database directory: %w", err)
}
}
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
_ = db.Close()
return nil, fmt.Errorf("enable foreign keys: %w", err)
}
if path == ":memory:" {
db.SetMaxOpenConns(1)
}
return &Store{DB: db}, nil
}
func (s *Store) Close() error {
if s == nil || s.DB == nil {
return nil
}
return s.DB.Close()
}
func (s *Store) Ping(ctx context.Context) error {
return s.DB.PingContext(ctx)
}
func (s *Store) Migrate(ctx context.Context) error {
_, err := s.DB.ExecContext(ctx, schemaSQL)
if err != nil {
return fmt.Errorf("migrate sqlite: %w", err)
}
return nil
}
const schemaSQL = `
CREATE TABLE IF NOT EXISTS mail_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id TEXT NOT NULL,
pop3_uid TEXT NOT NULL,
message_id_header TEXT,
in_reply_to TEXT,
references_header TEXT,
subject TEXT,
sender_name TEXT,
sender_email TEXT,
to_json TEXT NOT NULL DEFAULT '[]',
cc_json TEXT NOT NULL DEFAULT '[]',
date_header TEXT,
sent_at TEXT,
received_at TEXT NOT NULL,
body_text TEXT,
body_html TEXT,
raw_eml_path TEXT,
has_attachment INTEGER NOT NULL DEFAULT 0,
local_read INTEGER NOT NULL DEFAULT 0,
local_processed INTEGER NOT NULL DEFAULT 0,
local_archived INTEGER NOT NULL DEFAULT 0,
local_deleted INTEGER NOT NULL DEFAULT 0,
local_replied INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(account_id, pop3_uid)
);
CREATE INDEX IF NOT EXISTS idx_mail_messages_account_uid ON mail_messages(account_id, pop3_uid);
CREATE INDEX IF NOT EXISTS idx_mail_messages_message_id ON mail_messages(message_id_header);
CREATE INDEX IF NOT EXISTS idx_mail_messages_sender ON mail_messages(sender_email);
CREATE INDEX IF NOT EXISTS idx_mail_messages_sent_at ON mail_messages(sent_at);
CREATE INDEX IF NOT EXISTS idx_mail_messages_status ON mail_messages(local_read, local_processed, local_archived, local_deleted, local_replied);
CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL,
file_name TEXT NOT NULL,
content_type TEXT,
content_id TEXT,
size_bytes INTEGER NOT NULL DEFAULT 0,
storage_path TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(message_id) REFERENCES mail_messages(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_attachments_message ON attachments(message_id);
CREATE TABLE IF NOT EXISTS labels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
color TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS message_labels (
message_id INTEGER NOT NULL,
label_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY(message_id, label_id),
FOREIGN KEY(message_id) REFERENCES mail_messages(id) ON DELETE CASCADE,
FOREIGN KEY(label_id) REFERENCES labels(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_message_labels_label ON message_labels(label_id);
CREATE TABLE IF NOT EXISTS drafts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
to_json TEXT NOT NULL DEFAULT '[]',
cc_json TEXT NOT NULL DEFAULT '[]',
bcc_json TEXT NOT NULL DEFAULT '[]',
subject TEXT,
body TEXT,
reply_to_email_id INTEGER,
status TEXT NOT NULL DEFAULT 'draft',
last_error TEXT,
sent_message_id INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(reply_to_email_id) REFERENCES mail_messages(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_drafts_status ON drafts(status);
CREATE TABLE IF NOT EXISTS sent_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
draft_id INTEGER,
to_json TEXT NOT NULL DEFAULT '[]',
cc_json TEXT NOT NULL DEFAULT '[]',
bcc_json TEXT NOT NULL DEFAULT '[]',
subject TEXT,
body TEXT,
message_id_header TEXT,
in_reply_to TEXT,
references_header TEXT,
smtp_from TEXT,
status TEXT NOT NULL,
error_message TEXT,
sent_at TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY(draft_id) REFERENCES drafts(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS idx_sent_messages_draft ON sent_messages(draft_id);
CREATE INDEX IF NOT EXISTS idx_sent_messages_status ON sent_messages(status);
`

33
internal/db/db_test.go Normal file
View File

@@ -0,0 +1,33 @@
package db
import (
"context"
"database/sql"
"testing"
)
func TestOpenAndMigrateCreatesCoreTables(t *testing.T) {
store, err := Open(":memory:")
if err != nil {
t.Fatalf("Open() error = %v", err)
}
defer store.Close()
if err := store.Migrate(context.Background()); err != nil {
t.Fatalf("Migrate() error = %v", err)
}
var name string
err = store.DB.QueryRowContext(context.Background(), "SELECT name FROM sqlite_master WHERE type='table' AND name='mail_messages'").Scan(&name)
if err != nil {
t.Fatalf("mail_messages table missing: %v", err)
}
err = store.DB.QueryRowContext(context.Background(), "SELECT name FROM sqlite_master WHERE type='table' AND name='sent_messages'").Scan(&name)
if err != nil && err != sql.ErrNoRows {
t.Fatalf("sent_messages lookup failed: %v", err)
}
if name != "sent_messages" {
t.Fatalf("sent_messages table missing, got %q", name)
}
}

View File

@@ -0,0 +1,571 @@
package e2e
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net"
"net/http"
"net/http/httptest"
"net/textproto"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
"internal-mail-skill/internal/api"
"internal-mail-skill/internal/config"
"internal-mail-skill/internal/db"
"internal-mail-skill/internal/pop3"
"internal-mail-skill/internal/service"
mailsmtp "internal-mail-skill/internal/smtp"
)
func TestLocalPOP3SMTPHTTPFlow(t *testing.T) {
ctx := context.Background()
now := time.Date(2026, 6, 6, 9, 30, 0, 0, time.UTC)
raw := buildTestMessage(t, now)
popServer := newFakePOP3Server(t, []popMessage{{uid: "uid-local-1", raw: raw}})
smtpServer := newFakeSMTPServer(t)
dataDir := t.TempDir()
store, err := db.Open(filepath.Join(dataDir, "mail.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = store.Close() })
if err := store.Migrate(ctx); err != nil {
t.Fatalf("migrate db: %v", err)
}
cfg := &config.Config{
AccountID: "local-e2e",
POP3: config.POP3Config{
Host: popServer.host,
Port: popServer.port,
Username: "local-user",
Password: "local-pass",
TimeoutSeconds: 5,
},
SMTP: config.SMTPConfig{
Host: smtpServer.host,
Port: smtpServer.port,
FromAddress: "agent@example.local",
FromName: "Local Agent",
TimeoutSeconds: 5,
AuthRequired: true,
Username: "smtp-user",
Password: "smtp-pass",
AllowInsecureAuth: true,
},
Data: config.DataConfig{
DataDir: dataDir,
DatabasePath: filepath.Join(dataDir, "mail.db"),
RawMailDir: filepath.Join(dataDir, "raw"),
AttachmentDir: filepath.Join(dataDir, "attachments"),
},
}
svc := service.NewWithOptions(
store,
pop3.NewFetcher(cfg.POP3),
mailsmtp.NewSender(cfg.SMTP),
cfg.AccountID,
service.Options{RawDir: cfg.Data.RawMailDir, AttachmentDir: cfg.Data.AttachmentDir, FromAddress: cfg.SMTP.FromAddress, FromName: cfg.SMTP.FromName},
)
server := httptest.NewServer(api.New(cfg, store, svc).Handler())
t.Cleanup(server.Close)
health := getJSON[map[string]any](t, server.URL+"/health")
if health["status"] != "ok" || health["database"] != "ok" {
t.Fatalf("unexpected health: %#v", health)
}
syncResult := postJSON[map[string]any](t, server.URL+"/sync", nil, http.StatusOK)
if got := int(syncResult["stored"].(float64)); got != 1 {
t.Fatalf("stored = %d, want 1; result=%#v", got, syncResult)
}
syncAgain := postJSON[map[string]any](t, server.URL+"/sync", nil, http.StatusOK)
if got := int(syncAgain["skipped"].(float64)); got != 1 {
t.Fatalf("skipped after second sync = %d, want 1; result=%#v", got, syncAgain)
}
search := getJSON[map[string]any](t, server.URL+"/emails/search?q=Quarterly+Report&has_attachment=true")
emails := search["emails"].([]any)
if len(emails) != 1 {
t.Fatalf("search returned %d emails, want 1: %#v", len(emails), search)
}
emailID := int64(emails[0].(map[string]any)["id"].(float64))
detail := getJSON[map[string]any](t, fmt.Sprintf("%s/emails/%d", server.URL, emailID))
if detail["subject"] != "Quarterly Report" {
t.Fatalf("subject = %q", detail["subject"])
}
if !strings.Contains(detail["body_text"].(string), "The report is attached.") {
t.Fatalf("body_text missing expected content: %q", detail["body_text"])
}
attachments := detail["attachments"].([]any)
if len(attachments) != 1 {
t.Fatalf("attachments = %d, want 1", len(attachments))
}
if _, ok := attachments[0].(map[string]any)["attachment_id"]; !ok {
t.Fatalf("attachment id not exposed in detail: %#v", attachments[0])
}
if _, ok := attachments[0].(map[string]any)["storage_path"]; ok {
t.Fatalf("attachment storage path leaked: %#v", attachments[0])
}
label := postJSON[map[string]any](t, server.URL+"/labels", map[string]any{"name": "Local E2E", "color": "#336699"}, http.StatusCreated)
postJSON[map[string]any](t, server.URL+"/emails/labels/apply", map[string]any{
"email_ids": []int64{emailID},
"label_ids": []int64{int64(label["id"].(float64))},
"op": "add",
}, http.StatusOK)
postJSON[map[string]any](t, fmt.Sprintf("%s/emails/%d/mark", server.URL, emailID), map[string]any{"read": true, "processed": true}, http.StatusOK)
marked := getJSON[map[string]any](t, fmt.Sprintf("%s/emails/%d", server.URL, emailID))
status := marked["local_status"].(map[string]any)
if status["read"] != true || status["processed"] != true {
t.Fatalf("local status not updated: %#v", status)
}
if got := len(marked["labels"].([]any)); got != 1 {
t.Fatalf("labels after apply = %d, want 1", got)
}
draft := postJSON[map[string]any](t, server.URL+"/drafts", map[string]any{
"to": []string{"recipient@example.local"},
"cc": []string{"copy@example.local"},
"bcc": []string{"audit@example.local"},
"subject": "Re: Quarterly Report",
"body": "Thanks, received.",
"reply_to_email_id": emailID,
}, http.StatusCreated)
outcome := postJSON[map[string]any](t, fmt.Sprintf("%s/drafts/%d/send", server.URL, int64(draft["id"].(float64))), nil, http.StatusOK)
if sent := outcome["sent_message"].(map[string]any); sent["status"] != "sent" {
t.Fatalf("draft send status = %#v", sent)
}
sentMessages := smtpServer.messages()
if len(sentMessages) != 1 {
t.Fatalf("smtp captured %d messages, want 1", len(sentMessages))
}
if !containsAll(sentMessages[0].recipients, []string{"recipient@example.local", "copy@example.local", "audit@example.local"}) {
t.Fatalf("smtp recipients = %#v", sentMessages[0].recipients)
}
rawSent := string(sentMessages[0].data)
if strings.Contains(rawSent, "audit@example.local") {
t.Fatalf("Bcc recipient leaked into message headers/body:\n%s", rawSent)
}
if !strings.Contains(rawSent, "In-Reply-To: <local-1@example.local>") {
t.Fatalf("reply headers missing from SMTP message:\n%s", rawSent)
}
replied := getJSON[map[string]any](t, fmt.Sprintf("%s/emails/%d", server.URL, emailID))
if replied["local_status"].(map[string]any)["replied"] != true {
t.Fatalf("original message was not marked replied: %#v", replied["local_status"])
}
postJSON[map[string]any](t, server.URL+"/send", map[string]any{
"to": []string{"new@example.local"},
"subject": "Direct local send",
"body": "Hello from direct send.",
}, http.StatusOK)
if len(smtpServer.messages()) != 2 {
t.Fatalf("smtp captured %d messages after /send, want 2", len(smtpServer.messages()))
}
var sentCount int
if err := store.DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM sent_messages WHERE status = 'sent'").Scan(&sentCount); err != nil {
t.Fatalf("count sent messages: %v", err)
}
if sentCount != 2 {
t.Fatalf("sent_messages rows = %d, want 2", sentCount)
}
assertNoStoredAttachmentPathInAPI(t, detail)
}
func buildTestMessage(t *testing.T, date time.Time) []byte {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
textPart, err := writer.CreatePart(textproto.MIMEHeader{
"Content-Type": {"text/plain; charset=utf-8"},
"Content-Transfer-Encoding": {"quoted-printable"},
})
if err != nil {
t.Fatalf("create text part: %v", err)
}
_, _ = io.WriteString(textPart, "Hello Agent,\r\nThe report is attached.\r\n")
attachmentPart, err := writer.CreatePart(textproto.MIMEHeader{
"Content-Type": {"text/plain; charset=utf-8"},
"Content-Disposition": {`attachment; filename="../report.txt"`},
"Content-Transfer-Encoding": {"base64"},
})
if err != nil {
t.Fatalf("create attachment part: %v", err)
}
_, _ = io.WriteString(attachmentPart, "cmVwb3J0LWJvZHk=")
if err := writer.Close(); err != nil {
t.Fatalf("close multipart: %v", err)
}
headers := strings.Join([]string{
"Message-ID: <local-1@example.local>",
"From: Alice Example <alice@example.local>",
"To: Agent <agent@example.local>",
"Subject: Quarterly Report",
"Date: " + date.Format(time.RFC1123Z),
"Content-Type: multipart/mixed; boundary=" + writer.Boundary(),
"MIME-Version: 1.0",
"",
"",
}, "\r\n")
return []byte(headers + body.String())
}
type popMessage struct {
uid string
raw []byte
}
type fakePOP3Server struct {
host string
port int
ln net.Listener
}
func newFakePOP3Server(t *testing.T, messages []popMessage) *fakePOP3Server {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen pop3: %v", err)
}
host, portString, _ := net.SplitHostPort(ln.Addr().String())
port, _ := strconv.Atoi(portString)
server := &fakePOP3Server{host: host, port: port, ln: ln}
t.Cleanup(func() { _ = ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go handlePOP3Conn(conn, messages)
}
}()
return server
}
func handlePOP3Conn(conn net.Conn, messages []popMessage) {
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
reader := bufio.NewReader(conn)
writer := bufio.NewWriter(conn)
writeLine(writer, "+OK local POP3 ready")
for {
line, err := reader.ReadString('\n')
if err != nil {
return
}
line = strings.TrimRight(line, "\r\n")
parts := strings.Fields(line)
if len(parts) == 0 {
writeLine(writer, "-ERR empty command")
continue
}
switch strings.ToUpper(parts[0]) {
case "USER", "PASS", "NOOP":
writeLine(writer, "+OK")
case "UIDL":
if len(parts) > 1 {
idx, _ := strconv.Atoi(parts[1])
if idx <= 0 || idx > len(messages) {
writeLine(writer, "-ERR no such message")
continue
}
writeLine(writer, fmt.Sprintf("+OK %d %s", idx, messages[idx-1].uid))
continue
}
writeLine(writer, "+OK uid list follows")
for i, msg := range messages {
writeLine(writer, fmt.Sprintf("%d %s", i+1, msg.uid))
}
writeLine(writer, ".")
case "RETR":
if len(parts) < 2 {
writeLine(writer, "-ERR message id required")
continue
}
idx, _ := strconv.Atoi(parts[1])
if idx <= 0 || idx > len(messages) {
writeLine(writer, "-ERR no such message")
continue
}
writeLine(writer, fmt.Sprintf("+OK %d octets", len(messages[idx-1].raw)))
writeMultiline(writer, messages[idx-1].raw)
case "QUIT":
writeLine(writer, "+OK bye")
return
default:
writeLine(writer, "-ERR unsupported command")
}
}
}
func writeLine(w *bufio.Writer, line string) {
_, _ = w.WriteString(line + "\r\n")
_ = w.Flush()
}
func writeMultiline(w *bufio.Writer, raw []byte) {
for _, line := range strings.Split(strings.ReplaceAll(string(raw), "\r\n", "\n"), "\n") {
if strings.HasPrefix(line, ".") {
line = "." + line
}
_, _ = w.WriteString(line + "\r\n")
}
_, _ = w.WriteString(".\r\n")
_ = w.Flush()
}
type smtpMessage struct {
from string
recipients []string
data []byte
}
type fakeSMTPServer struct {
host string
port int
ln net.Listener
mu sync.Mutex
sent []smtpMessage
}
func newFakeSMTPServer(t *testing.T) *fakeSMTPServer {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen smtp: %v", err)
}
host, portString, _ := net.SplitHostPort(ln.Addr().String())
port, _ := strconv.Atoi(portString)
server := &fakeSMTPServer{host: host, port: port, ln: ln}
t.Cleanup(func() { _ = ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go server.handleConn(conn)
}
}()
return server
}
func (s *fakeSMTPServer) messages() []smtpMessage {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]smtpMessage, len(s.sent))
copy(out, s.sent)
return out
}
func (s *fakeSMTPServer) handleConn(conn net.Conn) {
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
reader := bufio.NewReader(conn)
writer := bufio.NewWriter(conn)
writeLine(writer, "220 local SMTP ready")
var current smtpMessage
for {
line, err := reader.ReadString('\n')
if err != nil {
return
}
line = strings.TrimRight(line, "\r\n")
upper := strings.ToUpper(line)
switch {
case strings.HasPrefix(upper, "EHLO ") || strings.HasPrefix(upper, "HELO "):
writeLine(writer, "250-localhost")
writeLine(writer, "250-AUTH PLAIN LOGIN")
writeLine(writer, "250 HELP")
case strings.HasPrefix(upper, "AUTH "):
writeLine(writer, "235 2.7.0 Authentication successful")
case strings.HasPrefix(upper, "MAIL FROM:"):
current = smtpMessage{from: extractSMTPPath(line)}
writeLine(writer, "250 OK")
case strings.HasPrefix(upper, "RCPT TO:"):
current.recipients = append(current.recipients, extractSMTPPath(line))
writeLine(writer, "250 OK")
case upper == "DATA":
writeLine(writer, "354 End data with <CR><LF>.<CR><LF>")
data, err := readSMTPData(reader)
if err != nil {
return
}
current.data = data
s.mu.Lock()
s.sent = append(s.sent, current)
s.mu.Unlock()
writeLine(writer, "250 OK queued")
case upper == "RSET":
current = smtpMessage{}
writeLine(writer, "250 OK")
case upper == "QUIT":
writeLine(writer, "221 bye")
return
default:
writeLine(writer, "250 OK")
}
}
}
func extractSMTPPath(line string) string {
_, value, ok := strings.Cut(line, ":")
if !ok {
return ""
}
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, "<")
value = strings.TrimSuffix(value, ">")
return value
}
func readSMTPData(reader *bufio.Reader) ([]byte, error) {
var out bytes.Buffer
for {
line, err := reader.ReadString('\n')
if err != nil {
return nil, err
}
if line == ".\r\n" || line == ".\n" {
return out.Bytes(), nil
}
if strings.HasPrefix(line, "..") {
line = line[1:]
}
out.WriteString(line)
}
}
func getJSON[T any](t *testing.T, url string) T {
t.Helper()
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
t.Fatalf("new GET request: %v", err)
}
return doJSON[T](t, req, http.StatusOK)
}
func postJSON[T any](t *testing.T, url string, body any, status int) T {
t.Helper()
var reader io.Reader
if body != nil {
payload, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
reader = bytes.NewReader(payload)
}
req, err := http.NewRequest(http.MethodPost, url, reader)
if err != nil {
t.Fatalf("new POST request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
return doJSON[T](t, req, status)
}
func doJSON[T any](t *testing.T, req *http.Request, status int) T {
t.Helper()
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", req.Method, req.URL, err)
}
defer resp.Body.Close()
payload, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read response body: %v", err)
}
if resp.StatusCode != status {
t.Fatalf("%s %s status=%d want=%d body=%s", req.Method, req.URL, resp.StatusCode, status, payload)
}
var out T
if err := json.Unmarshal(payload, &out); err != nil {
t.Fatalf("decode response JSON: %v body=%s", err, payload)
}
return out
}
func containsAll(got, want []string) bool {
seen := map[string]bool{}
for _, item := range got {
seen[item] = true
}
for _, item := range want {
if !seen[item] {
return false
}
}
return true
}
func assertNoStoredAttachmentPathInAPI(t *testing.T, detail map[string]any) {
t.Helper()
encoded, err := json.Marshal(detail)
if err != nil {
t.Fatalf("marshal detail: %v", err)
}
if strings.Contains(string(encoded), string(os.PathSeparator)+"attachments"+string(os.PathSeparator)) {
t.Fatalf("API response appears to expose attachment storage path: %s", encoded)
}
}
func TestSentMessagesTableStoresFailedSendWithoutMarkingDraftSent(t *testing.T) {
ctx := context.Background()
dataDir := t.TempDir()
store, err := db.Open(filepath.Join(dataDir, "mail.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = store.Close() })
if err := store.Migrate(ctx); err != nil {
t.Fatalf("migrate db: %v", err)
}
cfg := &config.Config{AccountID: "local-fail"}
svc := service.NewWithOptions(store, nil, failingMailer{}, cfg.AccountID, service.Options{FromAddress: "agent@example.local"})
server := httptest.NewServer(api.New(cfg, store, svc).Handler())
t.Cleanup(server.Close)
draft := postJSON[map[string]any](t, server.URL+"/drafts", map[string]any{
"to": []string{"recipient@example.local"},
"subject": "Will fail",
"body": "Body",
}, http.StatusCreated)
postJSON[map[string]any](t, fmt.Sprintf("%s/drafts/%d/send", server.URL, int64(draft["id"].(float64))), nil, http.StatusBadGateway)
var status, lastError, sentStatus, errorMessage string
err = store.DB.QueryRowContext(ctx, `
SELECT d.status, COALESCE(d.last_error, ''), sm.status, COALESCE(sm.error_message, '')
FROM drafts d JOIN sent_messages sm ON sm.draft_id = d.id
WHERE d.id = ?`, int64(draft["id"].(float64))).Scan(&status, &lastError, &sentStatus, &errorMessage)
if err != nil {
t.Fatalf("query failed send rows: %v", err)
}
if status != "draft" || sentStatus != "failed" || lastError == "" || errorMessage == "" {
t.Fatalf("unexpected failed send persistence: draft=%q lastError=%q sent=%q error=%q", status, lastError, sentStatus, errorMessage)
}
}
type failingMailer struct{}
func (failingMailer) Send(context.Context, mailsmtp.SendRequest) (mailsmtp.SendResult, error) {
return mailsmtp.SendResult{}, fmt.Errorf("local smtp failure")
}

124
internal/model/model.go Normal file
View File

@@ -0,0 +1,124 @@
package model
import "time"
type Address struct {
Name string `json:"name"`
Email string `json:"email"`
}
type ParsedAttachment struct {
Filename string `json:"filename"`
ContentType string `json:"content_type"`
ContentID string `json:"content_id,omitempty"`
SizeBytes int64 `json:"size_bytes"`
Data []byte `json:"-"`
}
type ParsedEmail struct {
MessageIDHeader string `json:"message_id_header"`
InReplyTo string `json:"in_reply_to"`
References string `json:"references_header"`
Subject string `json:"subject"`
SenderName string `json:"sender_name"`
SenderEmail string `json:"sender_email"`
To []Address `json:"to"`
Cc []Address `json:"cc"`
DateHeader string `json:"date_header"`
SentAt *time.Time `json:"sent_at,omitempty"`
BodyText string `json:"body_text"`
BodyHTML string `json:"body_html"`
Attachments []ParsedAttachment `json:"attachments"`
}
type Attachment struct {
ID int64 `json:"attachment_id"`
MessageID int64 `json:"-"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
ContentID string `json:"content_id,omitempty"`
SizeBytes int64 `json:"size_bytes"`
}
type Label struct {
ID int64 `json:"id"`
Name string `json:"name"`
Color string `json:"color,omitempty"`
}
type LocalStatus struct {
Read bool `json:"read"`
Processed bool `json:"processed"`
Archived bool `json:"archived"`
Deleted bool `json:"deleted"`
Replied bool `json:"replied"`
}
type EmailSummary struct {
ID int64 `json:"id"`
Subject string `json:"subject"`
SenderName string `json:"sender_name"`
SenderEmail string `json:"sender_email"`
To []Address `json:"to"`
Cc []Address `json:"cc,omitempty"`
DateHeader string `json:"date"`
SentAt *time.Time `json:"sent_at,omitempty"`
ReceivedAt *time.Time `json:"received_at,omitempty"`
Snippet string `json:"snippet"`
HasAttachment bool `json:"has_attachment"`
AttachmentCount int `json:"attachment_count"`
LocalStatus LocalStatus `json:"local_status"`
Labels []Label `json:"labels"`
}
type EmailDetail struct {
ID int64 `json:"id"`
MessageIDHeader string `json:"message_id_header"`
InReplyTo string `json:"in_reply_to,omitempty"`
References string `json:"references_header,omitempty"`
Subject string `json:"subject"`
SenderName string `json:"from_name"`
SenderEmail string `json:"from_email"`
To []Address `json:"to"`
Cc []Address `json:"cc"`
DateHeader string `json:"date"`
SentAt *time.Time `json:"sent_at,omitempty"`
ReceivedAt *time.Time `json:"received_at,omitempty"`
BodyText string `json:"body_text"`
BodyHTML string `json:"body_html"`
Attachments []Attachment `json:"attachments"`
LocalStatus LocalStatus `json:"local_status"`
Labels []Label `json:"labels"`
}
type Draft struct {
ID int64 `json:"id"`
To []string `json:"to"`
Cc []string `json:"cc"`
Bcc []string `json:"bcc"`
Subject string `json:"subject"`
Body string `json:"body"`
ReplyToEmailID *int64 `json:"reply_to_email_id,omitempty"`
Status string `json:"status"`
LastError string `json:"last_error,omitempty"`
SentMessageID *int64 `json:"sent_message_id,omitempty"`
CreatedAt *time.Time `json:"created_at,omitempty"`
UpdatedAt *time.Time `json:"updated_at,omitempty"`
}
type FetchedMessage struct {
ServerID int `json:"server_id"`
UID string `json:"uid"`
Raw []byte `json:"-"`
Error string `json:"error,omitempty"`
}
type SentMessage struct {
ID int64 `json:"id"`
DraftID *int64 `json:"draft_id,omitempty"`
Subject string `json:"subject"`
MessageIDHeader string `json:"message_id_header,omitempty"`
Status string `json:"status"`
ErrorMessage string `json:"error_message,omitempty"`
SentAt *time.Time `json:"sent_at,omitempty"`
}

228
internal/parser/parser.go Normal file
View File

@@ -0,0 +1,228 @@
package parser
import (
"bytes"
"encoding/base64"
"fmt"
"io"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/mail"
"path/filepath"
"strings"
"time"
"golang.org/x/net/html/charset"
"internal-mail-skill/internal/model"
)
func Parse(raw []byte) (model.ParsedEmail, error) {
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return model.ParsedEmail{}, fmt.Errorf("read message: %w", err)
}
parsed := model.ParsedEmail{
MessageIDHeader: strings.TrimSpace(msg.Header.Get("Message-ID")),
InReplyTo: strings.TrimSpace(msg.Header.Get("In-Reply-To")),
References: strings.TrimSpace(msg.Header.Get("References")),
Subject: decodeHeader(msg.Header.Get("Subject")),
DateHeader: strings.TrimSpace(msg.Header.Get("Date")),
}
if sentAt, err := mail.ParseDate(parsed.DateHeader); err == nil {
parsed.SentAt = &sentAt
}
if from, err := mail.ParseAddress(msg.Header.Get("From")); err == nil {
parsed.SenderName = decodeHeader(from.Name)
parsed.SenderEmail = strings.ToLower(from.Address)
}
parsed.To = parseAddressList(msg.Header.Get("To"))
parsed.Cc = parseAddressList(msg.Header.Get("Cc"))
if err := parsePart(mail.Header(msg.Header), msg.Body, &parsed); err != nil {
return model.ParsedEmail{}, err
}
return parsed, nil
}
func parsePart(header mail.Header, body io.Reader, out *model.ParsedEmail) error {
mediaType, params, err := mime.ParseMediaType(header.Get("Content-Type"))
if err != nil || mediaType == "" {
mediaType = "text/plain"
}
mediaType = strings.ToLower(mediaType)
if strings.HasPrefix(mediaType, "multipart/") {
boundary := params["boundary"]
if boundary == "" {
return fmt.Errorf("multipart message missing boundary")
}
mr := multipart.NewReader(body, boundary)
for {
part, err := mr.NextPart()
if err == io.EOF {
return nil
}
if err != nil {
return fmt.Errorf("read multipart: %w", err)
}
if err := parsePart(mail.Header(part.Header), part, out); err != nil {
return err
}
}
}
decoded := transferDecodedReader(header, body)
filename := partFilename(header)
disposition := strings.ToLower(header.Get("Content-Disposition"))
isAttachment := filename != "" || strings.Contains(disposition, "attachment")
if !isAttachment && strings.HasPrefix(mediaType, "text/") {
text, err := readText(decoded, params["charset"])
if err != nil {
return fmt.Errorf("read text part: %w", err)
}
switch mediaType {
case "text/html":
appendText(&out.BodyHTML, text)
default:
appendText(&out.BodyText, text)
}
return nil
}
data, err := io.ReadAll(decoded)
if err != nil {
return fmt.Errorf("read attachment: %w", err)
}
if filename == "" {
filename = "attachment.bin"
}
out.Attachments = append(out.Attachments, model.ParsedAttachment{
Filename: sanitizeFilename(filename),
ContentType: mediaType,
ContentID: trimAngle(header.Get("Content-ID")),
SizeBytes: int64(len(data)),
Data: data,
})
return nil
}
func decodeHeader(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
decoder := &mime.WordDecoder{CharsetReader: charset.NewReaderLabel}
decoded, err := decoder.DecodeHeader(value)
if err != nil {
return value
}
return decoded
}
func parseAddressList(value string) []model.Address {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
list, err := mail.ParseAddressList(value)
if err != nil {
decoded := decodeHeader(value)
list, err = mail.ParseAddressList(decoded)
if err != nil {
return nil
}
}
out := make([]model.Address, 0, len(list))
for _, addr := range list {
out = append(out, model.Address{
Name: decodeHeader(addr.Name),
Email: strings.ToLower(addr.Address),
})
}
return out
}
func transferDecodedReader(header mail.Header, r io.Reader) io.Reader {
switch strings.ToLower(strings.TrimSpace(header.Get("Content-Transfer-Encoding"))) {
case "base64":
return base64.NewDecoder(base64.StdEncoding, r)
case "quoted-printable":
return quotedprintable.NewReader(r)
default:
return r
}
}
func readText(r io.Reader, label string) (string, error) {
if label != "" {
cr, err := charset.NewReaderLabel(label, r)
if err == nil {
r = cr
}
}
data, err := io.ReadAll(r)
if err != nil {
return "", err
}
return string(data), nil
}
func partFilename(header mail.Header) string {
if disp := header.Get("Content-Disposition"); disp != "" {
_, params, err := mime.ParseMediaType(disp)
if err == nil && params["filename"] != "" {
return decodeHeader(params["filename"])
}
}
if ct := header.Get("Content-Type"); ct != "" {
_, params, err := mime.ParseMediaType(ct)
if err == nil && params["name"] != "" {
return decodeHeader(params["name"])
}
}
return ""
}
func appendText(dst *string, value string) {
if strings.TrimSpace(value) == "" {
return
}
if strings.TrimSpace(*dst) == "" {
*dst = value
return
}
*dst += "\n" + value
}
func sanitizeFilename(name string) string {
name = filepath.Base(strings.TrimSpace(name))
name = strings.ReplaceAll(name, "\x00", "")
name = strings.ReplaceAll(name, "/", "_")
name = strings.ReplaceAll(name, "\\", "_")
if name == "." || name == "" {
return "attachment.bin"
}
return name
}
func trimAngle(value string) string {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, "<")
value = strings.TrimSuffix(value, ">")
return value
}
func ParseTimeRFC3339(value string) *time.Time {
if value == "" {
return nil
}
t, err := time.Parse(time.RFC3339Nano, value)
if err != nil {
return nil
}
return &t
}

View File

@@ -0,0 +1,63 @@
package parser
import (
"strings"
"testing"
)
func TestParseMultipartEmailWithAttachment(t *testing.T) {
raw := strings.Join([]string{
"Message-ID: <m1@example.com>",
"Subject: =?UTF-8?B?5rWL6K+V6YKu5Lu2?=",
"From: =?UTF-8?B?5byg5LiJ?= <sender@example.com>",
"To: receiver@example.com",
"Cc: cc@example.com",
"Date: Tue, 04 Jun 2024 12:00:00 +0800",
"MIME-Version: 1.0",
"Content-Type: multipart/mixed; boundary=mix",
"",
"--mix",
"Content-Type: multipart/alternative; boundary=alt",
"",
"--alt",
"Content-Type: text/plain; charset=utf-8",
"",
"plain body",
"--alt",
"Content-Type: text/html; charset=utf-8",
"",
"<p>html body</p>",
"--alt--",
"--mix",
"Content-Type: text/plain; name=\"note.txt\"",
"Content-Disposition: attachment; filename=\"note.txt\"",
"Content-Transfer-Encoding: base64",
"",
"YXR0YWNobWVudA==",
"--mix--",
"",
}, "\r\n")
msg, err := Parse([]byte(raw))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if msg.Subject != "测试邮件" {
t.Fatalf("Subject = %q", msg.Subject)
}
if msg.SenderName != "张三" || msg.SenderEmail != "sender@example.com" {
t.Fatalf("sender = %q <%s>", msg.SenderName, msg.SenderEmail)
}
if strings.TrimSpace(msg.BodyText) != "plain body" {
t.Fatalf("BodyText = %q", msg.BodyText)
}
if !strings.Contains(msg.BodyHTML, "html body") {
t.Fatalf("BodyHTML = %q", msg.BodyHTML)
}
if len(msg.Attachments) != 1 {
t.Fatalf("attachments = %d", len(msg.Attachments))
}
if msg.Attachments[0].Filename != "note.txt" || string(msg.Attachments[0].Data) != "attachment" {
t.Fatalf("attachment = %#v", msg.Attachments[0])
}
}

69
internal/pop3/pop3.go Normal file
View File

@@ -0,0 +1,69 @@
package pop3
import (
"context"
"fmt"
gopop3 "github.com/knadh/go-pop3"
"internal-mail-skill/internal/config"
"internal-mail-skill/internal/model"
)
type Fetcher struct {
cfg config.POP3Config
}
func NewFetcher(cfg config.POP3Config) *Fetcher {
return &Fetcher{cfg: cfg}
}
func (f *Fetcher) Fetch(ctx context.Context) ([]model.FetchedMessage, error) {
if f.cfg.Host == "" {
return nil, fmt.Errorf("POP3_HOST is required")
}
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
client := gopop3.New(gopop3.Opt{
Host: f.cfg.Host,
Port: f.cfg.Port,
TLSEnabled: f.cfg.UseTLS,
DialTimeout: f.cfg.Timeout(),
})
conn, err := client.NewConn()
if err != nil {
return nil, fmt.Errorf("connect POP3: %w", err)
}
defer conn.Quit()
if err := conn.Auth(f.cfg.Username, f.cfg.Password); err != nil {
return nil, fmt.Errorf("POP3 auth: %w", err)
}
ids, err := conn.Uidl(0)
if err != nil {
return nil, fmt.Errorf("POP3 UIDL: %w", err)
}
out := make([]model.FetchedMessage, 0, len(ids))
for _, item := range ids {
select {
case <-ctx.Done():
return out, ctx.Err()
default:
}
raw, err := conn.RetrRaw(item.ID)
if err != nil {
out = append(out, model.FetchedMessage{ServerID: item.ID, UID: item.UID, Error: fmt.Sprintf("POP3 RETR %d: %v", item.ID, err)})
continue
}
out = append(out, model.FetchedMessage{
ServerID: item.ID,
UID: item.UID,
Raw: raw.Bytes(),
})
}
return out, nil
}

1127
internal/service/service.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,210 @@
package service
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"internal-mail-skill/internal/db"
"internal-mail-skill/internal/model"
mailsmtp "internal-mail-skill/internal/smtp"
)
func TestThreadAssemblyLinksReferences(t *testing.T) {
ctx := context.Background()
store, err := db.Open(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
if err := store.Migrate(ctx); err != nil {
t.Fatal(err)
}
svc := New(store, nil, nil, "default")
root, err := svc.SaveParsedEmail(ctx, SaveEmailInput{
POP3UID: "uid-root",
Parsed: model.ParsedEmail{
MessageIDHeader: "<root@example.com>",
Subject: "Root",
SenderEmail: "a@example.com",
},
})
if err != nil {
t.Fatal(err)
}
reply, err := svc.SaveParsedEmail(ctx, SaveEmailInput{
POP3UID: "uid-reply",
Parsed: model.ParsedEmail{
MessageIDHeader: "<reply@example.com>",
InReplyTo: "<root@example.com>",
References: "<root@example.com>",
Subject: "Re: Root",
SenderEmail: "b@example.com",
},
})
if err != nil {
t.Fatal(err)
}
thread, err := svc.GetThread(ctx, reply)
if err != nil {
t.Fatal(err)
}
if len(thread) != 2 || thread[0].ID != root || thread[1].ID != reply {
t.Fatalf("thread = %#v", thread)
}
}
func TestSearchMarkLabelsAndDraftLifecycle(t *testing.T) {
ctx := context.Background()
store, err := db.Open(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
if err := store.Migrate(ctx); err != nil {
t.Fatal(err)
}
svc := New(store, nil, nil, "default")
emailID, err := svc.SaveParsedEmail(ctx, SaveEmailInput{
POP3UID: "uid-1",
Parsed: model.ParsedEmail{
MessageIDHeader: "<m@example.com>",
Subject: "Quarterly Plan",
BodyText: "Please review",
SenderEmail: "boss@example.com",
},
})
if err != nil {
t.Fatal(err)
}
if err := svc.MarkEmail(ctx, emailID, MarkInput{Read: boolPtr(true), Processed: boolPtr(true)}); err != nil {
t.Fatal(err)
}
label, err := svc.CreateLabel(ctx, "work", "#336699")
if err != nil {
t.Fatal(err)
}
if err := svc.ApplyLabels(ctx, []int64{emailID}, []int64{label.ID}, "add"); err != nil {
t.Fatal(err)
}
results, err := svc.SearchEmails(ctx, SearchFilters{Q: "Quarterly", Read: boolPtr(true), Limit: 10})
if err != nil {
t.Fatal(err)
}
if len(results) != 1 || results[0].Labels[0].Name != "work" {
t.Fatalf("search results = %#v", results)
}
draft, err := svc.CreateDraft(ctx, DraftInput{To: []string{"x@example.com"}, Subject: "Draft", Body: "Hi"})
if err != nil {
t.Fatal(err)
}
updated, err := svc.UpdateDraft(ctx, draft.ID, DraftInput{To: []string{"x@example.com"}, Subject: "Updated", Body: "Hello"})
if err != nil {
t.Fatal(err)
}
if updated.Subject != "Updated" {
t.Fatalf("updated subject = %q", updated.Subject)
}
}
func TestSaveParsedEmailRollsBackWhenAttachmentStorageFails(t *testing.T) {
ctx := context.Background()
store, err := db.Open(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
if err := store.Migrate(ctx); err != nil {
t.Fatal(err)
}
dir := t.TempDir()
blocker := filepath.Join(dir, "attachments-blocker")
if err := os.WriteFile(blocker, []byte("not a directory"), 0600); err != nil {
t.Fatal(err)
}
svc := NewWithOptions(store, nil, nil, "default", Options{AttachmentDir: blocker})
_, err = svc.SaveParsedEmail(ctx, SaveEmailInput{
POP3UID: "uid-broken-attachment",
Parsed: model.ParsedEmail{
MessageIDHeader: "<broken@example.com>",
Subject: "Broken Attachment",
SenderEmail: "a@example.com",
Attachments: []model.ParsedAttachment{{
Filename: "note.txt",
ContentType: "text/plain",
SizeBytes: 4,
Data: []byte("nope"),
}},
},
})
if err == nil {
t.Fatal("SaveParsedEmail() error = nil, want attachment storage failure")
}
results, err := svc.SearchEmails(ctx, SearchFilters{Q: "Broken Attachment", Limit: 10})
if err != nil {
t.Fatal(err)
}
if len(results) != 0 {
t.Fatalf("failed save left searchable message: %#v", results)
}
}
func TestSendDraftIsIdempotentAfterSuccess(t *testing.T) {
ctx := context.Background()
store, err := db.Open(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
if err := store.Migrate(ctx); err != nil {
t.Fatal(err)
}
mailer := &fakeMailer{}
svc := NewWithOptions(store, nil, mailer, "default", Options{FromAddress: "sender@example.com"})
draft, err := svc.CreateDraft(ctx, DraftInput{To: []string{"x@example.com"}, Subject: "Once", Body: "hello"})
if err != nil {
t.Fatal(err)
}
first, err := svc.SendDraft(ctx, draft.ID)
if err != nil {
t.Fatal(err)
}
second, err := svc.SendDraft(ctx, draft.ID)
if err != nil {
t.Fatal(err)
}
if mailer.calls != 1 {
t.Fatalf("mailer calls = %d, want 1", mailer.calls)
}
if second.SentMessage.ID != first.SentMessage.ID {
t.Fatalf("second send message id = %d, want %d", second.SentMessage.ID, first.SentMessage.ID)
}
}
func boolPtr(v bool) *bool { return &v }
type fakeMailer struct {
calls int
err error
}
func (f *fakeMailer) Send(ctx context.Context, req mailsmtp.SendRequest) (mailsmtp.SendResult, error) {
f.calls++
if f.err != nil {
return mailsmtp.SendResult{}, f.err
}
if req.FromAddress == "" {
return mailsmtp.SendResult{}, errors.New("from required")
}
return mailsmtp.SendResult{MessageIDHeader: "<sent@example.com>", SentAt: time.Now().UTC()}, nil
}

View File

@@ -0,0 +1,90 @@
package smtp
import (
stdsmtp "net/smtp"
"strings"
"testing"
"internal-mail-skill/internal/config"
)
func TestBuildMessageOmitsBccHeaderButKeepsRecipients(t *testing.T) {
req := SendRequest{
FromAddress: "agent@example.com",
FromName: "Agent",
To: []string{"to@example.com"},
Cc: []string{"cc@example.com"},
Bcc: []string{"hidden@example.com"},
Subject: "Hello",
Body: "Body",
}
msg, recipients, err := BuildMessage(req)
if err != nil {
t.Fatalf("BuildMessage() error = %v", err)
}
raw := string(msg)
if strings.Contains(raw, "Bcc:") || strings.Contains(raw, "hidden@example.com") {
t.Fatalf("raw message leaked Bcc: %s", raw)
}
if len(recipients) != 3 {
t.Fatalf("recipients = %#v", recipients)
}
if recipients[2] != "hidden@example.com" {
t.Fatalf("Bcc missing from envelope recipients: %#v", recipients)
}
}
func TestAuthForConfigRequiresExplicitOptInForCleartextAuth(t *testing.T) {
cfg := config.SMTPConfig{
Host: "smtp.example.local",
Username: "user",
Password: "secret",
AuthRequired: true,
}
if _, err := authForConfig(cfg); err == nil {
t.Fatal("authForConfig() error = nil, want cleartext auth rejection")
}
cfg.AllowInsecureAuth = true
auth, err := authForConfig(cfg)
if err != nil {
t.Fatalf("authForConfig() with opt-in error = %v", err)
}
proto, _, err := auth.Start(&stdsmtp.ServerInfo{Name: cfg.Host, TLS: false})
if err != nil {
t.Fatalf("insecure auth Start() error = %v", err)
}
if proto != "PLAIN" {
t.Fatalf("proto = %q, want PLAIN", proto)
}
cfg.AllowInsecureAuth = false
cfg.UseSTARTTLS = true
auth, err = authForConfig(cfg)
if err != nil {
t.Fatalf("authForConfig() over STARTTLS error = %v", err)
}
if _, _, err := auth.Start(&stdsmtp.ServerInfo{Name: cfg.Host, TLS: true}); err != nil {
t.Fatalf("TLS auth Start() error = %v", err)
}
}
func TestAuthForConfigAllowsImplicitTLSAuth(t *testing.T) {
cfg := config.SMTPConfig{
Host: "smtp.example.local",
Username: "user",
Password: "secret",
AuthRequired: true,
UseTLS: true,
}
auth, err := authForConfig(cfg)
if err != nil {
t.Fatalf("authForConfig() error = %v", err)
}
if _, _, err := auth.Start(&stdsmtp.ServerInfo{Name: cfg.Host, TLS: false}); err != nil {
t.Fatalf("implicit TLS auth Start() error = %v", err)
}
}

252
internal/smtp/smtp.go Normal file
View File

@@ -0,0 +1,252 @@
package smtp
import (
"bytes"
"context"
"crypto/rand"
"crypto/tls"
"encoding/hex"
"fmt"
"io"
"mime"
"net"
"net/mail"
stdsmtp "net/smtp"
"os"
"strings"
"time"
"internal-mail-skill/internal/config"
)
type SendRequest struct {
FromAddress string
FromName string
To []string
Cc []string
Bcc []string
Subject string
Body string
InReplyTo string
References string
MessageIDHeader string
}
type SendResult struct {
MessageIDHeader string
Raw []byte
Recipients []string
SentAt time.Time
}
type Sender struct {
cfg config.SMTPConfig
}
func NewSender(cfg config.SMTPConfig) *Sender {
return &Sender{cfg: cfg}
}
func (s *Sender) Send(ctx context.Context, req SendRequest) (SendResult, error) {
if req.FromAddress == "" {
req.FromAddress = s.cfg.FromAddress
}
if req.FromName == "" {
req.FromName = s.cfg.FromName
}
raw, recipients, err := BuildMessage(req)
if err != nil {
return SendResult{}, err
}
if len(recipients) == 0 {
return SendResult{}, fmt.Errorf("no recipients")
}
if s.cfg.Host == "" {
return SendResult{}, fmt.Errorf("SMTP_HOST is required")
}
auth, err := authForConfig(s.cfg)
if err != nil {
return SendResult{}, err
}
if err := s.sendRaw(ctx, req.FromAddress, recipients, raw, auth); err != nil {
return SendResult{}, err
}
return SendResult{
MessageIDHeader: extractHeader(raw, "Message-ID"),
Raw: raw,
Recipients: recipients,
SentAt: time.Now().UTC(),
}, nil
}
func BuildMessage(req SendRequest) ([]byte, []string, error) {
if req.FromAddress == "" {
return nil, nil, fmt.Errorf("from address is required")
}
recipients := append([]string{}, req.To...)
recipients = append(recipients, req.Cc...)
recipients = append(recipients, req.Bcc...)
if len(recipients) == 0 {
return nil, nil, fmt.Errorf("at least one recipient is required")
}
messageID := req.MessageIDHeader
if messageID == "" {
messageID = newMessageID()
}
var b bytes.Buffer
writeHeader(&b, "From", (&mail.Address{Name: req.FromName, Address: req.FromAddress}).String())
writeHeader(&b, "To", strings.Join(req.To, ", "))
if len(req.Cc) > 0 {
writeHeader(&b, "Cc", strings.Join(req.Cc, ", "))
}
writeHeader(&b, "Subject", mime.QEncoding.Encode("utf-8", req.Subject))
writeHeader(&b, "Date", time.Now().Format(time.RFC1123Z))
writeHeader(&b, "Message-ID", messageID)
if req.InReplyTo != "" {
writeHeader(&b, "In-Reply-To", req.InReplyTo)
}
if req.References != "" {
writeHeader(&b, "References", req.References)
}
writeHeader(&b, "MIME-Version", "1.0")
writeHeader(&b, "Content-Type", `text/plain; charset="utf-8"`)
writeHeader(&b, "Content-Transfer-Encoding", "8bit")
b.WriteString("\r\n")
body := strings.ReplaceAll(req.Body, "\n", "\r\n")
body = strings.ReplaceAll(body, "\r\r\n", "\r\n")
b.WriteString(body)
if !strings.HasSuffix(body, "\r\n") {
b.WriteString("\r\n")
}
return b.Bytes(), recipients, nil
}
func (s *Sender) sendRaw(ctx context.Context, from string, recipients []string, raw []byte, auth stdsmtp.Auth) error {
timeout := s.cfg.Timeout()
if timeout <= 0 {
timeout = 30 * time.Second
}
address := net.JoinHostPort(s.cfg.Host, fmt.Sprintf("%d", s.cfg.Port))
dialer := &net.Dialer{Timeout: timeout}
var conn net.Conn
var err error
if s.cfg.UseTLS {
conn, err = tls.DialWithDialer(dialer, "tcp", address, &tls.Config{ServerName: s.cfg.Host, MinVersion: tls.VersionTLS12})
} else {
conn, err = dialer.DialContext(ctx, "tcp", address)
}
if err != nil {
return fmt.Errorf("connect SMTP: %w", err)
}
defer conn.Close()
client, err := stdsmtp.NewClient(conn, s.cfg.Host)
if err != nil {
return fmt.Errorf("create SMTP client: %w", err)
}
defer client.Close()
if s.cfg.UseSTARTTLS {
if err := client.StartTLS(&tls.Config{ServerName: s.cfg.Host, MinVersion: tls.VersionTLS12}); err != nil {
return fmt.Errorf("starttls: %w", err)
}
}
if auth != nil {
if err := client.Auth(auth); err != nil {
return fmt.Errorf("smtp auth: %w", err)
}
}
if err := client.Mail(from); err != nil {
return fmt.Errorf("smtp MAIL FROM: %w", err)
}
for _, recipient := range recipients {
if strings.TrimSpace(recipient) == "" {
continue
}
if err := client.Rcpt(recipient); err != nil {
return fmt.Errorf("smtp RCPT TO %s: %w", recipient, err)
}
}
writer, err := client.Data()
if err != nil {
return fmt.Errorf("smtp DATA: %w", err)
}
if _, err := io.Copy(writer, bytes.NewReader(raw)); err != nil {
_ = writer.Close()
return fmt.Errorf("write SMTP body: %w", err)
}
if err := writer.Close(); err != nil {
return fmt.Errorf("close SMTP body: %w", err)
}
if err := client.Quit(); err != nil {
return fmt.Errorf("smtp quit: %w", err)
}
return nil
}
func authForConfig(cfg config.SMTPConfig) (stdsmtp.Auth, error) {
if !cfg.AuthRequired {
return nil, nil
}
if !cfg.UseTLS && !cfg.UseSTARTTLS {
if cfg.AllowInsecureAuth {
return plainAuthNoTLSCheck{username: cfg.Username, password: cfg.Password}, nil
}
if cfg.Host != "localhost" && cfg.Host != "127.0.0.1" && cfg.Host != "::1" {
return nil, fmt.Errorf("SMTP auth over cleartext requires SMTP_USE_TLS=true, SMTP_USE_STARTTLS=true, or SMTP_ALLOW_INSECURE_AUTH=true")
}
}
if cfg.UseTLS {
return plainAuthNoTLSCheck{username: cfg.Username, password: cfg.Password}, nil
}
return stdsmtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host), nil
}
type plainAuthNoTLSCheck struct {
username string
password string
}
func (a plainAuthNoTLSCheck) Start(server *stdsmtp.ServerInfo) (string, []byte, error) {
resp := []byte("\x00" + a.username + "\x00" + a.password)
return "PLAIN", resp, nil
}
func (a plainAuthNoTLSCheck) Next(fromServer []byte, more bool) ([]byte, error) {
if more {
return nil, fmt.Errorf("unexpected server challenge for PLAIN auth")
}
return nil, nil
}
func writeHeader(b *bytes.Buffer, key, value string) {
if strings.TrimSpace(value) == "" {
return
}
b.WriteString(key)
b.WriteString(": ")
b.WriteString(value)
b.WriteString("\r\n")
}
func newMessageID() string {
host, err := os.Hostname()
if err != nil || host == "" {
host = "localhost"
}
var random [8]byte
if _, err := rand.Read(random[:]); err != nil {
return fmt.Sprintf("<%d@%s>", time.Now().UnixNano(), host)
}
return fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), hex.EncodeToString(random[:]), host)
}
func extractHeader(raw []byte, name string) string {
prefix := strings.ToLower(name) + ":"
for _, line := range strings.Split(string(raw), "\r\n") {
if strings.HasPrefix(strings.ToLower(line), prefix) {
return strings.TrimSpace(line[len(name)+1:])
}
}
return ""
}