package tools import ( "context" "errors" "log/slog" "math" "strconv" "strings" sdk "github.com/memohai/twilight-ai/sdk" "github.com/memohai/memoh/internal/email" ) type EmailProvider struct { logger *slog.Logger service *email.Service manager *email.Manager } func NewEmailProvider(log *slog.Logger, service *email.Service, manager *email.Manager) *EmailProvider { return &EmailProvider{ logger: log.With(slog.String("tool", "email")), service: service, manager: manager, } } func (p *EmailProvider) Tools(_ context.Context, session SessionContext) ([]sdk.Tool, error) { if session.IsSubagent { return nil, nil } sess := session return []sdk.Tool{ { Name: "list_email_accounts", Description: "List the email accounts (provider bindings) configured for this bot, including provider IDs, email addresses, and permissions.", Parameters: emptyObjectSchema(), Execute: func(ctx *sdk.ToolExecContext, _ any) (any, error) { return p.execListAccounts(ctx.Context, sess) }, }, { Name: "send_email", Description: "Send an email via the bot's configured email provider. Requires write permission.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "to": map[string]any{"type": "string", "description": "Recipient email address(es), comma-separated"}, "subject": map[string]any{"type": "string", "description": "Email subject"}, "body": map[string]any{"type": "string", "description": "Email body content"}, "html": map[string]any{"type": "boolean", "description": "Whether body is HTML (default false)"}, "provider_id": map[string]any{"type": "string", "description": "Email provider ID to send from (optional, uses default if omitted)"}, }, "required": []string{"to", "subject", "body"}, }, Execute: func(ctx *sdk.ToolExecContext, input any) (any, error) { return p.execSendEmail(ctx.Context, sess, inputAsMap(input)) }, }, { Name: "list_email", Description: "List emails from the mailbox (newest first). Supports pagination. Requires read permission.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "page": map[string]any{"type": "integer", "description": "Page number, 0-based (default 0 = newest)"}, "page_size": map[string]any{"type": "integer", "description": "Emails per page (default 20)"}, "provider_id": map[string]any{"type": "string", "description": "Email provider ID (optional, uses first readable binding)"}, }, }, Execute: func(ctx *sdk.ToolExecContext, input any) (any, error) { return p.execListEmails(ctx.Context, sess, inputAsMap(input)) }, }, { Name: "read_email", Description: "Read the full content of an email by its UID. Requires read permission.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "uid": map[string]any{"type": "integer", "description": "The email UID from email_list results"}, "provider_id": map[string]any{"type": "string", "description": "Email provider ID (optional, uses first readable binding)"}, }, "required": []string{"uid"}, }, Execute: func(ctx *sdk.ToolExecContext, input any) (any, error) { return p.execReadEmail(ctx.Context, sess, inputAsMap(input)) }, }, }, nil } func (p *EmailProvider) getBindings(ctx context.Context, botID string) ([]email.BindingResponse, error) { bindings, err := p.service.ListBindings(ctx, botID) if err != nil || len(bindings) == 0 { return nil, errors.New("no email binding configured for this bot") } return bindings, nil } func (p *EmailProvider) execListAccounts(ctx context.Context, session SessionContext) (any, error) { botID := strings.TrimSpace(session.BotID) if botID == "" { return nil, errors.New("bot_id is required") } bindings, err := p.getBindings(ctx, botID) if err != nil { return nil, err } accounts := make([]map[string]any, 0, len(bindings)) for _, b := range bindings { accounts = append(accounts, map[string]any{ "provider_id": b.EmailProviderID, "email_address": b.EmailAddress, "can_read": b.CanRead, "can_write": b.CanWrite, "can_delete": b.CanDelete, }) } return map[string]any{"accounts": accounts}, nil } func (p *EmailProvider) execSendEmail(ctx context.Context, session SessionContext, args map[string]any) (any, error) { botID := strings.TrimSpace(session.BotID) if botID == "" { return nil, errors.New("bot_id is required") } bindings, err := p.getBindings(ctx, botID) if err != nil { return nil, err } binding := resolveWriteBinding(bindings, StringArg(args, "provider_id")) if binding == nil { return nil, errors.New("email write permission denied or provider not found") } toRaw := StringArg(args, "to") subject := StringArg(args, "subject") body := StringArg(args, "body") isHTML, _, _ := BoolArg(args, "html") if toRaw == "" || subject == "" || body == "" { return nil, errors.New("to, subject, and body are required") } var toList []string for _, addr := range strings.Split(toRaw, ",") { addr = strings.TrimSpace(addr) if addr != "" { toList = append(toList, addr) } } messageID, err := p.manager.SendEmail(ctx, botID, binding.EmailProviderID, email.OutboundEmail{ To: toList, Subject: subject, Body: body, HTML: isHTML, }) if err != nil { return nil, err } return map[string]any{"message_id": messageID, "status": "sent"}, nil } func (p *EmailProvider) execListEmails(ctx context.Context, session SessionContext, args map[string]any) (any, error) { botID := strings.TrimSpace(session.BotID) if botID == "" { return nil, errors.New("bot_id is required") } bindings, err := p.getBindings(ctx, botID) if err != nil { return nil, err } binding := resolveReadBinding(bindings, StringArg(args, "provider_id")) if binding == nil { return nil, errors.New("email read permission denied or provider not found") } providerName, config, err := p.service.ProviderConfig(ctx, binding.EmailProviderID) if err != nil { return nil, err } config = ensureProviderID(config, binding.EmailProviderID) reader, err := p.service.Registry().GetMailboxReader(providerName) if err != nil { return nil, errors.New("mailbox listing not supported for this provider") } page, _, _ := IntArg(args, "page") pageSize, _, _ := IntArg(args, "page_size") if pageSize <= 0 { pageSize = 20 } emails, total, err := reader.ListMailbox(ctx, config, page, pageSize) if err != nil { return nil, err } summaries := make([]map[string]any, 0, len(emails)) for _, item := range emails { summaries = append(summaries, map[string]any{ "uid": item.MessageID, "from": item.From, "subject": item.Subject, "received_at": item.ReceivedAt, }) } return map[string]any{"emails": summaries, "total": total, "page": page}, nil } func (p *EmailProvider) execReadEmail(ctx context.Context, session SessionContext, args map[string]any) (any, error) { botID := strings.TrimSpace(session.BotID) if botID == "" { return nil, errors.New("bot_id is required") } bindings, err := p.getBindings(ctx, botID) if err != nil { return nil, err } binding := resolveReadBinding(bindings, StringArg(args, "provider_id")) if binding == nil { return nil, errors.New("email read permission denied or provider not found") } uidRaw, ok, _ := IntArg(args, "uid") if !ok || uidRaw <= 0 { uidStr := StringArg(args, "uid") if uidStr != "" { parsed, _ := strconv.Atoi(uidStr) uidRaw = parsed } } if uidRaw <= 0 { return nil, errors.New("uid is required") } if uidRaw > math.MaxUint32 { return nil, errors.New("uid out of range") } providerName, config, err := p.service.ProviderConfig(ctx, binding.EmailProviderID) if err != nil { return nil, err } config = ensureProviderID(config, binding.EmailProviderID) reader, err := p.service.Registry().GetMailboxReader(providerName) if err != nil { return nil, errors.New("mailbox reading not supported for this provider") } item, err := reader.ReadMailbox(ctx, config, uint32(uidRaw)) //nolint:gosec // bounds checked above if err != nil { return nil, err } return map[string]any{ "uid": item.MessageID, "from": item.From, "to": item.To, "subject": item.Subject, "body": item.BodyText, "received_at": item.ReceivedAt, }, nil } func resolveReadBinding(bindings []email.BindingResponse, providerID string) *email.BindingResponse { for i := range bindings { if !bindings[i].CanRead { continue } if providerID == "" || bindings[i].EmailProviderID == providerID { return &bindings[i] } } return nil } func resolveWriteBinding(bindings []email.BindingResponse, providerID string) *email.BindingResponse { for i := range bindings { if !bindings[i].CanWrite { continue } if providerID == "" || bindings[i].EmailProviderID == providerID { return &bindings[i] } } return nil } func ensureProviderID(config map[string]any, providerID string) map[string]any { if config == nil { config = make(map[string]any) } else { copied := make(map[string]any, len(config)+1) for k, v := range config { copied[k] = v } config = copied } config["_provider_id"] = providerID return config }