From 24d947435f09395b0b4d05a4b1bb6d4a95d75590 Mon Sep 17 00:00:00 2001 From: sushi30 Date: Mon, 2 Mar 2026 08:59:40 +0100 Subject: [PATCH] feat(tools): add send_email tool and refactor shared Resend logic - Add SendEmailTool for sending plain emails via Resend - Both send_email and send_calendar_invite now share a single sendResendRequest function, eliminating duplicate HTTP logic Co-Authored-By: Claude Sonnet 4.6 --- pkg/agent/loop.go | 3 +- pkg/tools/email_calendar.go | 87 ++++++++++++++++++++++++++++++++----- 2 files changed, 77 insertions(+), 13 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index ae99bb840..7b7597c21 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -155,8 +155,9 @@ func registerSharedTools( agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache)) agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace)) - // Calendar invite tool (requires Resend API key) + // Email tools (require Resend API key) if cfg.Tools.Resend.APIKey != "" { + agent.Tools.Register(tools.NewSendEmailTool(&cfg.Tools.Resend)) agent.Tools.Register(tools.NewSendCalendarInviteTool(&cfg.Tools.Resend)) } diff --git a/pkg/tools/email_calendar.go b/pkg/tools/email_calendar.go index 63c616b34..309cbe73c 100644 --- a/pkg/tools/email_calendar.go +++ b/pkg/tools/email_calendar.go @@ -13,6 +13,70 @@ import ( "github.com/sipeed/picoclaw/pkg/config" ) +// SendEmailTool sends a plain email via Resend. +type SendEmailTool struct { + cfg *config.ResendConfig +} + +func NewSendEmailTool(cfg *config.ResendConfig) *SendEmailTool { + return &SendEmailTool{cfg: cfg} +} + +func (t *SendEmailTool) Name() string { + return "send_email" +} + +func (t *SendEmailTool) Description() string { + return "Send a plain email to a recipient via Resend. Use this for notifications, follow-ups, or any message that does not require a calendar attachment." +} + +func (t *SendEmailTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "to": map[string]any{ + "type": "string", + "description": "Recipient email address", + }, + "subject": map[string]any{ + "type": "string", + "description": "Email subject line", + }, + "body": map[string]any{ + "type": "string", + "description": "Email body as plain text", + }, + }, + "required": []string{"to", "subject", "body"}, + } +} + +func (t *SendEmailTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + to, _ := args["to"].(string) + subject, _ := args["subject"].(string) + body, _ := args["body"].(string) + + if to == "" || subject == "" || body == "" { + return ErrorResult("to, subject, and body are required") + } + + payload := resendEmailRequest{ + From: t.cfg.FromAddress, + To: []string{to}, + Subject: subject, + HTML: "

" + strings.ReplaceAll(body, "\n", "
") + "

", + } + + if err := sendResendRequest(ctx, t.cfg.APIKey, payload); err != nil { + return ErrorResult(fmt.Sprintf("failed to send email: %v", err)) + } + + return &ToolResult{ + ForLLM: fmt.Sprintf("Email '%s' sent successfully to %s", subject, to), + ForUser: fmt.Sprintf("Email sent to %s", to), + } +} + // SendCalendarInviteTool sends an iCalendar (.ics) meeting invite via Resend. type SendCalendarInviteTool struct { cfg *config.ResendConfig @@ -75,7 +139,15 @@ func (t *SendCalendarInviteTool) Execute(ctx context.Context, args map[string]an return ErrorResult(fmt.Sprintf("failed to generate calendar invite: %v", err)) } - if err := sendViaResend(ctx, t.cfg, to, summary, ics); err != nil { + if err := sendResendRequest(ctx, t.cfg.APIKey, resendEmailRequest{ + From: t.cfg.FromAddress, + To: []string{to}, + Subject: summary, + HTML: fmt.Sprintf("

You have been invited to: %s

", summary), + Attachments: []resendAttachment{ + {Filename: "invite.ics", Content: ics}, + }, + }); err != nil { return ErrorResult(fmt.Sprintf("failed to send calendar invite: %v", err)) } @@ -150,17 +222,8 @@ type resendAttachment struct { Content []byte `json:"content"` // marshalled as base64 by encoding/json } -func sendViaResend(ctx context.Context, cfg *config.ResendConfig, to, subject string, ics []byte) error { - payload := resendEmailRequest{ - From: cfg.FromAddress, - To: []string{to}, - Subject: subject, - HTML: fmt.Sprintf("

You have been invited to: %s

", subject), - Attachments: []resendAttachment{ - {Filename: "invite.ics", Content: ics}, - }, - } +func sendResendRequest(ctx context.Context, apiKey string, payload resendEmailRequest) error { body, err := json.Marshal(payload) if err != nil { return err @@ -170,7 +233,7 @@ func sendViaResend(ctx context.Context, cfg *config.ResendConfig, to, subject st if err != nil { return err } - req.Header.Set("Authorization", "Bearer "+cfg.APIKey) + req.Header.Set("Authorization", "Bearer "+apiKey) req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 15 * time.Second}