Files
MailUI4Agents/gateway/internal/repo/attachments.go
JianFeeeee 0e754617a4 feat: AgentMail —— 以邮件为统一范式的多智能体协作平台
Go 单二进制网关 + React 前端 + opencode 桥接插件。部署产物是
「一个二进制加一个 .db 文件」:前端经 go:embed 打进二进制,
数据库默认内置 SQLite,systemd 托管。

核心设计
- 三维寻址 name@path.session,按最后一个 . 切分;session 位三态:
  省略=默认会话 / new=强制新建 / 具体别名=必须已存在(否则 404 无法送达)
- 会话别名默认复用 Agent 平台自己的命名机制(opencode 的 slug 与模型生成的
  标题),不在本侧另造一套;人显式定过的别名不被平台同步覆盖
- 对话树不建 tree_nodes 表:parent_mail_id 已完整编码树结构,
  再维护一张表就是第二份真相。用递归 CTE 查,按方向分块加载
- 附件内容存磁盘、按 sha256 内容寻址,数据库只存元数据;天然去重,
  且路径与用户 filename 无关,杜绝 ../ 穿越
- 配额约束的是模型的自主发信,不是 harness 的转发:插件代劳的权限询问与
  最终总结走免配额通道,靠上游消息 id 做幂等键而非计数
- 往返预算下沉到会话(写信时给、对话页里改)+ Agent 全局配额,两层都要过

后端 gateway/
- models/repo/handler/middleware/sse/blob 分层;两方言(SQLite/PostgreSQL)
  共用一份 repo 层 SQL,差异集中在 internal/db
- 多用户认证(bcrypt cost12、登录限速、会话隔离、权限边界)
- 密钥体系:Agent 密钥与用户密钥分表,三种生命周期;登记式密钥让全文
  只从客户端流向服务器一次
- 所有「判断 + 自增」都在同一条 UPDATE 里(配额、预算、one_time 密钥、
  附件挂载),并发下不会刷穿

前端 web/
- 三栏布局、三段式地址补全、权限卡片、密钥面板、配额面板、对话树、附件
- 全站纯 SVG 图标,不使用 emoji
- api/ 即可复用的客户端 SDK:基地址与凭证集中在 api/config.ts

插件 plugins/opencode-mail-bridge/
- 六个工具 + 两类自动转发(permission.ask 钩子接管平台原生权限询问、
  session.idle 时转发本轮总结)
2026-09-02 10:29:26 +08:00

227 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package repo
import (
"context"
"database/sql"
"errors"
"time"
"github.com/agentmail/gateway/internal/db"
"github.com/agentmail/gateway/internal/models"
"github.com/google/uuid"
)
// ---------- 附件 ----------
//
// 元数据在库、内容在磁盘internal/blob。两者的一致性由调用顺序保证
// 先落盘再入库 —— 反过来会出现「库里有记录但文件不存在」的下载 500。
// 落盘成功但入库失败时最多留下一个无引用的文件,由 GC 回收,不影响正确性。
var (
// ErrAttachmentNotFound 附件不存在
ErrAttachmentNotFound = errors.New("attachment not found")
// ErrAttachmentNotOwned 附件不属于该上传者
ErrAttachmentNotOwned = errors.New("attachment not owned by uploader")
// ErrAttachmentAlreadyAttached 附件已挂到别的邮件上
ErrAttachmentAlreadyAttached = errors.New("attachment already attached")
)
const attachmentCols = `attachment_id, mail_id, uploader, filename, content_type, size_bytes, sha256, created_at`
func scanAttachment(sc interface{ Scan(...any) error }) (*models.Attachment, error) {
var a models.Attachment
if err := sc.Scan(&a.ID, &a.MailID, &a.Uploader, &a.Filename,
&a.ContentType, &a.SizeBytes, &a.SHA256, &a.CreatedAt); err != nil {
return nil, err
}
return &a, nil
}
// CreateAttachment 登记一条待挂载的附件mail_id 为空)。
func CreateAttachment(ctx context.Context, uploader, filename, contentType string, size int64, sum string) (*models.Attachment, error) {
a := &models.Attachment{
Uploader: uploader,
Filename: filename,
ContentType: contentType,
SizeBytes: size,
SHA256: sum,
}
err := db.DB.QueryRowContext(ctx, `
INSERT INTO attachments (uploader, filename, content_type, size_bytes, sha256)
VALUES ($1, $2, $3, $4, $5)
RETURNING attachment_id, created_at
`, uploader, filename, contentType, size, sum).Scan(&a.ID, &a.CreatedAt)
if err != nil {
return nil, err
}
return a, nil
}
// GetAttachment 读取一条附件元数据。
func GetAttachment(ctx context.Context, id uuid.UUID) (*models.Attachment, error) {
a, err := scanAttachment(db.DB.QueryRowContext(ctx,
`SELECT `+attachmentCols+` FROM attachments WHERE attachment_id = $1`, id))
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAttachmentNotFound
}
return a, err
}
// ListAttachmentsFor 列出某封邮件的附件。
func ListAttachmentsFor(ctx context.Context, mailID uuid.UUID) ([]models.Attachment, error) {
rows, err := db.DB.QueryContext(ctx,
`SELECT `+attachmentCols+` FROM attachments WHERE mail_id = $1 ORDER BY created_at`, mailID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []models.Attachment{}
for rows.Next() {
a, err := scanAttachment(rows)
if err != nil {
return nil, err
}
out = append(out, *a)
}
return out, rows.Err()
}
// AttachToMail 把一批待挂载附件绑到某封邮件上。
//
// 每条都要求:存在、属于该上传者、且尚未挂载。
// 用 WHERE mail_id IS NULL AND uploader = ? 一条 UPDATE 完成判断与写入,
// 避免「先查后改」在并发下把同一个附件挂到两封邮件上。
func AttachToMail(ctx context.Context, mailID uuid.UUID, ids []uuid.UUID, uploader string) error {
for _, id := range ids {
tag, err := db.DB.ExecContext(ctx, `
UPDATE attachments SET mail_id = $1
WHERE attachment_id = $2 AND uploader = $3 AND mail_id IS NULL
`, mailID, id, uploader)
if err != nil {
return err
}
if n, _ := tag.RowsAffected(); n > 0 {
continue
}
// 没改到:查明原因,给调用方一个能照着修的错误
a, gErr := GetAttachment(ctx, id)
if gErr != nil {
return gErr
}
if a.Uploader != uploader {
return ErrAttachmentNotOwned
}
return ErrAttachmentAlreadyAttached
}
return nil
}
// CopyAttachmentsTo 把源邮件的附件复制到目标邮件(转发时用)。
//
// 内容寻址下「复制」只是新增一条指向同一 sha256 的元数据,不拷磁盘文件。
// uploader 记为转发人:附件随新邮件重新分发,其可见范围由新邮件的参与方决定,
// 而不是沿用原上传者。返回复制的数量。
func CopyAttachmentsTo(ctx context.Context, srcMailID, dstMailID uuid.UUID, forwarder string) (int, error) {
src, err := ListAttachmentsFor(ctx, srcMailID)
if err != nil {
return 0, err
}
for _, a := range src {
_, err := db.DB.ExecContext(ctx, `
INSERT INTO attachments (mail_id, uploader, filename, content_type, size_bytes, sha256)
VALUES ($1, $2, $3, $4, $5, $6)
`, dstMailID, forwarder, a.Filename, a.ContentType, a.SizeBytes, a.SHA256)
if err != nil {
return 0, err
}
}
return len(src), nil
}
// DeleteAttachment 删除一条附件元数据,返回它的 sha256 以及该内容是否已无人引用。
// 内容寻址下多条记录可能共享同一个文件,只有最后一条引用消失才能删磁盘文件。
func DeleteAttachment(ctx context.Context, id uuid.UUID) (sum string, orphaned bool, err error) {
a, err := GetAttachment(ctx, id)
if err != nil {
return "", false, err
}
if _, err = db.DB.ExecContext(ctx,
`DELETE FROM attachments WHERE attachment_id = $1`, id); err != nil {
return "", false, err
}
var refs int
if err = db.DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM attachments WHERE sha256 = $1`, a.SHA256).Scan(&refs); err != nil {
return "", false, err
}
return a.SHA256, refs == 0, nil
}
// SweepOrphanAttachments 清理超过 age 仍未挂载到邮件的附件记录,
// 返回可以从磁盘删除的 sha256 列表(已确认无任何记录引用)。
//
// 上传后没走完发信流程用户取消、Agent 崩溃)会留下这类记录,
// 不清理的话磁盘只会单调增长。
func SweepOrphanAttachments(ctx context.Context, age time.Duration) ([]string, error) {
cutoff := time.Now().Add(-age)
rows, err := db.DB.QueryContext(ctx,
`SELECT attachment_id, sha256 FROM attachments
WHERE mail_id IS NULL AND created_at < $1`, cutoff)
if err != nil {
return nil, err
}
type orphan struct {
id uuid.UUID
sum string
}
var found []orphan
for rows.Next() {
var o orphan
if err := rows.Scan(&o.id, &o.sum); err != nil {
rows.Close()
return nil, err
}
found = append(found, o)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
var removable []string
for _, o := range found {
if _, err := db.DB.ExecContext(ctx,
`DELETE FROM attachments WHERE attachment_id = $1`, o.id); err != nil {
return nil, err
}
var refs int
if err := db.DB.QueryRowContext(ctx,
`SELECT COUNT(*) FROM attachments WHERE sha256 = $1`, o.sum).Scan(&refs); err != nil {
return nil, err
}
if refs == 0 {
removable = append(removable, o.sum)
}
}
return removable, nil
}
// AttachmentAccessible 判断某人是否有权读取某附件:
// 已挂载的看邮件所属会话的参与关系,未挂载的只有上传者本人能看。
func AttachmentAccessible(ctx context.Context, a *models.Attachment, name string) (bool, error) {
if a.MailID == nil {
return a.Uploader == name, nil
}
var n int
err := db.DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM mails m
WHERE m.mail_id = $1
AND (m.from_name = $2 OR m.to_name = $2 OR `+db.CCHas("m.cc_list", 2)+`)
`, *a.MailID, name).Scan(&n)
return n > 0, err
}