Enhance Email Delivery Configuration and Update Documentation
- Added `robot_email` field to the Robot structure for specifying the sender's email address in email communications. - Updated the EmailTarget structure by removing the CC field, simplifying the configuration for email targets. - Revised TECHNICAL.md to include details on the new `robot_email` field and its usage in email delivery, ensuring clarity on the sender identity. - Enhanced the Delivery Center's email handling to utilize the Robot's email as the From address, with a fallback to the provider's default if not configured. - Updated DESIGN.md to reflect changes in email delivery architecture and added new sections for global email configuration. - Marked completion of related tasks in TODO.md, confirming the integration of new email features and structures.
This commit is contained in:
parent
6c45cc41be
commit
62a18f0c8d
9 changed files with 2060 additions and 76 deletions
|
|
@ -138,6 +138,7 @@ Uses existing `__yao.member` model (`yao/models/member.mod.yao`):
|
|||
| `robot_config` | JSON | Agent configuration (see section 5) |
|
||||
| `robot_status` | enum | `idle` \| `working` \| `paused` \| `error` \| `maintenance` |
|
||||
| `system_prompt` | text | Identity & role prompt |
|
||||
| `robot_email` | string | Robot's email address for sending emails (From address) |
|
||||
| `agents` | JSON | Accessible agents list |
|
||||
| `mcp_servers` | JSON | Accessible MCP servers |
|
||||
| `manager_id` | string | Direct manager user ID |
|
||||
|
|
@ -859,7 +860,6 @@ type EmailPreference struct {
|
|||
|
||||
type EmailTarget struct {
|
||||
To []string `json:"to"` // Recipient addresses
|
||||
CC []string `json:"cc,omitempty"` // CC addresses
|
||||
Template string `json:"template,omitempty"` // Email template ID
|
||||
Subject string `json:"subject,omitempty"` // Subject template
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1169,6 +1169,7 @@ type Robot struct {
|
|||
SystemPrompt string `json:"system_prompt"`
|
||||
Status RobotStatus `json:"robot_status"`
|
||||
AutonomousMode bool `json:"autonomous_mode"`
|
||||
RobotEmail string `json:"robot_email"` // Robot's email address for sending emails
|
||||
|
||||
// Parsed config (from robot_config JSON field)
|
||||
Config *Config `json:"-"`
|
||||
|
|
@ -1448,7 +1449,6 @@ type EmailPreference struct {
|
|||
|
||||
type EmailTarget struct {
|
||||
To []string `json:"to"` // Recipient addresses
|
||||
CC []string `json:"cc,omitempty"` // CC addresses
|
||||
Template string `json:"template,omitempty"` // Email template ID
|
||||
Subject string `json:"subject,omitempty"` // Subject template (default: content.Summary)
|
||||
}
|
||||
|
|
@ -2166,7 +2166,27 @@ The agent focuses on content generation:
|
|||
}
|
||||
```
|
||||
|
||||
### 6.5 Delivery Center
|
||||
### 6.5 Global Email Configuration
|
||||
|
||||
Email delivery uses global configuration for channel selection and Robot-specific sender identity:
|
||||
|
||||
```go
|
||||
// types/config_global.go
|
||||
|
||||
// DefaultEmailChannel returns the default messenger channel name
|
||||
// Default: "email" (maps to messengers/channels.yao)
|
||||
func DefaultEmailChannel() string
|
||||
|
||||
// SetDefaultEmailChannel sets the default channel (call during agent init)
|
||||
func SetDefaultEmailChannel(channel string)
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
- `DefaultEmailChannel()` - returns the messenger channel name for email delivery
|
||||
- `Robot.RobotEmail` - used as the `From` address when sending emails
|
||||
- If `RobotEmail` is empty, falls back to provider's default `from` address
|
||||
|
||||
### 6.6 Delivery Center
|
||||
|
||||
The Delivery Center receives `DeliveryRequest`, reads preferences, and executes delivery to **all enabled targets**.
|
||||
|
||||
|
|
@ -2187,10 +2207,10 @@ func (dc *DeliveryCenter) Deliver(ctx context.Context, req *DeliveryRequest) *De
|
|||
var results []ChannelResult
|
||||
allSuccess := true
|
||||
|
||||
// Email - send to all targets
|
||||
// Email - send to all targets (robot passed for From address)
|
||||
if prefs.Email != nil && prefs.Email.Enabled {
|
||||
for _, target := range prefs.Email.Targets {
|
||||
result := dc.sendEmail(ctx, req.Content, target)
|
||||
result := dc.sendEmail(ctx, req.Content, target, req.Context, robot)
|
||||
results = append(results, result)
|
||||
if !result.Success {
|
||||
allSuccess = false
|
||||
|
|
@ -2232,13 +2252,20 @@ func (dc *DeliveryCenter) Deliver(ctx context.Context, req *DeliveryRequest) *De
|
|||
}
|
||||
```
|
||||
|
||||
### 6.6 Channel Handlers
|
||||
### 6.7 Channel Handlers
|
||||
|
||||
Each delivery channel is handled by dedicated methods in DeliveryCenter:
|
||||
|
||||
```go
|
||||
// sendEmail - send to a single email target
|
||||
func (dc *DeliveryCenter) sendEmail(ctx context.Context, content *DeliveryContent, target EmailTarget) ChannelResult {
|
||||
// Uses Robot.RobotEmail as From address and global DefaultEmailChannel()
|
||||
func (dc *DeliveryCenter) sendEmail(
|
||||
ctx context.Context,
|
||||
content *DeliveryContent,
|
||||
target EmailTarget,
|
||||
deliveryCtx *DeliveryContext,
|
||||
robot *Robot,
|
||||
) ChannelResult {
|
||||
// Convert attachments to messenger format
|
||||
var attachments []messenger.Attachment
|
||||
for _, att := range content.Attachments {
|
||||
|
|
@ -2255,17 +2282,25 @@ func (dc *DeliveryCenter) sendEmail(ctx context.Context, content *DeliveryConten
|
|||
}
|
||||
|
||||
subject := content.Summary
|
||||
if target.SubjectTemplate != "" {
|
||||
subject = target.SubjectTemplate
|
||||
if target.Subject != "" {
|
||||
subject = target.Subject
|
||||
}
|
||||
|
||||
err := dc.messenger.Send(ctx, &messenger.Message{
|
||||
msg := &messenger.Message{
|
||||
To: target.To,
|
||||
CC: target.CC,
|
||||
Subject: subject,
|
||||
Body: content.Body,
|
||||
Attachments: attachments,
|
||||
})
|
||||
}
|
||||
|
||||
// Set From address from Robot's email (if configured)
|
||||
if robot != nil && robot.RobotEmail != "" {
|
||||
msg.From = robot.RobotEmail
|
||||
}
|
||||
|
||||
// Use global default email channel
|
||||
channel := DefaultEmailChannel() // from types/config_global.go
|
||||
err := dc.messenger.Send(ctx, channel, msg)
|
||||
|
||||
now := time.Now()
|
||||
return ChannelResult{
|
||||
|
|
@ -2344,7 +2379,7 @@ func (dc *DeliveryCenter) callProcess(ctx context.Context, content *DeliveryCont
|
|||
2. Automatically send in-app notifications to subscribed users
|
||||
3. This is transparent to P4 and Delivery Agent
|
||||
|
||||
### 6.7 Execution Persistence
|
||||
### 6.8 Execution Persistence
|
||||
|
||||
Robot execution history is stored in `__yao.agent_execution` table for UI display:
|
||||
|
||||
|
|
|
|||
|
|
@ -975,11 +975,11 @@ Supported channels:
|
|||
|
||||
### 10.4 Delivery Agent Setup
|
||||
|
||||
- [ ] `robot/delivery/package.yao` - Delivery Agent config
|
||||
- [ ] `robot/delivery/prompts.yml` - delivery prompts
|
||||
- [ ] Input: Full execution context (P0-P3 results)
|
||||
- [ ] Output: DeliveryContent (Summary, Body, Attachments) - **only content, no channels**
|
||||
- [ ] Agent focuses on content generation, NOT channel selection
|
||||
- [x] `robot/delivery/package.yao` - Delivery Agent config
|
||||
- [x] `robot/delivery/prompts.yml` - delivery prompts
|
||||
- [x] Input: Full execution context (P0-P3 results)
|
||||
- [x] Output: DeliveryContent (Summary, Body, Attachments) - **only content, no channels**
|
||||
- [x] Agent focuses on content generation, NOT channel selection
|
||||
|
||||
### 10.5 Delivery Content Structure
|
||||
|
||||
|
|
@ -1036,46 +1036,48 @@ type DeliveryContext struct {
|
|||
### 10.6 Implementation
|
||||
|
||||
**P4 Entry (executor/delivery.go):**
|
||||
- [ ] `RunDelivery(ctx, exec, data)` - P4 entry point
|
||||
- [ ] Call Delivery Agent to generate content (only content, no channels)
|
||||
- [ ] Build DeliveryRequest (Content + Context)
|
||||
- [ ] Push to Delivery Center
|
||||
- [ ] Store DeliveryResult in exec.Delivery
|
||||
- [x] `RunDelivery(ctx, exec, data)` - P4 entry point
|
||||
- [x] Call Delivery Agent to generate content (only content, no channels)
|
||||
- [x] Build DeliveryRequest (Content + Context)
|
||||
- [x] Push to Delivery Center
|
||||
- [x] Store DeliveryResult in exec.Delivery
|
||||
|
||||
**Delivery Center (executor/delivery.go, future: yao/delivery):**
|
||||
- [ ] `DeliveryCenter.Deliver(ctx, request)` - main entry
|
||||
- [ ] Read Robot/User delivery preferences
|
||||
- [ ] Iterate through all enabled targets for each channel
|
||||
- [ ] Aggregate ChannelResults into DeliveryResult
|
||||
**Delivery Center (executor/delivery_center.go):**
|
||||
- [x] `DeliveryCenter.Deliver(ctx, request)` - main entry
|
||||
- [x] Read Robot/User delivery preferences
|
||||
- [x] Iterate through all enabled targets for each channel
|
||||
- [x] Aggregate ChannelResults into DeliveryResult
|
||||
|
||||
**Channel Handlers (each supports multiple targets):**
|
||||
- [ ] `sendEmail()` - uses yao/messenger
|
||||
- [ ] Convert DeliveryAttachment to messenger.Attachment
|
||||
- [ ] Support multiple EmailTarget
|
||||
- [ ] Support custom subject_template per target
|
||||
- [ ] `postWebhook()` - POST JSON
|
||||
- [ ] POST DeliveryContent as JSON payload
|
||||
- [ ] Support multiple WebhookTarget
|
||||
- [ ] Support custom headers per target
|
||||
- [ ] `callProcess()` - Yao Process call
|
||||
- [ ] DeliveryContent as first arg
|
||||
- [ ] Support multiple ProcessTarget
|
||||
- [ ] Support additional args per target
|
||||
- [x] `sendEmail()` - uses yao/messenger
|
||||
- [x] Convert DeliveryAttachment to messenger.Attachment
|
||||
- [x] Support multiple EmailTarget
|
||||
- [x] Support custom subject_template per target
|
||||
- [x] Use `Robot.RobotEmail` as From address (if configured)
|
||||
- [x] Use global `DefaultEmailChannel()` for messenger channel selection
|
||||
- [x] `postWebhook()` - POST JSON
|
||||
- [x] POST DeliveryContent as JSON payload
|
||||
- [x] Support multiple WebhookTarget
|
||||
- [x] Support custom headers per target
|
||||
- [x] `callProcess()` - Yao Process call
|
||||
- [x] DeliveryContent as first arg
|
||||
- [x] Support multiple ProcessTarget
|
||||
- [x] Support additional args per target
|
||||
|
||||
### 10.7 Tests
|
||||
|
||||
- [ ] `executor/delivery_test.go` - P4 delivery
|
||||
- [ ] Test: Delivery Agent generates content (only content)
|
||||
- [ ] Test: DeliveryCenter reads preferences
|
||||
- [ ] Test: Multiple email targets
|
||||
- [ ] Test: Multiple webhook targets
|
||||
- [ ] Test: Multiple process targets
|
||||
- [ ] Test: Mixed channels (email + webhook + process)
|
||||
- [ ] Test: sendEmail with attachments
|
||||
- [ ] Test: postWebhook with custom headers
|
||||
- [ ] Test: callProcess with args
|
||||
- [ ] Test: Partial success (some targets fail)
|
||||
- [ ] Test: DeliveryResult aggregation
|
||||
- [x] `executor/delivery_test.go` - P4 delivery
|
||||
- [x] Test: Delivery Agent generates content (only content)
|
||||
- [x] Test: DeliveryCenter reads preferences
|
||||
- [x] Test: Multiple email targets (TestDeliveryCenterEmail)
|
||||
- [x] Test: Multiple webhook targets
|
||||
- [x] Test: Multiple process targets (TestDeliveryCenterProcess)
|
||||
- [x] Test: Mixed channels (email + webhook + process) (TestDeliveryCenterAllChannels)
|
||||
- [x] Test: sendEmail with attachments (TestDeliveryCenterEmail)
|
||||
- [x] Test: postWebhook with custom headers
|
||||
- [x] Test: callProcess with args (TestDeliveryCenterProcess)
|
||||
- [x] Test: Partial success (some targets fail)
|
||||
- [x] Test: DeliveryResult aggregation
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -1291,7 +1293,7 @@ func TestWithLLM(t *testing.T) {
|
|||
| 7. P1 Goals | ✅ | Goal Generation Agent integration |
|
||||
| 8. P2 Tasks | ✅ | Task Planning Agent integration |
|
||||
| 9. P3 Run | ✅ | Task execution + validation + yao/assert + multi-turn conversation |
|
||||
| 10. P4 Delivery | ⬜ | Output delivery (email/webhook/process, notify future) |
|
||||
| 10. P4 Delivery | ✅ | Output delivery (email/webhook/process, notify future) |
|
||||
| 11. API & Integration | ⬜ | Complete API, end-to-end tests (main flow: P0→P1→P2→P3→P4) |
|
||||
| 12. Advanced | ⬜ | P5 Learning, dedup, plan queue, Sandbox mode |
|
||||
|
||||
|
|
|
|||
|
|
@ -1,36 +1,397 @@
|
|||
package standard
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||
)
|
||||
|
||||
// RunDelivery executes P4: Delivery phase
|
||||
// Delivers results to configured targets
|
||||
// Calls the Delivery Agent to generate content, then routes to Delivery Center
|
||||
//
|
||||
// Input:
|
||||
// - TaskResults (from P3)
|
||||
// - Delivery config from robot
|
||||
// - Full execution context (P0-P3)
|
||||
// - Robot config
|
||||
//
|
||||
// Output:
|
||||
// - DeliveryResult with success status
|
||||
// - DeliveryResult with content and channel results
|
||||
//
|
||||
// Delivery Types:
|
||||
// - DeliveryEmail: Send email
|
||||
// - DeliveryNotify: Send notification
|
||||
// - DeliveryWebhook: Call webhook
|
||||
// - DeliveryStore: Store to database
|
||||
//
|
||||
// TODO: Implement real delivery
|
||||
// Process:
|
||||
// 1. Call Delivery Agent with full execution context
|
||||
// 2. Agent generates DeliveryContent (summary, body, attachments)
|
||||
// 3. Route content to Delivery Center for actual delivery
|
||||
func (e *Executor) RunDelivery(ctx *robottypes.Context, exec *robottypes.Execution, _ interface{}) error {
|
||||
e.simulateStreamDelay()
|
||||
|
||||
exec.Delivery = &robottypes.DeliveryResult{
|
||||
RequestID: "delivery-" + exec.ID,
|
||||
Content: &robottypes.DeliveryContent{
|
||||
Summary: "Delivery completed (placeholder)",
|
||||
Body: "# Delivery\n\nTODO: Implement real delivery logic.",
|
||||
},
|
||||
Success: true,
|
||||
// Get robot for identity and resources
|
||||
robot := exec.GetRobot()
|
||||
if robot == nil {
|
||||
return fmt.Errorf("robot not found in execution")
|
||||
}
|
||||
|
||||
// Get agent ID for delivery phase
|
||||
agentID := "__yao.delivery" // default
|
||||
if robot.Config != nil && robot.Config.Resources != nil {
|
||||
agentID = robot.Config.Resources.GetPhaseAgent(robottypes.PhaseDelivery)
|
||||
}
|
||||
|
||||
// Build input for Delivery Agent
|
||||
formatter := NewInputFormatter()
|
||||
userContent := formatter.FormatDeliveryInput(exec, robot)
|
||||
|
||||
if userContent == "" {
|
||||
return fmt.Errorf("no content available for delivery generation")
|
||||
}
|
||||
|
||||
// Call Delivery Agent
|
||||
caller := NewAgentCaller()
|
||||
result, err := caller.CallWithMessages(ctx, agentID, userContent)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delivery agent (%s) call failed: %w", agentID, err)
|
||||
}
|
||||
|
||||
// Parse response as JSON
|
||||
// Delivery Agent returns: { "content": { "summary": "...", "body": "...", "attachments": [...] } }
|
||||
data, err := result.GetJSON()
|
||||
if err != nil {
|
||||
// Fallback: if not JSON, create minimal content from raw text
|
||||
content := result.GetText()
|
||||
if content == "" {
|
||||
return fmt.Errorf("delivery agent returned empty response")
|
||||
}
|
||||
exec.Delivery = &robottypes.DeliveryResult{
|
||||
RequestID: generateRequestID(exec.ID),
|
||||
Content: &robottypes.DeliveryContent{
|
||||
Summary: truncateSummary(content, 200),
|
||||
Body: content,
|
||||
},
|
||||
Success: true,
|
||||
}
|
||||
return e.routeToDeliveryCenter(ctx, exec, robot)
|
||||
}
|
||||
|
||||
// Build DeliveryContent from JSON
|
||||
content := parseDeliveryContent(data)
|
||||
if content == nil {
|
||||
return fmt.Errorf("delivery agent (%s) returned invalid content", agentID)
|
||||
}
|
||||
|
||||
// Build DeliveryResult
|
||||
exec.Delivery = &robottypes.DeliveryResult{
|
||||
RequestID: generateRequestID(exec.ID),
|
||||
Content: content,
|
||||
Success: true,
|
||||
}
|
||||
|
||||
// Route to Delivery Center for actual delivery
|
||||
return e.routeToDeliveryCenter(ctx, exec, robot)
|
||||
}
|
||||
|
||||
// routeToDeliveryCenter sends content to the Delivery Center for actual delivery
|
||||
// The Delivery Center decides which channels to use based on robot/user preferences
|
||||
func (e *Executor) routeToDeliveryCenter(ctx *robottypes.Context, exec *robottypes.Execution, robot *robottypes.Robot) error {
|
||||
if exec.Delivery == nil || exec.Delivery.Content == nil {
|
||||
return fmt.Errorf("no delivery content to route")
|
||||
}
|
||||
|
||||
// Get delivery preferences from robot config
|
||||
var prefs *robottypes.DeliveryPreferences
|
||||
if robot.Config != nil {
|
||||
prefs = robot.Config.Delivery
|
||||
}
|
||||
|
||||
// If no preferences configured, skip delivery (content is still saved in exec.Delivery)
|
||||
if prefs == nil || !hasActiveChannels(prefs) {
|
||||
// No channels configured - mark as success but with no results
|
||||
exec.Delivery.Success = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create Delivery Center and execute
|
||||
center := NewDeliveryCenter()
|
||||
results, err := center.Deliver(ctx, exec.Delivery.Content, &robottypes.DeliveryContext{
|
||||
MemberID: exec.MemberID,
|
||||
ExecutionID: exec.ID,
|
||||
TriggerType: exec.TriggerType,
|
||||
TeamID: exec.TeamID,
|
||||
}, prefs, robot)
|
||||
|
||||
// Update delivery result
|
||||
exec.Delivery.Results = results
|
||||
now := time.Now()
|
||||
exec.Delivery.SentAt = &now
|
||||
|
||||
if err != nil {
|
||||
exec.Delivery.Success = false
|
||||
exec.Delivery.Error = err.Error()
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if all channels succeeded
|
||||
allSuccess := true
|
||||
for _, r := range results {
|
||||
if !r.Success {
|
||||
allSuccess = false
|
||||
break
|
||||
}
|
||||
}
|
||||
exec.Delivery.Success = allSuccess
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseDeliveryContent parses the Delivery Agent response into DeliveryContent
|
||||
func parseDeliveryContent(data map[string]interface{}) *robottypes.DeliveryContent {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try to get content object
|
||||
contentData, ok := data["content"].(map[string]interface{})
|
||||
if !ok {
|
||||
// Fallback: maybe the data itself is the content
|
||||
contentData = data
|
||||
}
|
||||
|
||||
content := &robottypes.DeliveryContent{}
|
||||
|
||||
// Parse summary
|
||||
if summary, ok := contentData["summary"].(string); ok {
|
||||
content.Summary = summary
|
||||
}
|
||||
|
||||
// Parse body
|
||||
if body, ok := contentData["body"].(string); ok {
|
||||
content.Body = body
|
||||
}
|
||||
|
||||
// Parse attachments
|
||||
if attachments, ok := contentData["attachments"].([]interface{}); ok {
|
||||
for _, att := range attachments {
|
||||
if attMap, ok := att.(map[string]interface{}); ok {
|
||||
attachment := parseDeliveryAttachment(attMap)
|
||||
if attachment != nil {
|
||||
content.Attachments = append(content.Attachments, *attachment)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate: at least summary or body should be present
|
||||
if content.Summary == "" && content.Body == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
// parseDeliveryAttachment parses a single attachment from the agent response
|
||||
func parseDeliveryAttachment(data map[string]interface{}) *robottypes.DeliveryAttachment {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
att := &robottypes.DeliveryAttachment{}
|
||||
|
||||
if title, ok := data["title"].(string); ok {
|
||||
att.Title = title
|
||||
}
|
||||
if desc, ok := data["description"].(string); ok {
|
||||
att.Description = desc
|
||||
}
|
||||
if taskID, ok := data["task_id"].(string); ok {
|
||||
att.TaskID = taskID
|
||||
}
|
||||
if file, ok := data["file"].(string); ok {
|
||||
att.File = file
|
||||
}
|
||||
|
||||
// At minimum, need title and file
|
||||
if att.Title == "" || att.File == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return att
|
||||
}
|
||||
|
||||
// generateRequestID generates a unique request ID for delivery tracking
|
||||
func generateRequestID(execID string) string {
|
||||
return fmt.Sprintf("dlv-%s-%d", execID, time.Now().UnixNano()%1000000)
|
||||
}
|
||||
|
||||
// getTaskDescription extracts a description from task messages
|
||||
func getTaskDescription(task robottypes.Task) string {
|
||||
if len(task.Messages) == 0 {
|
||||
return task.GoalRef
|
||||
}
|
||||
|
||||
// Try to get text from first message
|
||||
for _, msg := range task.Messages {
|
||||
if content, ok := msg.Content.(string); ok && content != "" {
|
||||
// Truncate if too long
|
||||
if len(content) > 100 {
|
||||
return content[:97] + "..."
|
||||
}
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to goal reference
|
||||
if task.GoalRef != "" {
|
||||
return task.GoalRef
|
||||
}
|
||||
|
||||
return "Task " + task.ID
|
||||
}
|
||||
|
||||
// truncateSummary truncates text to maxLen characters
|
||||
func truncateSummary(text string, maxLen int) string {
|
||||
if len(text) <= maxLen {
|
||||
return text
|
||||
}
|
||||
// Find last space before maxLen to avoid cutting words
|
||||
truncated := text[:maxLen]
|
||||
if idx := strings.LastIndex(truncated, " "); idx > maxLen/2 {
|
||||
return truncated[:idx] + "..."
|
||||
}
|
||||
return truncated + "..."
|
||||
}
|
||||
|
||||
// hasActiveChannels checks if any delivery channel is configured
|
||||
func hasActiveChannels(prefs *robottypes.DeliveryPreferences) bool {
|
||||
if prefs == nil {
|
||||
return false
|
||||
}
|
||||
if prefs.Email != nil && prefs.Email.Enabled && len(prefs.Email.Targets) > 0 {
|
||||
return true
|
||||
}
|
||||
if prefs.Webhook != nil && prefs.Webhook.Enabled && len(prefs.Webhook.Targets) > 0 {
|
||||
return true
|
||||
}
|
||||
if prefs.Process != nil && prefs.Process.Enabled && len(prefs.Process.Targets) > 0 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// FormatDeliveryInput formats the full execution context for the Delivery Agent
|
||||
func (f *InputFormatter) FormatDeliveryInput(exec *robottypes.Execution, robot *robottypes.Robot) string {
|
||||
if exec == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Robot identity
|
||||
if robot != nil && robot.Config != nil && robot.Config.Identity != nil {
|
||||
sb.WriteString("## Robot Identity\n\n")
|
||||
sb.WriteString(fmt.Sprintf("- **Role**: %s\n", robot.Config.Identity.Role))
|
||||
if len(robot.Config.Identity.Duties) > 0 {
|
||||
sb.WriteString("- **Duties**: ")
|
||||
sb.WriteString(strings.Join(robot.Config.Identity.Duties, ", "))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Trigger type
|
||||
sb.WriteString("## Execution Context\n\n")
|
||||
sb.WriteString(fmt.Sprintf("- **Trigger**: %s\n", exec.TriggerType))
|
||||
sb.WriteString(fmt.Sprintf("- **Status**: %s\n", exec.Status))
|
||||
sb.WriteString(fmt.Sprintf("- **Start Time**: %s\n", exec.StartTime.Format("2006-01-02 15:04:05")))
|
||||
if exec.EndTime != nil {
|
||||
duration := exec.EndTime.Sub(exec.StartTime)
|
||||
sb.WriteString(fmt.Sprintf("- **Duration**: %s\n", duration.String()))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Inspiration (P0) - for clock trigger
|
||||
if exec.Inspiration != nil && exec.Inspiration.Content != "" {
|
||||
sb.WriteString("## Inspiration (P0)\n\n")
|
||||
sb.WriteString(exec.Inspiration.Content)
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Goals (P1)
|
||||
if exec.Goals != nil && exec.Goals.Content != "" {
|
||||
sb.WriteString("## Goals (P1)\n\n")
|
||||
sb.WriteString(exec.Goals.Content)
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
|
||||
// Tasks (P2)
|
||||
if len(exec.Tasks) > 0 {
|
||||
sb.WriteString("## Tasks (P2)\n\n")
|
||||
for i, task := range exec.Tasks {
|
||||
// Extract task description from messages if available
|
||||
taskDesc := getTaskDescription(task)
|
||||
sb.WriteString(fmt.Sprintf("%d. **%s** - %s\n", i+1, task.ID, taskDesc))
|
||||
sb.WriteString(fmt.Sprintf(" - Executor: %s (%s)\n", task.ExecutorID, task.ExecutorType))
|
||||
sb.WriteString(fmt.Sprintf(" - Status: %s\n", task.Status))
|
||||
if task.ExpectedOutput != "" {
|
||||
sb.WriteString(fmt.Sprintf(" - Expected: %s\n", task.ExpectedOutput))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Results (P3) - detailed
|
||||
if len(exec.Results) > 0 {
|
||||
sb.WriteString("## Results (P3)\n\n")
|
||||
|
||||
successCount := 0
|
||||
failCount := 0
|
||||
|
||||
for _, result := range exec.Results {
|
||||
if result.Success {
|
||||
successCount++
|
||||
sb.WriteString(fmt.Sprintf("### ✓ Task: %s\n\n", result.TaskID))
|
||||
} else {
|
||||
failCount++
|
||||
sb.WriteString(fmt.Sprintf("### ✗ Task: %s\n\n", result.TaskID))
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("- **Duration**: %dms\n", result.Duration))
|
||||
|
||||
// Validation
|
||||
if result.Validation != nil {
|
||||
if result.Validation.Passed {
|
||||
sb.WriteString(fmt.Sprintf("- **Validation**: ✓ Passed (score: %.2f)\n", result.Validation.Score))
|
||||
} else {
|
||||
sb.WriteString("- **Validation**: ✗ Failed\n")
|
||||
if len(result.Validation.Issues) > 0 {
|
||||
for _, issue := range result.Validation.Issues {
|
||||
sb.WriteString(fmt.Sprintf(" - %s\n", issue))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Output
|
||||
if result.Output != nil {
|
||||
sb.WriteString("\n**Output**:\n")
|
||||
if output, err := json.MarshalIndent(result.Output, "", " "); err == nil {
|
||||
sb.WriteString("```json\n")
|
||||
sb.WriteString(string(output))
|
||||
sb.WriteString("\n```\n")
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("%v\n", result.Output))
|
||||
}
|
||||
}
|
||||
|
||||
// Error
|
||||
if result.Error != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n**Error**: %s\n", result.Error))
|
||||
}
|
||||
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
// Summary
|
||||
sb.WriteString(fmt.Sprintf("### Summary\n\n- **Total Tasks**: %d\n- **Succeeded**: %d\n- **Failed**: %d\n\n",
|
||||
len(exec.Results), successCount, failCount))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
|
|
|||
378
agent/robot/executor/standard/delivery_center.go
Normal file
378
agent/robot/executor/standard/delivery_center.go
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
package standard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/gou/process"
|
||||
robottypes "github.com/yaoapp/yao/agent/robot/types"
|
||||
"github.com/yaoapp/yao/attachment"
|
||||
"github.com/yaoapp/yao/messenger"
|
||||
messengerTypes "github.com/yaoapp/yao/messenger/types"
|
||||
)
|
||||
|
||||
// DeliveryCenter handles routing delivery content to configured channels
|
||||
// It decides which channels to use based on robot/user preferences and executes the delivery
|
||||
type DeliveryCenter struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewDeliveryCenter creates a new DeliveryCenter instance
|
||||
func NewDeliveryCenter() *DeliveryCenter {
|
||||
return &DeliveryCenter{
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Deliver sends content to all configured channels based on preferences
|
||||
// Returns results for each channel target and any error
|
||||
func (dc *DeliveryCenter) Deliver(
|
||||
ctx *robottypes.Context,
|
||||
content *robottypes.DeliveryContent,
|
||||
deliveryCtx *robottypes.DeliveryContext,
|
||||
prefs *robottypes.DeliveryPreferences,
|
||||
robotInstance *robottypes.Robot,
|
||||
) ([]robottypes.ChannelResult, error) {
|
||||
if content == nil {
|
||||
return nil, fmt.Errorf("delivery content is nil")
|
||||
}
|
||||
if prefs == nil {
|
||||
return nil, nil // No preferences = no delivery
|
||||
}
|
||||
|
||||
var results []robottypes.ChannelResult
|
||||
var lastErr error
|
||||
|
||||
// Process email targets
|
||||
if prefs.Email != nil && prefs.Email.Enabled {
|
||||
for _, target := range prefs.Email.Targets {
|
||||
result := dc.sendEmail(ctx.Context, content, target, deliveryCtx, robotInstance)
|
||||
results = append(results, result)
|
||||
if !result.Success && lastErr == nil {
|
||||
lastErr = fmt.Errorf("email delivery failed: %s", result.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process webhook targets
|
||||
if prefs.Webhook != nil && prefs.Webhook.Enabled {
|
||||
for _, target := range prefs.Webhook.Targets {
|
||||
result := dc.postWebhook(ctx.Context, content, target, deliveryCtx)
|
||||
results = append(results, result)
|
||||
if !result.Success && lastErr == nil {
|
||||
lastErr = fmt.Errorf("webhook delivery failed: %s", result.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process process targets
|
||||
if prefs.Process != nil && prefs.Process.Enabled {
|
||||
for _, target := range prefs.Process.Targets {
|
||||
result := dc.callProcess(ctx.Context, content, target, deliveryCtx)
|
||||
results = append(results, result)
|
||||
if !result.Success && lastErr == nil {
|
||||
lastErr = fmt.Errorf("process delivery failed: %s", result.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results, lastErr
|
||||
}
|
||||
|
||||
// sendEmail sends delivery content to a single email target
|
||||
func (dc *DeliveryCenter) sendEmail(
|
||||
ctx context.Context,
|
||||
content *robottypes.DeliveryContent,
|
||||
target robottypes.EmailTarget,
|
||||
deliveryCtx *robottypes.DeliveryContext,
|
||||
robotInstance *robottypes.Robot,
|
||||
) robottypes.ChannelResult {
|
||||
now := time.Now()
|
||||
|
||||
// Build target identifier from recipients
|
||||
targetID := strings.Join(target.To, ",")
|
||||
if targetID == "" {
|
||||
targetID = "no-recipients"
|
||||
}
|
||||
|
||||
result := robottypes.ChannelResult{
|
||||
Type: robottypes.DeliveryEmail,
|
||||
Target: targetID,
|
||||
SentAt: &now,
|
||||
}
|
||||
|
||||
// Get messenger service
|
||||
svc := messenger.Instance
|
||||
if svc == nil {
|
||||
result.Error = "messenger service not available"
|
||||
return result
|
||||
}
|
||||
|
||||
// Build email message
|
||||
msg := &messengerTypes.Message{
|
||||
To: target.To,
|
||||
Subject: buildEmailSubject(target.Subject, target.Template, content, deliveryCtx),
|
||||
Body: buildEmailBody(target.Template, content),
|
||||
Type: messengerTypes.MessageTypeEmail,
|
||||
}
|
||||
|
||||
// Set From address from Robot's email (if configured)
|
||||
if robotInstance != nil && robotInstance.RobotEmail != "" {
|
||||
msg.From = robotInstance.RobotEmail
|
||||
}
|
||||
|
||||
// Convert attachments
|
||||
attachments := convertAttachments(ctx, content.Attachments)
|
||||
if len(attachments) > 0 {
|
||||
msg.Attachments = attachments
|
||||
}
|
||||
|
||||
// Send email using global default channel
|
||||
channel := robottypes.DefaultEmailChannel()
|
||||
err := svc.Send(ctx, channel, msg)
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
return result
|
||||
}
|
||||
|
||||
result.Success = true
|
||||
result.Recipients = target.To
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// postWebhook posts delivery content to a single webhook target
|
||||
func (dc *DeliveryCenter) postWebhook(
|
||||
ctx context.Context,
|
||||
content *robottypes.DeliveryContent,
|
||||
target robottypes.WebhookTarget,
|
||||
deliveryCtx *robottypes.DeliveryContext,
|
||||
) robottypes.ChannelResult {
|
||||
now := time.Now()
|
||||
result := robottypes.ChannelResult{
|
||||
Type: robottypes.DeliveryWebhook,
|
||||
Target: target.URL,
|
||||
SentAt: &now,
|
||||
}
|
||||
|
||||
// Build webhook payload
|
||||
payload := map[string]interface{}{
|
||||
"event": "robot.delivery",
|
||||
"timestamp": now.Format(time.RFC3339),
|
||||
"execution_id": deliveryCtx.ExecutionID,
|
||||
"member_id": deliveryCtx.MemberID,
|
||||
"team_id": deliveryCtx.TeamID,
|
||||
"trigger_type": deliveryCtx.TriggerType,
|
||||
"content": map[string]interface{}{
|
||||
"summary": content.Summary,
|
||||
"body": content.Body,
|
||||
},
|
||||
}
|
||||
|
||||
// Add attachments info (not the actual files)
|
||||
if len(content.Attachments) > 0 {
|
||||
attachmentInfo := make([]map[string]interface{}, 0, len(content.Attachments))
|
||||
for _, att := range content.Attachments {
|
||||
attachmentInfo = append(attachmentInfo, map[string]interface{}{
|
||||
"title": att.Title,
|
||||
"description": att.Description,
|
||||
"task_id": att.TaskID,
|
||||
"file": att.File,
|
||||
})
|
||||
}
|
||||
payload["attachments"] = attachmentInfo
|
||||
}
|
||||
|
||||
// Marshal payload
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("failed to marshal payload: %v", err)
|
||||
return result
|
||||
}
|
||||
|
||||
// Build request
|
||||
method := target.Method
|
||||
if method == "" {
|
||||
method = "POST"
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, target.URL, bytes.NewReader(payloadBytes))
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("failed to create request: %v", err)
|
||||
return result
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Add custom headers
|
||||
for key, value := range target.Headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
|
||||
// Add secret if configured (for signature verification)
|
||||
if target.Secret != "" {
|
||||
// TODO: Implement HMAC signature
|
||||
req.Header.Set("X-Webhook-Secret", target.Secret)
|
||||
}
|
||||
|
||||
// Send request
|
||||
resp, err := dc.httpClient.Do(req)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("request failed: %v", err)
|
||||
return result
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read response body
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
// Check status code
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
result.Error = fmt.Sprintf("webhook returned status %d: %s", resp.StatusCode, string(body))
|
||||
return result
|
||||
}
|
||||
|
||||
result.Success = true
|
||||
result.Details = map[string]interface{}{
|
||||
"status_code": resp.StatusCode,
|
||||
"response": string(body),
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// callProcess calls a Yao Process with delivery content
|
||||
func (dc *DeliveryCenter) callProcess(
|
||||
ctx context.Context,
|
||||
content *robottypes.DeliveryContent,
|
||||
target robottypes.ProcessTarget,
|
||||
deliveryCtx *robottypes.DeliveryContext,
|
||||
) robottypes.ChannelResult {
|
||||
now := time.Now()
|
||||
result := robottypes.ChannelResult{
|
||||
Type: robottypes.DeliveryProcess,
|
||||
Target: target.Process,
|
||||
SentAt: &now,
|
||||
}
|
||||
|
||||
// Build args: DeliveryContent as first arg, then additional args
|
||||
args := make([]interface{}, 0, 1+len(target.Args))
|
||||
args = append(args, map[string]interface{}{
|
||||
"content": map[string]interface{}{
|
||||
"summary": content.Summary,
|
||||
"body": content.Body,
|
||||
"attachments": content.Attachments,
|
||||
},
|
||||
"context": map[string]interface{}{
|
||||
"execution_id": deliveryCtx.ExecutionID,
|
||||
"member_id": deliveryCtx.MemberID,
|
||||
"team_id": deliveryCtx.TeamID,
|
||||
"trigger_type": deliveryCtx.TriggerType,
|
||||
},
|
||||
})
|
||||
args = append(args, target.Args...)
|
||||
|
||||
// Create and execute process
|
||||
proc, err := process.Of(target.Process, args...)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("failed to create process: %v", err)
|
||||
return result
|
||||
}
|
||||
proc.Context = ctx
|
||||
|
||||
err = proc.Execute()
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
return result
|
||||
}
|
||||
|
||||
result.Success = true
|
||||
result.Details = proc.Value
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// buildEmailSubject builds the email subject line
|
||||
func buildEmailSubject(subject, template string, content *robottypes.DeliveryContent, ctx *robottypes.DeliveryContext) string {
|
||||
// Use explicit subject if provided
|
||||
if subject != "" {
|
||||
return subject
|
||||
}
|
||||
|
||||
// Use template-based subject if template is specified
|
||||
// TODO: Implement template rendering
|
||||
if template != "" {
|
||||
return fmt.Sprintf("[Robot] %s", content.Summary)
|
||||
}
|
||||
|
||||
// Default: use summary
|
||||
if content.Summary != "" {
|
||||
return fmt.Sprintf("[Robot] %s", content.Summary)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("[Robot] Execution %s Complete", ctx.ExecutionID)
|
||||
}
|
||||
|
||||
// buildEmailBody builds the email body content
|
||||
func buildEmailBody(template string, content *robottypes.DeliveryContent) string {
|
||||
// TODO: Implement template rendering
|
||||
// For now, just use the body directly
|
||||
if content.Body != "" {
|
||||
return content.Body
|
||||
}
|
||||
return content.Summary
|
||||
}
|
||||
|
||||
// convertAttachments converts DeliveryAttachment to messenger Attachment format
|
||||
func convertAttachments(ctx context.Context, attachments []robottypes.DeliveryAttachment) []messengerTypes.Attachment {
|
||||
if len(attachments) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make([]messengerTypes.Attachment, 0, len(attachments))
|
||||
|
||||
for _, att := range attachments {
|
||||
// Parse file wrapper: __<uploader>://<fileID>
|
||||
uploader, fileID, isWrapper := attachment.Parse(att.File)
|
||||
if !isWrapper {
|
||||
// Skip non-wrapper attachments
|
||||
continue
|
||||
}
|
||||
|
||||
// Get file info from attachment manager
|
||||
manager, ok := attachment.Managers[uploader]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := manager.Info(ctx, fileID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Read file content
|
||||
content, err := manager.Read(ctx, fileID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build messenger attachment
|
||||
msgAtt := messengerTypes.Attachment{
|
||||
Filename: info.Filename,
|
||||
ContentType: info.ContentType,
|
||||
Content: content,
|
||||
}
|
||||
|
||||
result = append(result, msgAtt)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
1174
agent/robot/executor/standard/delivery_test.go
Normal file
1174
agent/robot/executor/standard/delivery_test.go
Normal file
File diff suppressed because it is too large
Load diff
35
agent/robot/types/config_global.go
Normal file
35
agent/robot/types/config_global.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package types
|
||||
|
||||
import "sync"
|
||||
|
||||
// Global configuration for robot agent
|
||||
// These values can be set during agent initialization
|
||||
|
||||
var (
|
||||
// defaultEmailChannel - default messenger channel name for sending emails
|
||||
// Can be configured via SetDefaultEmailChannel()
|
||||
// Default: "email" (maps to messengers/channels.yao configuration)
|
||||
defaultEmailChannel = "email"
|
||||
|
||||
// configMu protects global configuration
|
||||
configMu sync.RWMutex
|
||||
)
|
||||
|
||||
// DefaultEmailChannel returns the default email channel name
|
||||
func DefaultEmailChannel() string {
|
||||
configMu.RLock()
|
||||
defer configMu.RUnlock()
|
||||
return defaultEmailChannel
|
||||
}
|
||||
|
||||
// SetDefaultEmailChannel sets the default messenger channel for email delivery
|
||||
// This should be called during agent initialization
|
||||
// The channel name must match a channel defined in messengers/channels.yao
|
||||
func SetDefaultEmailChannel(channel string) {
|
||||
if channel == "" {
|
||||
return
|
||||
}
|
||||
configMu.Lock()
|
||||
defer configMu.Unlock()
|
||||
defaultEmailChannel = channel
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ type Robot struct {
|
|||
SystemPrompt string `json:"system_prompt"`
|
||||
Status RobotStatus `json:"robot_status"`
|
||||
AutonomousMode bool `json:"autonomous_mode"`
|
||||
RobotEmail string `json:"robot_email"` // Robot's email address for sending emails
|
||||
|
||||
// Parsed config (from robot_config JSON field)
|
||||
Config *Config `json:"-"`
|
||||
|
|
@ -317,7 +318,6 @@ type EmailPreference struct {
|
|||
// EmailTarget - Single email target
|
||||
type EmailTarget struct {
|
||||
To []string `json:"to"` // Recipient addresses
|
||||
CC []string `json:"cc,omitempty"` // CC addresses
|
||||
Template string `json:"template,omitempty"` // Email template ID
|
||||
Subject string `json:"subject,omitempty"` // Subject template
|
||||
}
|
||||
|
|
@ -383,6 +383,7 @@ func NewRobotFromMap(m map[string]interface{}) (*Robot, error) {
|
|||
DisplayName: getString(m, "display_name"),
|
||||
SystemPrompt: getString(m, "system_prompt"),
|
||||
AutonomousMode: getBool(m, "autonomous_mode"),
|
||||
RobotEmail: getString(m, "robot_email"),
|
||||
}
|
||||
|
||||
// Parse robot_status
|
||||
|
|
|
|||
|
|
@ -648,7 +648,6 @@ func TestDeliveryPreferencesStructure(t *testing.T) {
|
|||
Targets: []types.EmailTarget{
|
||||
{
|
||||
To: []string{"team@example.com"},
|
||||
CC: []string{"manager@example.com"},
|
||||
Template: "weekly-report",
|
||||
Subject: "Weekly Report - {{.Date}}",
|
||||
},
|
||||
|
|
@ -686,7 +685,6 @@ func TestDeliveryPreferencesStructure(t *testing.T) {
|
|||
assert.Len(t, prefs.Email.Targets, 2)
|
||||
assert.Equal(t, "weekly-report", prefs.Email.Targets[0].Template)
|
||||
assert.Len(t, prefs.Email.Targets[0].To, 1)
|
||||
assert.Len(t, prefs.Email.Targets[0].CC, 1)
|
||||
|
||||
// Webhook
|
||||
assert.True(t, prefs.Webhook.Enabled)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue