diff --git a/README.md b/README.md index 0805370..6370013 100644 --- a/README.md +++ b/README.md @@ -511,6 +511,23 @@ Connect to IRC networks: ``` +
+Email + +```json +{ + "smtpServer": "smtp.gmail.com:587", + "imapServer": "imap.gmail.com:993", + "smtpInsecure": "false", + "imapInsecure": "false", + "username": "user@gmail.com", + "email": "user@gmail.com", + "password": "correct-horse-battery-staple", + "name": "LogalAGI Agent" +} +``` +
+ ## REST API
diff --git a/go.mod b/go.mod index 02c1206..4497961 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,10 @@ require ( mvdan.cc/xurls/v2 v2.6.0 ) +require github.com/JohannesKaufmann/dom v0.2.0 // indirect + require ( + github.com/JohannesKaufmann/html-to-markdown/v2 v2.3.2 github.com/PuerkitoBio/goquery v1.10.3 // indirect github.com/andybalholm/brotli v1.1.1 // indirect github.com/andybalholm/cascadia v1.3.3 // indirect @@ -45,6 +48,10 @@ require ( github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/cloudwego/base64x v0.1.5 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/emersion/go-imap/v2 v2.0.0-beta.5 // indirect + github.com/emersion/go-message v0.18.2 // indirect + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect + github.com/emersion/go-smtp v0.22.0 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-gonic/gin v1.10.0 // indirect @@ -60,6 +67,7 @@ require ( github.com/gofiber/utils v1.1.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/protobuf v1.5.4 // indirect + github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/pprof v0.0.0-20250423184734-337e5dd93bb4 // indirect diff --git a/go.sum b/go.sum index 5cb36cb..26cfd04 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/JohannesKaufmann/dom v0.2.0 h1:1bragmEb19K8lHAqgFgqCpiPCFEZMTXzOIEjuxkUfLQ= +github.com/JohannesKaufmann/dom v0.2.0/go.mod h1:57iSUl5RKric4bUkgos4zu6Xt5LMHUnw3TF1l5CbGZo= +github.com/JohannesKaufmann/html-to-markdown/v2 v2.3.2 h1:eeMLttqTjTgILD6no79Ge96V7Wv8pWDfMVn4jy+koIY= +github.com/JohannesKaufmann/html-to-markdown/v2 v2.3.2/go.mod h1:HtsP+1Fchp4dVvaiIsLHAl/yqL3H1YLwqLC9kNwqQEg= github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= @@ -37,6 +41,19 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/donseba/go-htmx v1.12.0 h1:7tESER0uxaqsuGMv3yP3pK1drfBUXM6apG4H7/3+IgE= github.com/donseba/go-htmx v1.12.0/go.mod h1:8PTAYvNKf8+QYis+DpAsggKz+sa2qljtMgvdAeNBh5s= +github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA= +github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY= +github.com/emersion/go-imap/v2 v2.0.0-beta.5 h1:H3858DNmBuXyMK1++YrQIRdpKE1MwBc+ywBtg3n+0wA= +github.com/emersion/go-imap/v2 v2.0.0-beta.5/go.mod h1:BZTFHsS1hmgBkFlHqbxGLXk2hnRqTItUgwjSSCsYNAk= +github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= +github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= +github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-smtp v0.22.0 h1:/d3HWxkZZ4riB+0kzfoODh9X+xyCrLEezMnAAa1LEMU= +github.com/emersion/go-smtp v0.22.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ= +github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= github.com/eritikass/githubmarkdownconvertergo v0.1.10 h1:mL93ADvYMOeT15DcGtK9AaFFc+RcWcy6kQBC6yS/5f4= github.com/eritikass/githubmarkdownconvertergo v0.1.10/go.mod h1:BdpHs6imOtzE5KorbUtKa6bZ0ZBh1yFcrTTAL8FwDKY= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= @@ -83,6 +100,8 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b h1:EY/KpStFl60qA17CptGXhwfZ+k1sFNJIUNR8DdbcuUk= +github.com/gomarkdown/markdown v0.0.0-20250311123330-531bef5e742b/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= diff --git a/services/connectors.go b/services/connectors.go index db9b262..1a5ddd8 100644 --- a/services/connectors.go +++ b/services/connectors.go @@ -20,6 +20,7 @@ const ( ConnectorGithubPRs = "github-prs" ConnectorTwitter = "twitter" ConnectorMatrix = "matrix" + ConnectorEmail = "email" ) var AvailableConnectors = []string{ @@ -31,6 +32,7 @@ var AvailableConnectors = []string{ ConnectorGithubPRs, ConnectorTwitter, ConnectorMatrix, + ConnectorEmail, } func Connectors(a *state.AgentConfig) []state.Connector { @@ -70,6 +72,8 @@ func Connectors(a *state.AgentConfig) []state.Connector { conns = append(conns, cc) case ConnectorMatrix: conns = append(conns, connectors.NewMatrix(config)) + case ConnectorEmail: + conns = append(conns, connectors.NewEmail(config)) } } return conns @@ -117,5 +121,10 @@ func ConnectorsConfigMeta() []config.FieldGroup { Label: "Matrix", Fields: connectors.MatrixConfigMeta(), }, + { + Name: "email", + Label: "Email", + Fields: connectors.EmailConfigMeta(), + }, } } diff --git a/services/connectors/email.go b/services/connectors/email.go new file mode 100644 index 0000000..711b530 --- /dev/null +++ b/services/connectors/email.go @@ -0,0 +1,424 @@ +package connectors + +import ( + "bytes" + "fmt" + "mime" + "strings" + "time" + + htmltomarkdown "github.com/JohannesKaufmann/html-to-markdown/v2" + imap "github.com/emersion/go-imap/v2" + sasl "github.com/emersion/go-sasl" + smtp "github.com/emersion/go-smtp" + + "github.com/emersion/go-imap/v2/imapclient" + "github.com/emersion/go-message" + "github.com/emersion/go-message/charset" + "github.com/gomarkdown/markdown" + "github.com/gomarkdown/markdown/html" + "github.com/gomarkdown/markdown/parser" + + "github.com/mudler/LocalAGI/core/agent" + "github.com/mudler/LocalAGI/core/types" + "github.com/mudler/LocalAGI/pkg/config" + "github.com/mudler/LocalAGI/pkg/xlog" + "github.com/sashabaranov/go-openai" +) + +type Email struct { + username string + name string + password string + email string + smtpServer string + smtpInsecure bool + imapServer string + imapInsecure bool +} + +func NewEmail(config map[string]string) *Email { + + return &Email{ + username: config["username"], + name: config["name"], + password: config["password"], + email: config["email"], + smtpServer: config["smtpServer"], + smtpInsecure: config["smtpInsecure"] == "true", + imapServer: config["imapServer"], + imapInsecure: config["imapInsecure"] == "true", + } +} + +func EmailConfigMeta() []config.Field { + return []config.Field{ + { + Name: "smtpServer", + Label: "SMTP Host:port", + Type: config.FieldTypeText, + Required: true, + HelpText: "SMTP server host:port (e.g., smtp.gmail.com:587)", + }, + { + Name: "smtpInsecure", + Label: "Insecure SMTP", + Type: config.FieldTypeCheckbox, + }, + { + Name: "imapServer", + Label: "IMAP Host:port", + Type: config.FieldTypeText, + Required: true, + HelpText: "IMAP server host:port (e.g., imap.gmail.com:993)", + }, + { + Name: "imapInsecure", + Label: "Insecure IMAP", + Type: config.FieldTypeCheckbox, + }, + { + Name: "username", + Label: "Username", + Type: config.FieldTypeText, + Required: true, + HelpText: "Username/email address", + }, + { + Name: "name", + Label: "Friendly Name", + Type: config.FieldTypeText, + Required: true, + HelpText: "Friendly name of sender", + }, + { + Name: "password", + Label: "Password", + Type: config.FieldTypeText, + Required: true, + HelpText: "SMTP/IMAP password or app password", + }, + { + Name: "email", + Label: "From Email", + Type: config.FieldTypeText, + Required: true, + HelpText: "Agent email address", + }, + } +} + +func (e *Email) AgentResultCallback() func(state types.ActionState) { + return func(state types.ActionState) { + // Send the result to the bot + } +} + +func (e *Email) AgentReasoningCallback() func(state types.ActionCurrentState) bool { + return func(state types.ActionCurrentState) bool { + // Send the reasoning to the bot + return true + } +} + +func filterEmailRecipients(input string, emailToRemove string) string { + + addresses := strings.Split(strings.TrimPrefix(input, "To: "), ",") + + var filtered []string + for _, address := range addresses { + address = strings.TrimSpace(address) + if !strings.Contains(address, emailToRemove) { + filtered = append(filtered, address) + } + } + + if len(filtered) > 0 { + return strings.Join(filtered, ", ") + } + return "" +} + +func (e *Email) sendMail(to, subject, content, replyToID, references string, emails []string, html bool) { + + auth := sasl.NewPlainClient("", e.username, e.password) + + contentType := "text/plain" + if html { + contentType = "text/html" + } + + var replyHeaders string + if replyToID != "" { + referenceLine := strings.ReplaceAll(references+" "+replyToID, "\n", "") + replyHeaders = fmt.Sprintf("In-Reply-To: %s\r\nReferences: %s\r\n", replyToID, referenceLine) + } + + // Build full message content + var builder strings.Builder + fmt.Fprintf(&builder, "To: %s\r\n", to) + fmt.Fprintf(&builder, "From: %s <%s>\r\n", e.name, e.email) + builder.WriteString(replyHeaders) + fmt.Fprintf(&builder, "MIME-Version: 1.0\r\nContent-Type: %s;\r\n", contentType) + fmt.Fprintf(&builder, "Subject: %s\r\n\r\n", subject) + fmt.Fprintf(&builder, "%s\r\n", content) + msg := strings.NewReader(builder.String()) + + if !e.smtpInsecure { + + err := smtp.SendMail(e.smtpServer, auth, e.email, emails, msg) + if err != nil { + xlog.Error(fmt.Sprintf("Email send err: %v", err)) + } + + } else { + + c, err := smtp.Dial(e.smtpServer) + if err != nil { + xlog.Error(fmt.Sprintf("Email connection err: %v", err)) + } + defer c.Close() + + err = c.Hello("client") + if err != nil { + xlog.Error(fmt.Sprintf("Email hello err: %v", err)) + } + + err = c.Auth(auth) + if err != nil { + xlog.Error(fmt.Sprintf("Email auth err: %v", err)) + } + + err = c.SendMail(e.email, emails, msg) + if err != nil { + xlog.Error(fmt.Sprintf("Email send err: %v", err)) + } + + } +} + +func imapWorker(done chan bool, e *Email, a *agent.Agent, c *imapclient.Client, startIndex uint32) { + + currentIndex := startIndex + + for { + select { + case <-done: + + xlog.Info("Stopping imapWorker") + err := c.Logout().Wait() + if err != nil { + xlog.Error(fmt.Sprintf("Email IMAP logout fail: %v", err)) + } + return + + default: + + selectedMbox, err := c.Select("INBOX", nil).Wait() + if err != nil { + xlog.Error(fmt.Sprintf("Email IMAP mailbox err: %v", err)) + } + + // Loop over any new messages recieved in selected mailbox + for currentIndex < selectedMbox.NumMessages { + + currentIndex++ + + // Download email info + seqSet := imap.SeqSetNum(currentIndex) + bodySection := &imap.FetchItemBodySection{} + fetchOptions := &imap.FetchOptions{ + Flags: true, + Envelope: true, + BodySection: []*imap.FetchItemBodySection{bodySection}, + } + messageBuffers, err := c.Fetch(seqSet, fetchOptions).Collect() + if err != nil { + xlog.Error(fmt.Sprintf("Email IMAP fetch err: %v", err)) + } + + // Start conversation goroutine + go func(e *Email, a *agent.Agent, c *imapclient.Client, fmb *imapclient.FetchMessageBuffer) { + + // Download Email contents + r := bytes.NewReader(fmb.FindBodySection(bodySection)) + msg, err := message.Read(r) + if err != nil { + xlog.Error(fmt.Sprintf("Email reader err: %v", err)) + } + buf := new(bytes.Buffer) + buf.ReadFrom(msg.Body) + + xlog.Debug("New email!") + xlog.Debug(fmt.Sprintf("From: %s", msg.Header.Get("From"))) + xlog.Debug(fmt.Sprintf("To: %s", msg.Header.Get("To"))) + xlog.Debug(fmt.Sprintf("Subject: %s", msg.Header.Get("Subject"))) + + // In the event that an email account has multiple email addresses, only respond to the one configured + if !strings.Contains(msg.Header.Get("To"), e.email) { + xlog.Info(fmt.Sprintf("Email was sent to %s, but appeared in my inbox (%s). Ignoring!", msg.Header.Get("To"), e.email)) + return + } + + content := buf.String() + contentIsHTML := false + + // Convert email to markdown only if it's in HTML + prefixes := []string{" md err: %v", err)) + contentIsHTML = false + content = buf.String() + } + } + } + + xlog.Debug(fmt.Sprintf("Markdown:\n\n%s", content)) + + // Construct prompt + prompt := fmt.Sprintf("%s %s:\n\nFrom: %s\nTime: %s\nSubject: %s\n=====\n%s", + "This email thread was sent to you. You are", + e.email, + msg.Header.Get("From"), + fmb.Envelope.Date.Format(time.RFC3339), + fmb.Envelope.Subject, + content, + ) + conv := []openai.ChatCompletionMessage{} + conv = append(conv, openai.ChatCompletionMessage{Role: "user", Content: prompt}) + + // Send prompt to agent and wait for result + xlog.Debug(fmt.Sprintf("Starting conversation:\n\n%v", conv)) + jobResult := a.Ask(types.WithConversationHistory(conv)) + if jobResult.Error != nil { + xlog.Error(fmt.Sprintf("Error asking agent: %v", jobResult.Error)) + } + + // Send agent response to user, replying to original email. + xlog.Debug("Agent finished responding. Sending reply email to user") + + // Get a list of emails to respond to ("Reply All" logic) + // This could be done through regex, but it's probably safer to rebuild explicitly + fromEmail := fmt.Sprintf("%s@%s", fmb.Envelope.From[0].Mailbox, fmb.Envelope.From[0].Host) + emails := []string{} + emails = append(emails, fromEmail) + + for _, addr := range fmb.Envelope.To { + if addr.Mailbox != "" && addr.Host != "" { + email := fmt.Sprintf("%s@%s", addr.Mailbox, addr.Host) + if email != e.email { + emails = append(emails, email) + } + } + } + + // Keep the original header, in case sender had contact names as part of the header + newToHeader := msg.Header.Get("From") + ", " + filterEmailRecipients(msg.Header.Get("To"), e.email) + + // Create the body of the email + replyContent := jobResult.Response + if jobResult.Response == "" { + replyContent = + "System: I'm sorry, but it looks like the agent did not respond. " + + "This could be in error, or maybe it had nothing to say." + } + + // Quote the original message. This lets the agent see conversation history and is an email standard. + quoteHeader := fmt.Sprintf("\r\n\r\nOn %s, %s wrote:\n", + fmb.Envelope.Date.Format("Monday, Jan 2, 2006 at 15:04"), + fmt.Sprintf("%s <%s>", fmb.Envelope.From[0].Name, fromEmail), + ) + quotedLines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") + for i, line := range quotedLines { + quotedLines[i] = "> " + line + } + replyContent = replyContent + quoteHeader + strings.Join(quotedLines, "\r\n") + + // If the original email was sent in HTML, reply with HTML + if contentIsHTML { + p := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock) + doc := p.Parse([]byte(replyContent)) + + opts := html.RendererOptions{Flags: html.CommonFlags | html.HrefTargetBlank | html.CompletePage} + renderer := html.NewRenderer(opts) + + replyContent = string(markdown.Render(doc, renderer)) + } + + // Send the email + e.sendMail(newToHeader, + fmt.Sprintf("Re: %s", msg.Header.Get("Subject")), + replyContent, + msg.Header.Get("Message-ID"), + msg.Header.Get("References"), + emails, + contentIsHTML, + ) + }(e, a, c, messageBuffers[0]) + } + time.Sleep(5 * time.Second) // Refresh inbox every n seconds + } + } +} + +func (e *Email) Start(a *agent.Agent) { + go func() { + + xlog.Info("Email connector is now running. Press CTRL-C to exit.") + // IMAP dial + imapOpts := &imapclient.Options{WordDecoder: &mime.WordDecoder{CharsetReader: charset.Reader}} + var c *imapclient.Client + var err error + if e.imapInsecure { + c, err = imapclient.DialInsecure(e.imapServer, imapOpts) + } else { + c, err = imapclient.DialTLS(e.imapServer, imapOpts) + } + + if err != nil { + xlog.Error(fmt.Sprintf("Email IMAP dial err: %v", err)) + return + } + defer c.Close() + + // IMAP login + err = c.Login(e.username, e.password).Wait() + if err != nil { + xlog.Error(fmt.Sprintf("Email IMAP login err: %v", err)) + return + } + + // IMAP mailbox + mailboxes, err := c.List("", "%", nil).Collect() + if err != nil { + xlog.Error(fmt.Sprintf("Email IMAP mailbox err: %v", err)) + return + } + + xlog.Debug(fmt.Sprintf("Email IMAP mailbox count: %v", len(mailboxes))) + for _, mbox := range mailboxes { + xlog.Debug(fmt.Sprintf(" - %v", mbox.Mailbox)) + } + + // Select INBOX + selectedMbox, err := c.Select("INBOX", nil).Wait() + if err != nil { + xlog.Error(fmt.Sprintf("Cannot select INBOX mailbox! %v", err)) + return + } + xlog.Debug(fmt.Sprintf("INBOX contains %v messages", selectedMbox.NumMessages)) + + // Start checking INBOX for new mail + imapWorkerHandle := make(chan bool) + go imapWorker(imapWorkerHandle, e, a, c, selectedMbox.NumMessages) + + <-a.Context().Done() + imapWorkerHandle <- true + xlog.Info("Email connector is now stopped.") + + }() +}