Files
MailUI4Agents/plugins/homeagent-mail-bridge/plugin.go
JianFeeeee f321380fa3 fix: relay 死循环防护 + DSH 工作区注册修复 + homeagent 工具集补齐 + 三插件 connect_to_server
## relay 死循环防护(两道防线)

### 主防线:免配额只给发往人类的 relay(handler/mail.go)

原设计:relay 走免配额通道(harness 搬运不该算模型自主发信)。
问题:收件方是另一个同样会自动转发的 Agent 时,整个回路里没有任何
一处在计数——生产上跑出过 41 封(会话 f3d824ce),间隔从 15 分钟
缩到 5 秒,且用了 37 封才烧掉 4/20 预算。

改为:repo.IsHumanUser(to.Name) 判定。Agent→Agent 的 relay 照样扣预算。

顺带修次序问题:原来是「先占幂等键再扣预算」,预算耗尽时幂等键
已被占用,加了额度也无法重发。现在预算失败会 ReleaseRelay 还回去。

### 兜底:hop_limit 列接通(repo/relayhops.go)

schema 里早有 hop_limit INT DEFAULT 5,从未有代码读它。
CountTrailingRelayHops 从最新邮件往前扫,遇到第一封非 relay
邮件即停(中间有一封自主发信或人类插话就归零)。

5 测试:空会话 / 只数 relay / 自主发信打断归零 / 达到上限 / 按会话独立

## DSH 工作区注册修复

问题:上一轮加的 workspaceRegistry.create(cwd) 用了兜底值 cwd(来自
resolveWorkspaceCwd,可能是 ~/.dsh/mail-sessions/mail-<uuid>),
而不是会话 header 里的真实 cwd。两者不一致时 attachSession 拒绝,
且 create 已先执行,每封邮件都往注册表里塞一条空的垃圾 workspace。

修复:读 handle.agent.session.header.cwd —— create 路径下是 meta.cwd,
resume 路径下是持久化 header 里那个。

## homeagent 插件:11 工具齐平 opencode

tools.go 新增:read_mail / forward_mail / suggest_address /
list_contacts / session_participants / read_thread / connect_to_server
+ handleConnectToServer(注册到 Gateway 前先用候选坐标试注册,
成功才写回 p.gwURL/p.key,失败不破坏原配置)

关键修:Plugin.name(插件名,homed 注册用)与 Plugin.agentName
(AgentMail 身份,Gateway 密钥绑定用)是两个命名空间。
它们混淆会导致 403:「该密钥已绑定到 Agent 'homeagent',不能用于
注册 'homeagent-mail-bridge'」。现已分开,并在 systemd drop-in
里显式设 AGENTMAIL_AGENT_NAME=homeagent。

## 三插件补齐 connect_to_server

之前只有 opencode 有。后果:Gateway 换地址或密钥需要重新登记时,
opencode 里的模型能自己修好,其他平台只能干等环境变量被人改。

DSH 版:从 GatewayClient 内部调 register(),成功后写回 client.baseURL
与 client.agentKey 当场生效。

pi 版:新导出 KEY_FILE / saveLocalKey(从 gateway.mjs),connect
工具直接用。

# 测试

relayhops_test.go 5 例
opencode 172 / dsh 188 / pi 214 全绿
check-shared-libs.sh 三方同源(rename-proposal 已纳入校验)
2026-09-03 15:01:52 +08:00

691 lines
21 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 main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"sync"
"time"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
const (
defaultGateway = "http://127.0.0.1:8180"
heartbeatIntval = 25 * time.Second
sseRetry = 3 * time.Second
mailPollIntval = 15 * time.Second
)
// Plugin 实现 sdk.Plugin。
//
// TrueAgent 是单一常驻 agent 事件循环,没有「每个对话一个 session」的概念。
// 因此本插件不做会话映射 —— 所有邮件注入同一个事件循环,像 QQ 插件一样。
// 邮件的 context 完全靠中断消息的文本传递,不靠 platform_sessions 上报。
type Plugin struct {
// name 是**插件名**homed 注册用,如 homeagent-mail-bridge
name string
// agentName 是**AgentMail 身份**(如 homeagent
//
// 两者必须分开:密钥绑定的是 AgentMail 身份,拿插件名去注册会被拒
// 403 该密钥已绑定到 Agent "homeagent",不能用于注册 "homeagent-mail-bridge"
// 这不是 Gateway 太严格 —— 名字与人类用户名共用命名空间,
// 让一把密钥能注册任意名字等于让它能冒充任何人。
agentName string
sdk *sdk.PluginSDK
gwURL string
key string
client *http.Client
stopCh chan struct{}
stopOnce sync.Once
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
gw := ""
key := ""
if v, ok := config["gateway_url"].(string); ok {
gw = v
}
if v, ok := config["gateway_key"].(string); ok {
key = v
}
if gw == "" {
gw = os.Getenv("AGENTMAIL_GATEWAY_URL")
}
if gw == "" {
gw = defaultGateway
}
if key == "" {
key = os.Getenv("AGENTMAIL_AGENT_KEY")
}
// AgentMail 身份config > 环境变量 > 从插件名去掉 -mail-bridge 后缀。
// 兜底那条让默认配置能直接跑通homeagent-mail-bridge → homeagent
// 但显式配置永远优先 —— 插件名是部署细节,不该决定对外身份。
agentName := ""
if v, ok := config["agent_name"].(string); ok {
agentName = strings.TrimSpace(v)
}
if agentName == "" {
agentName = strings.TrimSpace(os.Getenv("AGENTMAIL_AGENT_NAME"))
}
if agentName == "" {
agentName = strings.TrimSuffix(name, "-mail-bridge")
}
return &Plugin{
name: name,
agentName: agentName,
gwURL: strings.TrimRight(gw, "/"),
key: key,
client: &http.Client{Timeout: 30 * time.Second},
stopCh: make(chan struct{}),
}, nil
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
s.SetAutoRestart(true)
// 注册读取工具
s.RegisterTool("read_inbox", sdk.ToolDef{
Name: "read_inbox",
Description: "查阅收件箱中的邮件。收到新邮件通知后应立即调用此工具。每封含 mail_id、发件人、主题、正文与附件清单。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"status": map[string]interface{}{
"type": "string",
"description": "过滤条件 unread|all默认 unread",
},
"limit": map[string]interface{}{
"type": "number",
"description": "返回数量,默认 5",
},
},
},
}, p.handleReadInbox)
// 注册发信工具
s.RegisterTool("send_mail", sdk.ToolDef{
Name: "send_mail",
Description: "发送邮件。三维地址 name@path.session。回复来信请传 reply_to。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"to": map[string]interface{}{"type": "string", "description": "收件人三维地址"},
"subject": map[string]interface{}{"type": "string", "description": "邮件主题"},
"body": map[string]interface{}{"type": "string", "description": "邮件正文Markdown"},
"cc": map[string]interface{}{"type": "string", "description": "抄送"},
"reply_to": map[string]interface{}{"type": "string", "description": "回复某封邮件时传其 mail_id"},
},
"required": []string{"to", "subject", "body"},
},
}, p.handleSendMail)
// 读一封的完整内容(收件箱只给摘要;要回给抄收方就得先看清发给了谁)
s.RegisterTool("read_mail", sdk.ToolDef{
Name: "read_mail",
Description: "读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。",
Parameters: oneStringParam("mail_id", "邮件 ID", true),
}, p.handleReadMail)
// 转发 —— 引用原文与附件,按目标地址另行定位会话(它是一条新线索)
s.RegisterTool("forward_mail", sdk.ToolDef{
Name: "forward_mail",
Description: "转发一封邮件给新的收件人(自动引用原文与附件)。与回复不同:回复落回原会话,转发按目标地址另行定位会话。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"mail_id": map[string]interface{}{"type": "string", "description": "要转发的邮件 ID"},
"to": map[string]interface{}{"type": "string", "description": "新收件人的三维地址(先用 suggest_address 确认)"},
"comment": map[string]interface{}{"type": "string", "description": "转发说明,置于引用原文之前"},
"cc": map[string]interface{}{"type": "string", "description": "抄送,逗号分隔多个三维地址"},
"subject": map[string]interface{}{"type": "string", "description": "自定义主题;留空则自动加 Fwd: 前缀"},
"session_alias": map[string]interface{}{"type": "string", "description": "仅当目标地址以 .new 结尾时生效:给新会话命名"},
},
"required": []string{"mail_id", "to"},
},
}, p.handleForwardMail)
// ─── 寻址发现 ───
//
// 没有这一组时send_mail 的 to 是个只能靠记忆拼写的自由文本字段,
// 而拼错不报错:生产上另一个平台猜了 `opencode@/home`,投递成功,
// 但那不是它的工作目录,错误路径静默变成了新会话的 workspace。
s.RegisterTool("suggest_address", sdk.ToolDef{
Name: "suggest_address",
Description: "查询可用的收件人地址。不带参数给候选收件人名;带 name 给它可用的工作目录;" +
"name+path 都带则给该目录下可续谈的会话与现成地址。**发信前应先用它确认地址**,不要凭记忆拼写。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"name": map[string]interface{}{"type": "string", "description": "收件人名;留空则列出所有候选收件人"},
"path": map[string]interface{}{"type": "string", "description": "工作目录;与 name 同时给出才列会话"},
},
},
}, p.handleSuggestAddress)
s.RegisterTool("list_contacts", sdk.ToolDef{
Name: "list_contacts",
Description: "列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。用于回答「我还有什么没处理」。",
Parameters: oneStringParam("limit", "最多列出多少条,默认 20", false),
}, p.handleListContacts)
s.RegisterTool("session_participants", sdk.ToolDef{
Name: "session_participants",
Description: "列出某条会话的全部参与方(发件人/收件人/抄送方)及各自的可投递地址,并标出谁还没回应。" +
"**要回给抄收方或向第三方转达时先用它拿地址**。",
Parameters: oneStringParam("session_id", "会话 ID", true),
}, p.handleSessionParticipants)
s.RegisterTool("read_thread", sdk.ToolDef{
Name: "read_thread",
Description: "查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。多方抄送协作时用它确认" +
"别人已经说了什么,避免重复提问或重复汇报。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"mail_id": map[string]interface{}{"type": "string", "description": "线索中任一封邮件的 ID"},
"offset": map[string]interface{}{"type": "number", "description": "分页偏移,续取时传上次返回的 next_offset"},
},
"required": []string{"mail_id"},
},
}, p.handleReadThread)
// connect_to_server —— 连接自愈。
//
// Gateway 换了地址、或密钥需要重新登记时,模型能自己修好而不必等人改
// 环境变量。失败时把需要登记的密钥全文打出来,省掉一轮来回。
s.RegisterTool("connect_to_server", sdk.ToolDef{
Name: "connect_to_server",
Description: "连接到 AgentMail Gateway登记本机密钥并完成注册。首次安装或换了 Gateway 地址时调用。" +
"密钥若未在后台登记过,此处会返回需要登记的密钥全文。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"gateway_url": map[string]interface{}{"type": "string", "description": "Gateway 地址;省略则用当前配置"},
"key_token": map[string]interface{}{"type": "string", "description": "管理员签发的 Agent 密钥;省略则用当前密钥"},
},
},
}, p.handleConnectToServer)
// 注册输出通道 —— agent 可以主动调 output_send__homeagent 发信
s.RegisterOutputChannel("homeagent", sdk.CapText|sdk.CapFile,
"发送邮件。meta JSON 格式:{to, subject, reply_to}type: text",
sdk.ChannelDef{}, p.handleOutputChannel)
log.Printf("[homeagent-mail-bridge] 注册完成,等待 Gateway SSE")
// 启动心跳 + SSE后台 goroutine
go p.heartbeatLoop()
go p.sseLoop()
return nil
}
func (p *Plugin) Stop() error {
p.stopOnce.Do(func() { close(p.stopCh) })
return nil
}
// ─── 心跳 ───
func (p *Plugin) heartbeatLoop() {
// 首次注册
if err := p.register(); err != nil {
log.Printf("[homeagent-mail-bridge] 注册失败: %v", err)
}
ticker := time.NewTicker(heartbeatIntval)
defer ticker.Stop()
for {
select {
case <-p.stopCh:
return
case <-ticker.C:
if err := p.heartbeat(); err != nil {
log.Printf("[homeagent-mail-bridge] 心跳失败: %v", err)
}
}
}
}
func (p *Plugin) register() error {
body := map[string]interface{}{
"name": p.agentName,
"platform": "homeagent",
"workspaces": []interface{}{},
}
return p.post("/agent/register", body, nil)
}
func (p *Plugin) heartbeat() error {
return p.post("/agent/heartbeat", map[string]interface{}{}, nil)
}
// ─── SSE ───
func (p *Plugin) sseLoop() {
for {
select {
case <-p.stopCh:
return
default:
}
if err := p.readSSE(); err != nil {
log.Printf("[homeagent-mail-bridge] SSE 断开: %v%v 后重连", err, sseRetry)
time.Sleep(sseRetry)
}
}
}
func (p *Plugin) readSSE() error {
req, err := http.NewRequest("GET", p.gwURL+"/api/v1/events/stream", nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+p.key)
resp, err := p.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("SSE HTTP %d", resp.StatusCode)
}
log.Printf("[homeagent-mail-bridge] SSE 已连接")
buf := make([]byte, 0, 4096)
lineStart := 0
for {
select {
case <-p.stopCh:
return nil
default:
}
n, err := resp.Body.Read(buf[len(buf):cap(buf)])
if n > 0 {
buf = buf[:len(buf)+n]
// 处理完整行
for {
i := bytes.IndexByte(buf[lineStart:], '\n')
if i < 0 {
break
}
line := string(buf[lineStart : lineStart+i])
lineStart += i + 1
p.parseSSELine(line)
}
if lineStart > 0 {
buf = buf[lineStart:]
lineStart = 0
}
buf = buf[:len(buf)]
}
if err != nil {
if err != io.EOF {
return err
}
return nil
}
}
}
func (p *Plugin) parseSSELine(line string) {
// SSE 格式event: xxx\ndata: {...}\n\n
if !strings.HasPrefix(line, "data: ") {
return
}
raw := strings.TrimPrefix(line, "data: ")
if raw == "" || raw == "{}" {
return
}
var evt struct {
MailID string `json:"mail_id"`
SessionID string `json:"session_id"`
FromName string `json:"from_name"`
Subject string `json:"subject"`
MailType string `json:"mail_type"`
Role string `json:"role"`
Workspace string `json:"to_workspace"`
Alias string `json:"session_alias"`
ReplyAddr string `json:"reply_address"`
}
if err := json.Unmarshal([]byte(raw), &evt); err != nil {
return
}
if evt.MailID == "" {
return
}
// 权限决策回复MUST
if evt.MailType == "permission_decision" {
p.handlePermissionDecision(evt)
return
}
// 新邮件:注入 agent 事件循环
if evt.MailType == "normal" {
p.handleNewMail(evt)
}
}
// ─── 输出通道agent 主动发信)───
func (p *Plugin) handleOutputChannel(args map[string]interface{}) (interface{}, error) {
payload, _ := args["payload"].(string)
meta, _ := args["meta"].(string)
if payload == "" {
return nil, fmt.Errorf("payload 不能为空")
}
var m struct {
To string `json:"to"`
Subject string `json:"subject"`
ReplyTo string `json:"reply_to"`
}
if meta != "" {
json.Unmarshal([]byte(meta), &m)
}
if m.To == "" {
return nil, fmt.Errorf("meta 中需要 to 字段")
}
if err := p.sendMail(m.To, m.Subject, payload, m.ReplyTo); err != nil {
return nil, err
}
return map[string]interface{}{"status": "sent"}, nil
}
func (p *Plugin) sendMail(to, subject, body, replyTo string) error {
payload := map[string]interface{}{
"to": to,
"subject": subject,
"body": body,
}
if replyTo != "" {
payload["reply_to"] = replyTo
}
return p.post("/mail/send", payload, nil)
}
// ─── 新邮件处理 ───
func (p *Plugin) handleNewMail(evt struct {
MailID string `json:"mail_id"`
SessionID string `json:"session_id"`
FromName string `json:"from_name"`
Subject string `json:"subject"`
MailType string `json:"mail_type"`
Role string `json:"role"`
Workspace string `json:"to_workspace"`
Alias string `json:"session_alias"`
ReplyAddr string `json:"reply_address"`
}) {
// 记录回信目标 —— StageBeforeOutput 会检查是否由邮件触发,
// StageAfterOutput 用这个地址把最终回复发回去。
// 构建中断消息
prompt := fmt.Sprintf(
"你收到一封新邮件AgentMail。\n\n"+
"发件人:%s\n"+
"主题:%s\n"+
"邮件 ID%s\n"+
"身份:你是 %s\n\n"+
"请先调用 read_inbox 读取完整正文,然后处理其中的请求。\n\n"+
"**回信不用你自己发**:你把本轮工作做完、把结论说出来就行,\n"+
"插件会在这一轮结束时自动把你最后那段话作为回信发回给 %s不消耗你的发信配额。\n"+
"只有在需要主动联系其他人、或要带附件时才调用 send_mail。",
evt.FromName, evt.Subject, evt.MailID, p.agentName, evt.FromName,
)
// 非阻塞注入 —— TrueAgent 的事件循环会处理
// InjectInputSync 阻塞等待 agent 处理完毕,返回最终回复文本。
// 这比 stage handler 更可靠TrueAgent 的事件循环保证不会重入,
// 而 stage handler 的触发时机依赖 ToolCalls 的状态快照,实际测试中
// before_output 看不到 read_inbox 调用(它在 turn 中间就被清掉了)。
reply := p.sdk.InjectInputSync(p.name, p.name, prompt)
if reply == "" {
log.Printf("[homeagent-mail-bridge] 已注入新邮件 %s来自 %s%sagent 无回复", evt.MailID[:8], evt.FromName, evt.Subject)
return
}
// 自动把 agent 的回复发回给发件人
if err := p.sendMail(evt.FromName, "Re: "+evt.Subject, reply, evt.MailID); err != nil {
log.Printf("[homeagent-mail-bridge] 自动回信失败: %v", err)
} else {
log.Printf("[homeagent-mail-bridge] 已自动回信给 %s%d 字)", evt.FromName, len(reply))
}
}
// ─── 权限决策 ───
func (p *Plugin) handlePermissionDecision(evt struct {
MailID string `json:"mail_id"`
SessionID string `json:"session_id"`
FromName string `json:"from_name"`
Subject string `json:"subject"`
MailType string `json:"mail_type"`
Role string `json:"role"`
Workspace string `json:"to_workspace"`
Alias string `json:"session_alias"`
ReplyAddr string `json:"reply_address"`
}) {
prompt := fmt.Sprintf(
"你之前发起的权限请求已有结论:%s决策人%s。请据此继续。",
evt.Subject, evt.FromName,
)
p.sdk.InjectText(p.name, p.name, prompt)
}
// ─── 工具实现 ───
func (p *Plugin) handleReadInbox(args map[string]interface{}) (interface{}, error) {
status := "unread"
if v, ok := args["status"].(string); ok && v != "" {
status = v
}
limit := 5
if v, ok := args["limit"].(float64); ok && v > 0 {
limit = int(v)
}
url := fmt.Sprintf("%s/api/v1/mail/inbox?status=%s&limit=%d", p.gwURL, status, limit)
var result map[string]interface{}
if err := p.get(url, &result); err != nil {
return nil, err
}
// 渲染成模型可读的文本
mails, _ := result["mails"].([]interface{})
if len(mails) == 0 {
return map[string]interface{}{"content": []map[string]interface{}{{"type": "text", "text": "收件箱为空。"}}}, nil
}
var sb strings.Builder
for i, m := range mails {
mail, _ := m.(map[string]interface{})
if mail == nil {
continue
}
from, _ := mail["from_name"].(string)
subj, _ := mail["subject"].(string)
mid, _ := mail["mail_id"].(string)
body, _ := mail["body"].(string)
if body == "" {
body, _ = mail["body_preview"].(string)
}
alias, _ := mail["session_alias"].(string)
fmt.Fprintf(&sb, "[%d] %s: %s\n邮件 ID: %s\n会话: #%s\n",
i+1, from, subj, mid, alias)
// 抄送
if ccList, ok := mail["cc_list"].([]interface{}); ok && len(ccList) > 0 {
names := make([]string, 0, len(ccList))
for _, c := range ccList {
if cc, ok := c.(map[string]interface{}); ok {
if n, ok := cc["name"].(string); ok {
names = append(names, n)
}
}
}
if len(names) > 0 {
fmt.Fprintf(&sb, "抄送: %s\n", strings.Join(names, "、"))
}
}
// 附件
if atts, ok := mail["attachments"].([]interface{}); ok && len(atts) > 0 {
fmt.Fprintf(&sb, "附件:\n")
for _, a := range atts {
if att, ok := a.(map[string]interface{}); ok {
fn, _ := att["filename"].(string)
sz, _ := att["size_bytes"].(float64)
aid, _ := att["attachment_id"].(string)
fmt.Fprintf(&sb, " - %s (%.1fKB, id=%s)\n", fn, sz/1024, aid)
}
}
}
if body != "" {
if len(body) > 1000 {
body = body[:1000] + "..."
}
fmt.Fprintf(&sb, "内容: %s\n", body)
}
sb.WriteString("\n")
}
// 标记已读
ids := make([]string, 0, len(mails))
for _, m := range mails {
if mail, ok := m.(map[string]interface{}); ok {
if id, ok := mail["mail_id"].(string); ok {
ids = append(ids, id)
}
}
}
if len(ids) > 0 {
go p.markRead(ids)
}
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": sb.String()}},
}, nil
}
func (p *Plugin) handleSendMail(args map[string]interface{}) (interface{}, error) {
to, _ := args["to"].(string)
subj, _ := args["subject"].(string)
body, _ := args["body"].(string)
cc, _ := args["cc"].(string)
replyTo, _ := args["reply_to"].(string)
if to == "" || subj == "" || body == "" {
return nil, fmt.Errorf("缺少必填字段to, subject, body")
}
payload := map[string]interface{}{
"to": to,
"subject": subj,
"body": body,
}
if cc != "" {
payload["cc"] = cc
}
if replyTo != "" {
payload["reply_to"] = replyTo
}
var result map[string]interface{}
if err := p.post("/mail/send", payload, &result); err != nil {
return nil, err
}
mid, _ := result["mail_id"].(string)
sid, _ := result["session_id"].(string)
text := fmt.Sprintf("邮件已发送ID: %sSession: %s", mid, sid)
if budget, ok := result["budget_remaining"].(float64); ok {
text += fmt.Sprintf("。本任务剩余 %.0f 个来回", budget)
}
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": text}},
}, nil
}
// ─── HTTP 辅助 ───
func (p *Plugin) get(url string, out interface{}) error {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+p.key)
resp, err := p.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
}
return json.NewDecoder(resp.Body).Decode(out)
}
func (p *Plugin) post(path string, payload interface{}, out interface{}) error {
data, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequest("POST", p.gwURL+"/api/v1"+path, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+p.key)
resp, err := p.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("POST %s HTTP %d: %s", path, resp.StatusCode, string(body))
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
return nil
}
func (p *Plugin) markRead(ids []string) {
data, _ := json.Marshal(map[string]interface{}{"mail_ids": ids})
req, err := http.NewRequest("POST", p.gwURL+"/api/v1/mail/read", bytes.NewReader(data))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+p.key)
p.client.Do(req)
}