Initial commit: email-openclaw bridge v1
Full pipeline: IMAP ingress -> OpenClaw dispatch -> callback -> SMTP reply. SQLite stateful storage with idempotency, threading, and retry logic. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
package mail
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"thuanle.me/claw-email-bridge/internal/config"
|
||||
"thuanle.me/claw-email-bridge/internal/database"
|
||||
"thuanle.me/claw-email-bridge/internal/logging"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Retry backoff schedule: 1s, 5s, 15s.
|
||||
var smtpRetryBackoffs = []time.Duration{1 * time.Second, 5 * time.Second, 15 * time.Second}
|
||||
|
||||
// SMTPSender sends reply emails via SMTP with retry logic.
|
||||
type SMTPSender struct {
|
||||
cfg *config.Config
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewSMTPSender creates a new SMTP sender.
|
||||
func NewSMTPSender(cfg *config.Config, db *gorm.DB) *SMTPSender {
|
||||
return &SMTPSender{cfg: cfg, db: db}
|
||||
}
|
||||
|
||||
// SendReply sends an AI-generated reply to the original sender.
|
||||
// This should be called asynchronously (go sender.SendReply(task)).
|
||||
func (s *SMTPSender) SendReply(task *database.Task) {
|
||||
log := logging.TaskLogger(task.TaskUUID, task.ThreadID, task.MessageID)
|
||||
|
||||
// Build the email message with threading headers.
|
||||
msg := s.buildMessage(task)
|
||||
|
||||
for attempt := 1; attempt <= len(smtpRetryBackoffs); attempt++ {
|
||||
task.AttemptSMTP = attempt
|
||||
|
||||
log.Info("smtp: sending reply",
|
||||
"attempt", attempt,
|
||||
"to", task.Sender,
|
||||
)
|
||||
|
||||
err := s.send(task.Sender, msg)
|
||||
if err == nil {
|
||||
task.Status = database.StatusCompleted
|
||||
if err := database.UpdateTask(s.db, task); err != nil {
|
||||
log.Error("smtp: failed to save COMPLETED status", "error", err)
|
||||
}
|
||||
log.Info("smtp: reply sent successfully",
|
||||
"attempt", attempt,
|
||||
"status", database.StatusCompleted,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
log.Warn("smtp: send failed",
|
||||
"attempt", attempt,
|
||||
"error", err,
|
||||
)
|
||||
|
||||
// Update status to SMTP_RETRYING.
|
||||
task.Status = database.StatusSMTPRetrying
|
||||
if err := database.UpdateTask(s.db, task); err != nil {
|
||||
log.Error("smtp: failed to save retry status", "error", err)
|
||||
}
|
||||
|
||||
// Last attempt — mark as FAILED.
|
||||
if attempt == len(smtpRetryBackoffs) {
|
||||
errMsg := err.Error()
|
||||
task.Status = database.StatusFailed
|
||||
task.LastError = &errMsg
|
||||
if err := database.UpdateTask(s.db, task); err != nil {
|
||||
log.Error("smtp: failed to save FAILED status", "error", err)
|
||||
}
|
||||
log.Error("smtp: all retries exhausted",
|
||||
"attempt", attempt,
|
||||
"status", database.StatusFailed,
|
||||
"error", errMsg,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
backoff := smtpRetryBackoffs[attempt-1]
|
||||
log.Info("smtp: retrying after backoff", "backoff", backoff)
|
||||
time.Sleep(backoff)
|
||||
}
|
||||
}
|
||||
|
||||
// buildMessage constructs the RFC 2822 email with threading headers.
|
||||
func (s *SMTPSender) buildMessage(task *database.Task) string {
|
||||
subject := task.Subject
|
||||
if !strings.HasPrefix(strings.ToLower(subject), "re:") {
|
||||
subject = "Re: " + subject
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("From: %s\r\n", s.cfg.SystemEmail))
|
||||
b.WriteString(fmt.Sprintf("To: %s\r\n", task.Sender))
|
||||
b.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
|
||||
|
||||
// Threading headers — required by requirements 4.3.
|
||||
b.WriteString(fmt.Sprintf("In-Reply-To: %s\r\n", task.MessageID))
|
||||
b.WriteString(fmt.Sprintf("References: %s\r\n", task.MessageID))
|
||||
|
||||
b.WriteString("MIME-Version: 1.0\r\n")
|
||||
b.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
||||
b.WriteString("\r\n")
|
||||
b.WriteString(task.AIResponse)
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// send performs the actual SMTP delivery.
|
||||
func (s *SMTPSender) send(to string, msg string) error {
|
||||
addr := fmt.Sprintf("%s:%s", s.cfg.SMTPHost, s.cfg.SMTPPort)
|
||||
|
||||
auth := smtp.PlainAuth("", s.cfg.SMTPUser, s.cfg.SMTPPass, s.cfg.SMTPHost)
|
||||
|
||||
err := smtp.SendMail(addr, auth, s.cfg.SystemEmail, []string{to}, []byte(msg))
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp sendmail: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendReplyFunc returns a function compatible with api.OnCallbackDone.
|
||||
func (s *SMTPSender) SendReplyFunc() func(task *database.Task) {
|
||||
return func(task *database.Task) {
|
||||
s.SendReply(task)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user