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 <noreply@anthropic.com>
This commit is contained in:
sushi30 2026-03-02 08:59:40 +01:00
parent 4258f884e1
commit 24d947435f
2 changed files with 77 additions and 13 deletions

View file

@ -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))
}

View file

@ -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: "<p>" + strings.ReplaceAll(body, "\n", "<br>") + "</p>",
}
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("<p>You have been invited to: <strong>%s</strong></p>", 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("<p>You have been invited to: <strong>%s</strong></p>", 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}