From f14cccba6ecb2ca06d7b95fe3adcdc26e1f1b8bd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 13:51:38 +0000 Subject: [PATCH 1/3] Initial plan From 2554b9f9d72619ff9bc94d604126ce0ee4f7f9cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:02:08 +0000 Subject: [PATCH 2/3] feat: Phase 4 email assistant (IMAP/SMTP) Agent-Logs-Url: https://github.com/Kritarth-Dandapat/super-ollama/sessions/e08ab9f9-16ea-4242-b398-db9eef4ba004 Co-authored-by: Kritarth-Dandapat <141005022+Kritarth-Dandapat@users.noreply.github.com> --- cmd/super-ollama/email.go | 505 +++++++++++++++++++++++++++++++++++++ cmd/super-ollama/main.go | 2 +- go.mod | 8 + go.sum | 38 +++ internal/mail/body.go | 78 ++++++ internal/mail/config.go | 187 ++++++++++++++ internal/mail/imap.go | 201 +++++++++++++++ internal/mail/mail_test.go | 332 ++++++++++++++++++++++++ internal/mail/prompt.go | 135 ++++++++++ internal/mail/smtp.go | 88 +++++++ internal/mail/store.go | 235 +++++++++++++++++ 11 files changed, 1808 insertions(+), 1 deletion(-) create mode 100644 cmd/super-ollama/email.go create mode 100644 internal/mail/body.go create mode 100644 internal/mail/config.go create mode 100644 internal/mail/imap.go create mode 100644 internal/mail/mail_test.go create mode 100644 internal/mail/prompt.go create mode 100644 internal/mail/smtp.go create mode 100644 internal/mail/store.go diff --git a/cmd/super-ollama/email.go b/cmd/super-ollama/email.go new file mode 100644 index 00000000000..440dcf9839b --- /dev/null +++ b/cmd/super-ollama/email.go @@ -0,0 +1,505 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/ollama/ollama/internal/engine" + "github.com/ollama/ollama/internal/mail" + "github.com/ollama/ollama/internal/ui" +) + +// newEmailCmd builds the "email" parent command with all sub-commands. +func newEmailCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "email", + Short: "Email assistant — compose, reply, summarise, list, read, send", + } + cmd.AddCommand( + newEmailSetupCmd(), + newEmailListCmd(), + newEmailReadCmd(), + newEmailComposeCmd(), + newEmailReplyCmd(), + newEmailSummariseCmd(), + newEmailSendCmd(), + ) + return cmd +} + +// ── setup ────────────────────────────────────────────────────────────────── + +func newEmailSetupCmd() *cobra.Command { + return &cobra.Command{ + Use: "setup", + Short: "Store IMAP/SMTP passwords in the OS keyring", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := mail.LoadConfig() + if err != nil { + return err + } + + imapPw, err := readPassword(fmt.Sprintf("IMAP password for %s: ", cfg.IMAP.Username)) + if err != nil { + return err + } + if err := mail.SetIMAPPassword(cfg.IMAP.Username, imapPw); err != nil { + return fmt.Errorf("save imap password: %w", err) + } + + smtpPw, err := readPassword(fmt.Sprintf("SMTP password for %s: ", cfg.SMTP.Username)) + if err != nil { + return err + } + if err := mail.SetSMTPPassword(cfg.SMTP.Username, smtpPw); err != nil { + return fmt.Errorf("save smtp password: %w", err) + } + + ui.Println("Credentials saved to OS keyring.") + return nil + }, + } +} + +// ── list ─────────────────────────────────────────────────────────────────── + +func newEmailListCmd() *cobra.Command { + var unread bool + var n int + + cmd := &cobra.Command{ + Use: "list", + Short: "List recent emails from INBOX", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := mail.LoadConfig() + if err != nil { + return err + } + pw, err := mail.GetIMAPPassword(cfg.IMAP.Username) + if err != nil { + return err + } + + headers, err := mail.ListMessages(cfg.IMAP, pw, n, unread) + if err != nil { + return err + } + if len(headers) == 0 { + ui.Println("No messages found.") + return nil + } + ui.Printf("%-6s %-30s %-40s %s\n", "UID", "From", "Subject", "Date") + ui.Println(strings.Repeat("-", 100)) + for _, h := range headers { + flag := " " + if !h.Seen { + flag = "*" + } + from := truncate(h.From, 28) + subj := truncate(h.Subject, 38) + date := h.Date.Local().Format("2006-01-02 15:04") + ui.Printf("%s%-5d %-30s %-40s %s\n", flag, h.UID, from, subj, date) + } + return nil + }, + } + cmd.Flags().BoolVar(&unread, "unread", false, "show unread messages only") + cmd.Flags().IntVarP(&n, "n", "n", 20, "number of messages to fetch") + return cmd +} + +// ── read ─────────────────────────────────────────────────────────────────── + +func newEmailReadCmd() *cobra.Command { + var messageID string + + cmd := &cobra.Command{ + Use: "read", + Short: "Display the full body of a message", + RunE: func(cmd *cobra.Command, args []string) error { + uid, err := parseUID(messageID) + if err != nil { + return err + } + cfg, err := mail.LoadConfig() + if err != nil { + return err + } + pw, err := mail.GetIMAPPassword(cfg.IMAP.Username) + if err != nil { + return err + } + + msg, err := mail.ReadMessage(cfg.IMAP, pw, uid) + if err != nil { + return err + } + ui.Printf("From: %s\n", msg.From) + ui.Printf("Subject: %s\n", msg.Subject) + ui.Printf("Date: %s\n", msg.Date.Local().Format(time.RFC1123)) + ui.Println(strings.Repeat("-", 60)) + ui.Println(msg.Body) + return nil + }, + } + cmd.Flags().StringVar(&messageID, "message-id", "", "IMAP UID of the message (required)") + _ = cmd.MarkFlagRequired("message-id") + return cmd +} + +// ── compose ──────────────────────────────────────────────────────────────── + +func newEmailComposeCmd() *cobra.Command { + var to, subject, tone string + var draft bool + + cmd := &cobra.Command{ + Use: "compose", + Short: "AI-compose an email draft", + RunE: func(cmd *cobra.Command, args []string) error { + ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + model, err := resolveModel() + if err != nil { + return err + } + + sysPrompt, _ := mail.LoadEmailSystemPrompt() + profile, _ := mail.LoadProfile() + contactNotes, _ := mail.LoadContactNotes(to) + + prompt := mail.BuildComposePrompt(mail.ComposePromptOptions{ + SystemPrompt: sysPrompt, + Profile: profile, + ContactNotes: contactNotes, + To: to, + Subject: subject, + Tone: mail.ToneHint(tone), + }) + + eng, err := engine.New(context.Background()) + if err != nil { + return err + } + defer eng.Close() + + body, err := eng.Generate(ctx, model, prompt, nil) + if err != nil { + return err + } + + ui.Println("\n── Draft ─────────────────────────────────────────────") + ui.Printf("To: %s\n", to) + ui.Printf("Subject: %s\n", subject) + ui.Println(strings.Repeat("-", 60)) + ui.Println(body) + ui.Println(strings.Repeat("-", 60)) + + if draft { + store, err := mail.OpenStore("") + if err != nil { + return fmt.Errorf("open mail store: %w", err) + } + defer store.Close() + + id, err := store.SaveDraft(mail.Draft{ + CreatedAt: time.Now(), + To: to, + Subject: subject, + Body: body, + }) + if err != nil { + return fmt.Errorf("save draft: %w", err) + } + ui.Printf("\nDraft saved with ID %d. Send with: super-ollama email send --draft-id %d\n", id, id) + } + return nil + }, + } + cmd.Flags().StringVar(&to, "to", "", "recipient address (required)") + cmd.Flags().StringVar(&subject, "subject", "", "email subject (required)") + cmd.Flags().StringVar(&tone, "tone", "", "writing tone: formal|casual|brief") + cmd.Flags().BoolVar(&draft, "draft", false, "save the generated text as a draft") + _ = cmd.MarkFlagRequired("to") + _ = cmd.MarkFlagRequired("subject") + return cmd +} + +// ── reply ────────────────────────────────────────────────────────────────── + +func newEmailReplyCmd() *cobra.Command { + var messageID, tone string + + cmd := &cobra.Command{ + Use: "reply", + Short: "AI-compose a reply to an email", + RunE: func(cmd *cobra.Command, args []string) error { + ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + uid, err := parseUID(messageID) + if err != nil { + return err + } + + cfg, err := mail.LoadConfig() + if err != nil { + return err + } + imapPw, err := mail.GetIMAPPassword(cfg.IMAP.Username) + if err != nil { + return err + } + + orig, err := mail.ReadMessage(cfg.IMAP, imapPw, uid) + if err != nil { + return err + } + + model, err := resolveModel() + if err != nil { + return err + } + + sysPrompt, _ := mail.LoadEmailSystemPrompt() + profile, _ := mail.LoadProfile() + contactNotes, _ := mail.LoadContactNotes(orig.From) + + prompt := mail.BuildReplyPrompt(mail.ReplyPromptOptions{ + SystemPrompt: sysPrompt, + Profile: profile, + ContactNotes: contactNotes, + OriginalFrom: orig.From, + OriginalBody: orig.Body, + Tone: mail.ToneHint(tone), + }) + + eng, err := engine.New(context.Background()) + if err != nil { + return err + } + defer eng.Close() + + body, err := eng.Generate(ctx, model, prompt, nil) + if err != nil { + return err + } + + ui.Println("\n── Reply draft ───────────────────────────────────────") + ui.Printf("To: %s\n", orig.From) + ui.Printf("Subject: Re: %s\n", orig.Subject) + ui.Println(strings.Repeat("-", 60)) + ui.Println(body) + ui.Println(strings.Repeat("-", 60)) + + // Persist as draft automatically. + store, err := mail.OpenStore("") + if err != nil { + return fmt.Errorf("open mail store: %w", err) + } + defer store.Close() + + id, err := store.SaveDraft(mail.Draft{ + CreatedAt: time.Now(), + To: orig.From, + Subject: "Re: " + orig.Subject, + Body: body, + ReplyTo: messageID, + }) + if err != nil { + return fmt.Errorf("save draft: %w", err) + } + ui.Printf("\nDraft saved with ID %d. Send with: super-ollama email send --draft-id %d\n", id, id) + return nil + }, + } + cmd.Flags().StringVar(&messageID, "message-id", "", "IMAP UID of the message to reply to (required)") + cmd.Flags().StringVar(&tone, "tone", "", "writing tone: formal|casual|brief") + _ = cmd.MarkFlagRequired("message-id") + return cmd +} + +// ── summarise ────────────────────────────────────────────────────────────── + +func newEmailSummariseCmd() *cobra.Command { + var messageID string + + cmd := &cobra.Command{ + Use: "summarise", + Short: "AI-summarise an email", + RunE: func(cmd *cobra.Command, args []string) error { + ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + uid, err := parseUID(messageID) + if err != nil { + return err + } + + cfg, err := mail.LoadConfig() + if err != nil { + return err + } + pw, err := mail.GetIMAPPassword(cfg.IMAP.Username) + if err != nil { + return err + } + + msg, err := mail.ReadMessage(cfg.IMAP, pw, uid) + if err != nil { + return err + } + + model, err := resolveModel() + if err != nil { + return err + } + eng, err := engine.New(context.Background()) + if err != nil { + return err + } + defer eng.Close() + + prompt := mail.BuildSummarisePrompt(mail.SummarisePromptOptions{ + From: msg.From, + Subject: msg.Subject, + Body: msg.Body, + }) + summary, err := eng.Generate(ctx, model, prompt, nil) + if err != nil { + return err + } + + ui.Printf("From: %s\n", msg.From) + ui.Printf("Subject: %s\n", msg.Subject) + ui.Println() + ui.Println(summary) + return nil + }, + } + cmd.Flags().StringVar(&messageID, "message-id", "", "IMAP UID of the message (required)") + _ = cmd.MarkFlagRequired("message-id") + return cmd +} + +// ── send ─────────────────────────────────────────────────────────────────── + +func newEmailSendCmd() *cobra.Command { + var draftID int64 + var confirm bool + + cmd := &cobra.Command{ + Use: "send", + Short: "Send a saved draft via SMTP", + RunE: func(cmd *cobra.Command, args []string) error { + store, err := mail.OpenStore("") + if err != nil { + return fmt.Errorf("open mail store: %w", err) + } + defer store.Close() + + d, err := store.GetDraft(draftID) + if err != nil { + return err + } + + ui.Printf("To: %s\n", d.To) + ui.Printf("Subject: %s\n", d.Subject) + ui.Println(strings.Repeat("-", 60)) + ui.Println(d.Body) + ui.Println(strings.Repeat("-", 60)) + + if !confirm { + ui.Print("\nSend this email? [y/N] ") + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() || !strings.EqualFold(strings.TrimSpace(scanner.Text()), "y") { + ui.Println("Aborted.") + return nil + } + } + + cfg, err := mail.LoadConfig() + if err != nil { + return err + } + smtpPw, err := mail.GetSMTPPassword(cfg.SMTP.Username) + if err != nil { + return err + } + + if err := mail.SendMessage(cfg.SMTP, smtpPw, mail.SendOptions{ + To: []string{d.To}, + Subject: d.Subject, + Body: d.Body, + ReplyTo: d.ReplyTo, + }); err != nil { + return fmt.Errorf("send: %w", err) + } + + if err := store.DeleteDraft(draftID); err != nil { + ui.Eprintf("warning: could not delete draft %d from store: %v\n", draftID, err) + } + ui.Println("Email sent.") + return nil + }, + } + cmd.Flags().Int64Var(&draftID, "draft-id", 0, "ID of the draft to send (required)") + cmd.Flags().BoolVar(&confirm, "confirm", false, "skip the confirmation prompt") + _ = cmd.MarkFlagRequired("draft-id") + return cmd +} + +// ── helpers ──────────────────────────────────────────────────────────────── + +func parseUID(s string) (uint32, error) { + n, err := strconv.ParseUint(strings.TrimSpace(s), 10, 32) + if err != nil { + return 0, fmt.Errorf("invalid message-id %q: must be a positive integer UID", s) + } + if n == 0 { + return 0, fmt.Errorf("message-id must be > 0") + } + return uint32(n), nil +} + +func truncate(s string, maxLen int) string { + r := []rune(s) + if len(r) <= maxLen { + return s + } + return string(r[:maxLen-1]) + "…" +} + +// readPassword prompts on stderr and reads a password without echo. +// Falls back to a plain line read when not connected to a terminal. +func readPassword(prompt string) (string, error) { + ui.Eprint(prompt) + if term.IsTerminal(int(os.Stdin.Fd())) { + pw, err := term.ReadPassword(int(os.Stdin.Fd())) + ui.Eprintln() + if err != nil { + return "", err + } + return string(pw), nil + } + // Non-TTY fallback (e.g., piped input in tests). + scanner := bufio.NewScanner(os.Stdin) + if scanner.Scan() { + return strings.TrimSpace(scanner.Text()), nil + } + if err := scanner.Err(); err != nil { + return "", err + } + return "", fmt.Errorf("no password provided") +} diff --git a/cmd/super-ollama/main.go b/cmd/super-ollama/main.go index 3dc36df4b7b..fbcc54925bf 100644 --- a/cmd/super-ollama/main.go +++ b/cmd/super-ollama/main.go @@ -48,7 +48,7 @@ func main() { newHiddenRunnerCmd(), newAskCmd(), newChatCmd(), - newStubCmd("email", "Email assistant (coming in a later phase)"), + newEmailCmd(), newStubCmd("todo", "TODO manager (coming in a later phase)"), newStubCmd("snap", "Screenshot capture (coming in a later phase)"), newStubCmd("learn", "Learning-loop re-index (coming in a later phase)"), diff --git a/go.mod b/go.mod index 6949b147108..05e75f89e3a 100644 --- a/go.mod +++ b/go.mod @@ -39,6 +39,7 @@ require ( ) require ( + github.com/alessio/shellescape v1.4.1 // indirect github.com/apache/arrow/go/arrow v0.0.0-20211112161151-bc219186db40 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect @@ -52,8 +53,14 @@ require ( github.com/chewxy/math32 v1.11.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect + github.com/danieljoos/wincred v1.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emersion/go-imap v1.2.1 // indirect + github.com/emersion/go-message v0.18.1 // indirect + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect + github.com/emersion/go-smtp v0.21.3 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/flatbuffers v24.3.25+incompatible // indirect github.com/kr/text v0.2.0 // indirect @@ -69,6 +76,7 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xtgo/set v1.0.0 // indirect + github.com/zalando/go-keyring v0.2.5 // indirect go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect gorgonia.org/vecf32 v0.9.0 // indirect diff --git a/go.sum b/go.sum index 4074dd1860f..db9b6224815 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,8 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= +github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= +github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/apache/arrow/go/arrow v0.0.0-20211112161151-bc219186db40 h1:q4dksr6ICHXqG5hm0ZW5IHyeEJXoIJSOZeBLmWPNeIQ= github.com/apache/arrow/go/arrow v0.0.0-20211112161151-bc219186db40/go.mod h1:Q7yQnSMnLvcXlZ8RV+jwz/6y1rQTqbX6C82SndT52Zs= @@ -55,6 +57,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/d4l3k/go-bfloat16 v0.0.0-20211005043715-690c3bdd05f1 h1:cBzrdJPAFBsgCrDPnZxlp1dF2+k4r1kVpD7+1S1PVjY= github.com/d4l3k/go-bfloat16 v0.0.0-20211005043715-690c3bdd05f1/go.mod h1:uw2gLcxEuYUlAd/EXyjc/v55nd3+47YAgWbSXVxPrNI= +github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= +github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -62,6 +66,17 @@ github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+ github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +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-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= +github.com/emersion/go-message v0.18.1 h1:tfTxIoXFSFRwWaZsgnqS1DSZuGpYGzSmCZD8SK3QA2E= +github.com/emersion/go-message v0.18.1/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.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGVMY= +github.com/emersion/go-smtp v0.21.3/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= +github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU= github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= @@ -100,6 +115,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= @@ -275,6 +292,9 @@ github.com/xtgo/set v1.0.0/go.mod h1:d3NHzGzSa0NmB2NhFyECA+QdRp29oEn2xbT+TpeFoM8 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zalando/go-keyring v0.2.5 h1:Bc2HHpjALryKD62ppdEzaFG6VxL6Bc+5v0LYpN8Lba8= +github.com/zalando/go-keyring v0.2.5/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 h1:lGdhQUN/cnWdSH3291CUuxSEqc+AsGTiDxPP3r2J0l4= go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E= @@ -285,6 +305,7 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -316,6 +337,8 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -328,8 +351,11 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -341,6 +367,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -355,20 +383,28 @@ golang.org/x/sys v0.0.0-20210304124612-50617c2ba197/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -384,6 +420,8 @@ golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapK golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/mail/body.go b/internal/mail/body.go new file mode 100644 index 00000000000..d149a745bbb --- /dev/null +++ b/internal/mail/body.go @@ -0,0 +1,78 @@ +package mail + +import ( + "bytes" + "fmt" + "io" + "mime" + "mime/multipart" + "mime/quotedprintable" + "net/mail" + "strings" +) + +// readBody extracts the plaintext body from a raw RFC-2822 message reader. +// It handles plain text, quoted-printable, base64, and multipart/alternative. +func readBody(r io.Reader) string { + raw, err := io.ReadAll(r) + if err != nil { + return "" + } + msg, err := mail.ReadMessage(bytes.NewReader(raw)) + if err != nil { + // Fall back to returning the raw content. + return string(raw) + } + ct := msg.Header.Get("Content-Type") + mediaType, params, _ := mime.ParseMediaType(ct) + + switch { + case strings.HasPrefix(mediaType, "multipart/"): + return extractMultipart(msg.Body, params["boundary"]) + default: + return decodeTransferEncoding(msg.Body, msg.Header.Get("Content-Transfer-Encoding")) + } +} + +// extractMultipart walks a multipart message and returns the first text/plain part. +func extractMultipart(r io.Reader, boundary string) string { + mr := multipart.NewReader(r, boundary) + for { + part, err := mr.NextPart() + if err != nil { + break + } + ct := part.Header.Get("Content-Type") + mediaType, params, _ := mime.ParseMediaType(ct) + if strings.HasPrefix(mediaType, "multipart/") { + // Recurse into nested multipart. + if text := extractMultipart(part, params["boundary"]); text != "" { + return text + } + continue + } + if strings.HasPrefix(mediaType, "text/plain") || mediaType == "" { + return decodeTransferEncoding(part, part.Header.Get("Content-Transfer-Encoding")) + } + } + return "" +} + +// decodeTransferEncoding decodes quoted-printable encoding; for base64 Go's +// mime/multipart already decodes automatically, so we just read. +func decodeTransferEncoding(r io.Reader, enc string) string { + var reader io.Reader = r + if strings.EqualFold(enc, "quoted-printable") { + reader = quotedprintable.NewReader(r) + } + b, err := io.ReadAll(reader) + if err != nil { + return "" + } + return string(b) +} + +// FormatMessageID returns a human-readable string for a UID (used in CLI output). +func FormatMessageID(uid uint32) string { + return fmt.Sprintf("%d", uid) +} diff --git a/internal/mail/config.go b/internal/mail/config.go new file mode 100644 index 00000000000..f168c61e545 --- /dev/null +++ b/internal/mail/config.go @@ -0,0 +1,187 @@ +// Package mail provides IMAP/SMTP email access for super-ollama. +// +// Credentials are never stored in mail.toml; instead they are kept in the OS +// keyring under service "super-ollama-mail" with the account username as the +// keyring user. mail.toml holds non-secret connection parameters only. +package mail + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/pelletier/go-toml/v2" + "github.com/zalando/go-keyring" +) + +const keyringService = "super-ollama-mail" + +// IMAPConfig holds IMAP server parameters (no password). +type IMAPConfig struct { + Host string `toml:"host"` + Port int `toml:"port"` + Username string `toml:"username"` + TLS bool `toml:"tls"` // true → implicit TLS (port 993); false → STARTTLS +} + +// SMTPConfig holds SMTP server parameters (no password). +type SMTPConfig struct { + Host string `toml:"host"` + Port int `toml:"port"` + Username string `toml:"username"` + From string `toml:"from"` // display name + address e.g. "Alice " +} + +// rawMailConfig is used only for TOML parsing. +type rawMailConfig struct { + IMAP IMAPConfig `toml:"imap"` + SMTP SMTPConfig `toml:"smtp"` +} + +// Config is the resolved mail configuration with separate IMAP/SMTP sections. +type Config struct { + IMAP IMAPConfig + SMTP SMTPConfig +} + +// configDir returns ~/.super-ollama (or $XDG_CONFIG_HOME/super-ollama). +func configDir() (string, error) { + if xdg := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); xdg != "" { + return filepath.Join(xdg, "super-ollama"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".super-ollama"), nil +} + +// ConfigPath returns the path to mail.toml. +func ConfigPath() (string, error) { + dir, err := configDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "mail.toml"), nil +} + +// LoadConfig reads mail.toml. A missing file returns ErrNotConfigured. +func LoadConfig() (*Config, error) { + path, err := ConfigPath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, ErrNotConfigured + } + return nil, err + } + var raw rawMailConfig + if err := toml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("mail.toml: %w", err) + } + cfg := &Config{IMAP: raw.IMAP, SMTP: raw.SMTP} + if cfg.IMAP.Port == 0 { + cfg.IMAP.Port = 993 + } + if cfg.SMTP.Port == 0 { + cfg.SMTP.Port = 587 + } + return cfg, nil +} + +// ErrNotConfigured is returned when mail.toml does not exist. +var ErrNotConfigured = errors.New("mail is not configured: create ~/.super-ollama/mail.toml") + +// GetIMAPPassword retrieves the IMAP password from the OS keyring. +func GetIMAPPassword(username string) (string, error) { + pw, err := keyring.Get(keyringService, "imap:"+username) + if err != nil { + return "", fmt.Errorf("imap password for %q not found in keyring (run: super-ollama email setup): %w", username, err) + } + return pw, nil +} + +// SetIMAPPassword stores the IMAP password in the OS keyring. +func SetIMAPPassword(username, password string) error { + return keyring.Set(keyringService, "imap:"+username, password) +} + +// GetSMTPPassword retrieves the SMTP password from the OS keyring. +func GetSMTPPassword(username string) (string, error) { + pw, err := keyring.Get(keyringService, "smtp:"+username) + if err != nil { + return "", fmt.Errorf("smtp password for %q not found in keyring (run: super-ollama email setup): %w", username, err) + } + return pw, nil +} + +// SetSMTPPassword stores the SMTP password in the OS keyring. +func SetSMTPPassword(username, password string) error { + return keyring.Set(keyringService, "smtp:"+username, password) +} + +// profilePath returns ~/.super-ollama/profile.md. +func profilePath() (string, error) { + dir, err := configDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "profile.md"), nil +} + +// LoadProfile reads ~/.super-ollama/profile.md; returns empty string if absent. +func LoadProfile() (string, error) { + path, err := profilePath() + if err != nil { + return "", err + } + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return "", nil + } + if err != nil { + return "", err + } + return string(data), nil +} + +// LoadEmailSystemPrompt reads ~/.super-ollama/prompts/email.md; returns empty string if absent. +func LoadEmailSystemPrompt() (string, error) { + dir, err := configDir() + if err != nil { + return "", err + } + data, err := os.ReadFile(filepath.Join(dir, "prompts", "email.md")) + if os.IsNotExist(err) { + return "", nil + } + if err != nil { + return "", err + } + return string(data), nil +} + +// LoadContactNotes reads a contact note file from ~/.super-ollama/contacts/.md +// where is derived from the email address. Returns empty string if absent. +func LoadContactNotes(emailAddr string) (string, error) { + dir, err := configDir() + if err != nil { + return "", err + } + // Use the email address with @ and . replaced by - as the file slug. + slug := strings.NewReplacer("@", "-at-", ".", "-").Replace(emailAddr) + path := filepath.Join(dir, "contacts", slug+".md") + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return "", nil + } + if err != nil { + return "", err + } + return string(data), nil +} diff --git a/internal/mail/imap.go b/internal/mail/imap.go new file mode 100644 index 00000000000..164d599fff9 --- /dev/null +++ b/internal/mail/imap.go @@ -0,0 +1,201 @@ +package mail + +import ( + "crypto/tls" + "fmt" + "sort" + "time" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/client" + _ "github.com/emersion/go-message/charset" // register charset decoders +) + +// MessageHeader holds the envelope fields returned by list operations. +type MessageHeader struct { + UID uint32 + Subject string + From string + Date time.Time + Seen bool +} + +// Message holds the full content of an email. +type Message struct { + MessageHeader + Body string +} + +// imapDial connects to the IMAP server. +func imapDial(cfg IMAPConfig) (*client.Client, error) { + addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) + if cfg.TLS { + tlsCfg := &tls.Config{ServerName: cfg.Host} + return client.DialTLS(addr, tlsCfg) + } + c, err := client.Dial(addr) + if err != nil { + return nil, err + } + // Attempt STARTTLS; ignore the error if the server does not advertise it. + _ = c.StartTLS(&tls.Config{ServerName: cfg.Host}) + return c, nil +} + +// ListMessages fetches the most recent n message headers from the INBOX. +// If unreadOnly is true, only unseen messages are returned. +func ListMessages(cfg IMAPConfig, password string, n int, unreadOnly bool) ([]MessageHeader, error) { + if n <= 0 { + n = 20 + } + + c, err := imapDial(cfg) + if err != nil { + return nil, fmt.Errorf("imap connect: %w", err) + } + defer func() { _ = c.Logout() }() + + if err := c.Login(cfg.Username, password); err != nil { + return nil, fmt.Errorf("imap login: %w", err) + } + + mbox, err := c.Select("INBOX", true) + if err != nil { + return nil, fmt.Errorf("imap select INBOX: %w", err) + } + if mbox.Messages == 0 { + return nil, nil + } + + // Fetch the last n messages by sequence number. + from := uint32(1) + if mbox.Messages > uint32(n) { + from = mbox.Messages - uint32(n) + 1 + } + seqSet := new(imap.SeqSet) + seqSet.AddRange(from, mbox.Messages) + + items := []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchUid} + messages := make(chan *imap.Message, 64) + errCh := make(chan error, 1) + go func() { + errCh <- c.Fetch(seqSet, items, messages) + }() + + var headers []MessageHeader + for msg := range messages { + if msg.Envelope == nil { + continue + } + + seen := false + for _, f := range msg.Flags { + if f == imap.SeenFlag { + seen = true + break + } + } + if unreadOnly && seen { + continue + } + + from := "" + if len(msg.Envelope.From) > 0 { + a := msg.Envelope.From[0] + if a.PersonalName != "" { + from = fmt.Sprintf("%s <%s@%s>", a.PersonalName, a.MailboxName, a.HostName) + } else { + from = fmt.Sprintf("%s@%s", a.MailboxName, a.HostName) + } + } + + headers = append(headers, MessageHeader{ + UID: msg.Uid, + Subject: msg.Envelope.Subject, + From: from, + Date: msg.Envelope.Date, + Seen: seen, + }) + } + if err := <-errCh; err != nil { + return nil, fmt.Errorf("imap fetch: %w", err) + } + + // Sort descending by date (newest first). + sort.Slice(headers, func(i, j int) bool { + return headers[i].Date.After(headers[j].Date) + }) + return headers, nil +} + +// ReadMessage fetches the full body of the message identified by its IMAP UID. +func ReadMessage(cfg IMAPConfig, password string, uid uint32) (*Message, error) { + c, err := imapDial(cfg) + if err != nil { + return nil, fmt.Errorf("imap connect: %w", err) + } + defer func() { _ = c.Logout() }() + + if err := c.Login(cfg.Username, password); err != nil { + return nil, fmt.Errorf("imap login: %w", err) + } + + if _, err := c.Select("INBOX", true); err != nil { + return nil, fmt.Errorf("imap select INBOX: %w", err) + } + + seqSet := new(imap.SeqSet) + seqSet.AddNum(uid) + + section := &imap.BodySectionName{} + items := []imap.FetchItem{imap.FetchEnvelope, imap.FetchFlags, imap.FetchUid, section.FetchItem()} + messages := make(chan *imap.Message, 1) + errCh := make(chan error, 1) + go func() { + errCh <- c.UidFetch(seqSet, items, messages) + }() + + var result *Message + for msg := range messages { + if msg.Envelope == nil { + continue + } + body := "" + if r := msg.GetBody(section); r != nil { + body = readBody(r) + } + from := "" + if len(msg.Envelope.From) > 0 { + a := msg.Envelope.From[0] + if a.PersonalName != "" { + from = fmt.Sprintf("%s <%s@%s>", a.PersonalName, a.MailboxName, a.HostName) + } else { + from = fmt.Sprintf("%s@%s", a.MailboxName, a.HostName) + } + } + seen := false + for _, f := range msg.Flags { + if f == imap.SeenFlag { + seen = true + break + } + } + result = &Message{ + MessageHeader: MessageHeader{ + UID: msg.Uid, + Subject: msg.Envelope.Subject, + From: from, + Date: msg.Envelope.Date, + Seen: seen, + }, + Body: body, + } + } + if err := <-errCh; err != nil { + return nil, fmt.Errorf("imap uid fetch: %w", err) + } + if result == nil { + return nil, fmt.Errorf("message UID %d not found", uid) + } + return result, nil +} diff --git a/internal/mail/mail_test.go b/internal/mail/mail_test.go new file mode 100644 index 00000000000..e142a56547e --- /dev/null +++ b/internal/mail/mail_test.go @@ -0,0 +1,332 @@ +package mail_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/ollama/ollama/internal/mail" +) + +// ── config loading ────────────────────────────────────────────────────────── + +func TestLoadConfig_MissingFile(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + _, err := mail.LoadConfig() + if err != mail.ErrNotConfigured { + t.Fatalf("expected ErrNotConfigured, got %v", err) + } +} + +func TestLoadConfig_ValidTOML(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + + cfgDir := filepath.Join(dir, "super-ollama") + if err := os.MkdirAll(cfgDir, 0o700); err != nil { + t.Fatal(err) + } + toml := ` +[imap] +host = "imap.example.com" +port = 993 +username = "user@example.com" +tls = true + +[smtp] +host = "smtp.example.com" +port = 587 +username = "user@example.com" +from = "User " +` + if err := os.WriteFile(filepath.Join(cfgDir, "mail.toml"), []byte(toml), 0o600); err != nil { + t.Fatal(err) + } + + cfg, err := mail.LoadConfig() + if err != nil { + t.Fatal(err) + } + if cfg.IMAP.Host != "imap.example.com" { + t.Errorf("IMAP host: got %q, want %q", cfg.IMAP.Host, "imap.example.com") + } + if cfg.IMAP.Port != 993 { + t.Errorf("IMAP port: got %d, want 993", cfg.IMAP.Port) + } + if cfg.SMTP.From != "User " { + t.Errorf("SMTP from: got %q, want %q", cfg.SMTP.From, "User ") + } +} + +func TestLoadConfig_DefaultPorts(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + + cfgDir := filepath.Join(dir, "super-ollama") + if err := os.MkdirAll(cfgDir, 0o700); err != nil { + t.Fatal(err) + } + // No port fields — should default to 993 / 587. + toml := ` +[imap] +host = "imap.example.com" +username = "u@example.com" + +[smtp] +host = "smtp.example.com" +username = "u@example.com" +` + if err := os.WriteFile(filepath.Join(cfgDir, "mail.toml"), []byte(toml), 0o600); err != nil { + t.Fatal(err) + } + + cfg, err := mail.LoadConfig() + if err != nil { + t.Fatal(err) + } + if cfg.IMAP.Port != 993 { + t.Errorf("IMAP default port: got %d, want 993", cfg.IMAP.Port) + } + if cfg.SMTP.Port != 587 { + t.Errorf("SMTP default port: got %d, want 587", cfg.SMTP.Port) + } +} + +// ── profile / contact helpers ─────────────────────────────────────────────── + +func TestLoadProfile_Missing(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + got, err := mail.LoadProfile() + if err != nil { + t.Fatal(err) + } + if got != "" { + t.Errorf("expected empty profile, got %q", got) + } +} + +func TestLoadProfile_Present(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + cfgDir := filepath.Join(dir, "super-ollama") + if err := os.MkdirAll(cfgDir, 0o700); err != nil { + t.Fatal(err) + } + content := "# About me\nName: Alice\n" + if err := os.WriteFile(filepath.Join(cfgDir, "profile.md"), []byte(content), 0o600); err != nil { + t.Fatal(err) + } + got, err := mail.LoadProfile() + if err != nil { + t.Fatal(err) + } + if got != content { + t.Errorf("profile mismatch: got %q, want %q", got, content) + } +} + +func TestLoadContactNotes_Present(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + cfgDir := filepath.Join(dir, "super-ollama", "contacts") + if err := os.MkdirAll(cfgDir, 0o700); err != nil { + t.Fatal(err) + } + // alice@example.com → alice-at-example-com.md + notes := "Alice is the project lead.\n" + slug := "alice-at-example-com" + if err := os.WriteFile(filepath.Join(cfgDir, slug+".md"), []byte(notes), 0o600); err != nil { + t.Fatal(err) + } + got, err := mail.LoadContactNotes("alice@example.com") + if err != nil { + t.Fatal(err) + } + if got != notes { + t.Errorf("contact notes mismatch: got %q, want %q", got, notes) + } +} + +// ── prompt assembly ───────────────────────────────────────────────────────── + +func TestBuildComposePrompt_ContainsKeyParts(t *testing.T) { + opts := mail.ComposePromptOptions{ + SystemPrompt: "You are an email assistant.", + Profile: "Name: Alice", + ContactNotes: "Bob is the CTO.", + RecentEmails: []string{"Past email summary 1"}, + To: "bob@example.com", + Subject: "Q3 Report", + Tone: mail.ToneFormal, + } + prompt := mail.BuildComposePrompt(opts) + + cases := []string{ + "You are an email assistant.", + "Write in a professional, formal register.", + "Name: Alice", + "Bob is the CTO.", + "Past email summary 1", + "bob@example.com", + "Q3 Report", + } + for _, want := range cases { + if !strings.Contains(prompt, want) { + t.Errorf("prompt missing %q", want) + } + } +} + +func TestBuildReplyPrompt_ContainsOriginalBody(t *testing.T) { + opts := mail.ReplyPromptOptions{ + OriginalFrom: "bob@example.com", + OriginalBody: "Please review the attached report.", + Tone: mail.ToneBrief, + } + prompt := mail.BuildReplyPrompt(opts) + + if !strings.Contains(prompt, "Please review the attached report.") { + t.Error("reply prompt missing original body") + } + if !strings.Contains(prompt, "Be concise. Keep the email under 100 words.") { + t.Error("reply prompt missing brief tone instruction") + } +} + +func TestBuildSummarisePrompt(t *testing.T) { + opts := mail.SummarisePromptOptions{ + From: "carol@example.com", + Subject: "Budget Update", + Body: "The budget has been revised upward.", + } + prompt := mail.BuildSummarisePrompt(opts) + if !strings.Contains(prompt, "carol@example.com") { + t.Error("summarise prompt missing From") + } + if !strings.Contains(prompt, "Budget Update") { + t.Error("summarise prompt missing Subject") + } + if !strings.Contains(prompt, "budget has been revised") { + t.Error("summarise prompt missing body text") + } +} + +// ── SQLite store ───────────────────────────────────────────────────────────── + +func TestStore_DraftRoundtrip(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "mail.db") + store, err := mail.OpenStore(dbPath) + if err != nil { + t.Fatal(err) + } + defer store.Close() + + d := mail.Draft{ + CreatedAt: time.Now().Truncate(time.Millisecond), + To: "alice@example.com", + Subject: "Hello", + Body: "Hi Alice,\n\nJust checking in.\n\nBest,\nBob", + } + id, err := store.SaveDraft(d) + if err != nil { + t.Fatal(err) + } + if id <= 0 { + t.Fatalf("expected positive draft id, got %d", id) + } + + got, err := store.GetDraft(id) + if err != nil { + t.Fatal(err) + } + if got.To != d.To { + t.Errorf("To: got %q, want %q", got.To, d.To) + } + if got.Subject != d.Subject { + t.Errorf("Subject: got %q, want %q", got.Subject, d.Subject) + } + if got.Body != d.Body { + t.Errorf("Body mismatch") + } + + // Delete and verify it's gone. + if err := store.DeleteDraft(id); err != nil { + t.Fatal(err) + } + _, err = store.GetDraft(id) + if err == nil { + t.Error("expected error after deleting draft, got nil") + } +} + +func TestStore_EmailUpsertAndRetrieve(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "mail.db") + store, err := mail.OpenStore(dbPath) + if err != nil { + t.Fatal(err) + } + defer store.Close() + + e := mail.StoredEmail{ + MessageID: "", + Subject: "Test subject", + FromAddr: "sender@example.com", + ToAddr: "me@example.com", + Date: time.Now().Truncate(time.Millisecond), + Body: "Test body", + } + if err := store.UpsertEmail(e); err != nil { + t.Fatal(err) + } + + got, err := store.GetEmail(e.MessageID) + if err != nil { + t.Fatal(err) + } + if got.Subject != e.Subject { + t.Errorf("Subject: got %q, want %q", got.Subject, e.Subject) + } + + // Upsert with updated summary. + e.Summary = "A test email." + if err := store.UpsertEmail(e); err != nil { + t.Fatal(err) + } + got2, err := store.GetEmail(e.MessageID) + if err != nil { + t.Fatal(err) + } + if got2.Summary != "A test email." { + t.Errorf("Summary after upsert: got %q, want %q", got2.Summary, "A test email.") + } +} + +func TestStore_ListDrafts(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "mail.db") + store, err := mail.OpenStore(dbPath) + if err != nil { + t.Fatal(err) + } + defer store.Close() + + for i := 0; i < 3; i++ { + _, err := store.SaveDraft(mail.Draft{ + CreatedAt: time.Now(), + To: "x@example.com", + Subject: "Draft", + Body: "Body", + }) + if err != nil { + t.Fatal(err) + } + } + drafts, err := store.ListDrafts() + if err != nil { + t.Fatal(err) + } + if len(drafts) != 3 { + t.Errorf("expected 3 drafts, got %d", len(drafts)) + } +} diff --git a/internal/mail/prompt.go b/internal/mail/prompt.go new file mode 100644 index 00000000000..85d3a3d09f9 --- /dev/null +++ b/internal/mail/prompt.go @@ -0,0 +1,135 @@ +package mail + +import ( + "fmt" + "strings" +) + +// ToneHint controls the writing style injected into compose/reply prompts. +type ToneHint string + +const ( + ToneFormal ToneHint = "formal" + ToneCasual ToneHint = "casual" + ToneBrief ToneHint = "brief" +) + +// toneInstruction maps a ToneHint to the prefix injected into the system prompt. +func toneInstruction(tone ToneHint) string { + switch tone { + case ToneFormal: + return "Write in a professional, formal register." + case ToneCasual: + return "Write in a friendly, conversational tone." + case ToneBrief: + return "Be concise. Keep the email under 100 words." + default: + return "" + } +} + +// ComposePromptOptions holds all context needed to build an email compose prompt. +type ComposePromptOptions struct { + SystemPrompt string // from ~/.super-ollama/prompts/email.md + Profile string // from ~/.super-ollama/profile.md + ContactNotes string // from ~/.super-ollama/contacts/.md + RecentEmails []string // summaries of semantically similar past emails + To string + Subject string + Tone ToneHint +} + +// ReplyPromptOptions holds context for building an email reply prompt. +type ReplyPromptOptions struct { + SystemPrompt string + Profile string + ContactNotes string + RecentEmails []string + OriginalFrom string + OriginalBody string + Tone ToneHint +} + +// SummarisePromptOptions holds context for building a summarisation prompt. +type SummarisePromptOptions struct { + From string + Subject string + Body string +} + +// BuildComposePrompt assembles the full LLM prompt for email composition. +func BuildComposePrompt(opts ComposePromptOptions) string { + var sb strings.Builder + + if opts.SystemPrompt != "" { + sb.WriteString(opts.SystemPrompt) + sb.WriteString("\n\n") + } + if tone := toneInstruction(opts.Tone); tone != "" { + sb.WriteString(tone) + sb.WriteString("\n\n") + } + if opts.Profile != "" { + sb.WriteString("## User profile\n\n") + sb.WriteString(opts.Profile) + sb.WriteString("\n\n") + } + if opts.ContactNotes != "" { + sb.WriteString("## Contact notes\n\n") + sb.WriteString(opts.ContactNotes) + sb.WriteString("\n\n") + } + if len(opts.RecentEmails) > 0 { + sb.WriteString("## Similar past emails (for context)\n\n") + for i, e := range opts.RecentEmails { + sb.WriteString(fmt.Sprintf("### Email %d\n%s\n\n", i+1, e)) + } + } + sb.WriteString("---\n") + sb.WriteString(fmt.Sprintf("User instruction: compose an email to %s about \"%s\".\n", opts.To, opts.Subject)) + sb.WriteString("Return only the email body (no headers). Do not include a subject line.\n") + return sb.String() +} + +// BuildReplyPrompt assembles the LLM prompt for replying to an email. +func BuildReplyPrompt(opts ReplyPromptOptions) string { + var sb strings.Builder + + if opts.SystemPrompt != "" { + sb.WriteString(opts.SystemPrompt) + sb.WriteString("\n\n") + } + if tone := toneInstruction(opts.Tone); tone != "" { + sb.WriteString(tone) + sb.WriteString("\n\n") + } + if opts.Profile != "" { + sb.WriteString("## User profile\n\n") + sb.WriteString(opts.Profile) + sb.WriteString("\n\n") + } + if opts.ContactNotes != "" { + sb.WriteString("## Contact notes\n\n") + sb.WriteString(opts.ContactNotes) + sb.WriteString("\n\n") + } + if len(opts.RecentEmails) > 0 { + sb.WriteString("## Similar past emails (for context)\n\n") + for i, e := range opts.RecentEmails { + sb.WriteString(fmt.Sprintf("### Email %d\n%s\n\n", i+1, e)) + } + } + sb.WriteString("---\n") + sb.WriteString(fmt.Sprintf("Original email from %s:\n\n%s\n\n", opts.OriginalFrom, opts.OriginalBody)) + sb.WriteString("User instruction: write a reply to the above email.\n") + sb.WriteString("Return only the reply body (no headers). Do not include a subject line.\n") + return sb.String() +} + +// BuildSummarisePrompt builds a short summarisation prompt. +func BuildSummarisePrompt(opts SummarisePromptOptions) string { + return fmt.Sprintf( + "Summarise the following email in 2-3 sentences.\n\nFrom: %s\nSubject: %s\n\n%s", + opts.From, opts.Subject, opts.Body, + ) +} diff --git a/internal/mail/smtp.go b/internal/mail/smtp.go new file mode 100644 index 00000000000..935b99db2ae --- /dev/null +++ b/internal/mail/smtp.go @@ -0,0 +1,88 @@ +package mail + +import ( + "crypto/tls" + "fmt" + "net/smtp" + "strings" + "time" +) + +// SendOptions carries all fields needed to transmit an email. +type SendOptions struct { + To []string + Subject string + Body string + ReplyTo string // optional In-Reply-To message-id +} + +// SendMessage transmits an email via SMTP using the provided credentials. +// Authentication uses PLAIN over TLS (port 465) or STARTTLS (port 587/25). +func SendMessage(cfg SMTPConfig, password string, opts SendOptions) error { + if len(opts.To) == 0 { + return fmt.Errorf("no recipients specified") + } + + from := cfg.From + if from == "" { + from = cfg.Username + } + + // Build raw RFC-2822 message. + var sb strings.Builder + sb.WriteString(fmt.Sprintf("From: %s\r\n", from)) + sb.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(opts.To, ", "))) + sb.WriteString(fmt.Sprintf("Subject: %s\r\n", opts.Subject)) + sb.WriteString(fmt.Sprintf("Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))) + sb.WriteString("MIME-Version: 1.0\r\n") + sb.WriteString("Content-Type: text/plain; charset=UTF-8\r\n") + sb.WriteString("Content-Transfer-Encoding: 8bit\r\n") + if opts.ReplyTo != "" { + sb.WriteString(fmt.Sprintf("In-Reply-To: %s\r\n", opts.ReplyTo)) + } + sb.WriteString("\r\n") + sb.WriteString(strings.ReplaceAll(opts.Body, "\n", "\r\n")) + + addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) + auth := smtp.PlainAuth("", cfg.Username, password, cfg.Host) + + // Port 465 → implicit TLS; everything else → plain + STARTTLS offered by server. + if cfg.Port == 465 { + return sendImplicitTLS(addr, cfg.Host, auth, from, opts.To, []byte(sb.String())) + } + return smtp.SendMail(addr, auth, from, opts.To, []byte(sb.String())) +} + +// sendImplicitTLS dials port 465 with an already-wrapped TLS connection and +// then speaks SMTP over it (no STARTTLS negotiation needed). +func sendImplicitTLS(addr, host string, auth smtp.Auth, from string, to []string, msg []byte) error { + conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: host}) + if err != nil { + return fmt.Errorf("tls dial: %w", err) + } + c, err := smtp.NewClient(conn, host) + if err != nil { + return fmt.Errorf("smtp new client: %w", err) + } + defer func() { _ = c.Quit() }() + + if err := c.Auth(auth); err != nil { + return fmt.Errorf("smtp auth: %w", err) + } + if err := c.Mail(from); err != nil { + return fmt.Errorf("smtp MAIL FROM: %w", err) + } + for _, rcpt := range to { + if err := c.Rcpt(rcpt); err != nil { + return fmt.Errorf("smtp RCPT TO %q: %w", rcpt, err) + } + } + w, err := c.Data() + if err != nil { + return fmt.Errorf("smtp DATA: %w", err) + } + if _, err := w.Write(msg); err != nil { + return fmt.Errorf("smtp write body: %w", err) + } + return w.Close() +} diff --git a/internal/mail/store.go b/internal/mail/store.go new file mode 100644 index 00000000000..bfb262c51d8 --- /dev/null +++ b/internal/mail/store.go @@ -0,0 +1,235 @@ +package mail + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +// Store wraps a SQLite database that holds synced email headers and drafts. +type Store struct { + db *sql.DB +} + +// Draft represents an AI-composed email that has not been sent yet. +type Draft struct { + ID int64 + CreatedAt time.Time + To string + Subject string + Body string + ReplyTo string // original message-id when this is a reply +} + +// StoredEmail is a lightweight representation of a synced email header + body. +type StoredEmail struct { + ID int64 + MessageID string + Subject string + FromAddr string + ToAddr string + Date time.Time + Body string + Summary string +} + +// defaultDBPath returns ~/.super-ollama/mail.db (or $SUPER_OLLAMA_MAIL_DB). +func defaultDBPath() (string, error) { + if p := os.Getenv("SUPER_OLLAMA_MAIL_DB"); p != "" { + return p, nil + } + dir, err := configDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "mail.db"), nil +} + +// OpenStore opens (and migrates) the email SQLite store. +func OpenStore(dbPath string) (*Store, error) { + if dbPath == "" { + var err error + dbPath, err = defaultDBPath() + if err != nil { + return nil, err + } + } + if err := os.MkdirAll(filepath.Dir(dbPath), 0o700); err != nil { + return nil, err + } + db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on&_journal_mode=WAL") + if err != nil { + return nil, fmt.Errorf("open mail db: %w", err) + } + if err := migrate(db); err != nil { + _ = db.Close() + return nil, err + } + return &Store{db: db}, nil +} + +// Close releases the database connection. +func (s *Store) Close() error { + return s.db.Close() +} + +const schema = ` +CREATE TABLE IF NOT EXISTS emails ( + id INTEGER PRIMARY KEY, + message_id TEXT UNIQUE NOT NULL, + subject TEXT, + from_addr TEXT, + to_addr TEXT, + date INTEGER, + body TEXT, + summary TEXT +); + +CREATE TABLE IF NOT EXISTS drafts ( + id INTEGER PRIMARY KEY, + created_at INTEGER NOT NULL, + to_addr TEXT NOT NULL, + subject TEXT, + body TEXT, + reply_to TEXT +); +` + +func migrate(db *sql.DB) error { + _, err := db.Exec(schema) + return err +} + +// UpsertEmail inserts or replaces a synced email record. +func (s *Store) UpsertEmail(e StoredEmail) error { + _, err := s.db.Exec(` + INSERT INTO emails (message_id, subject, from_addr, to_addr, date, body, summary) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(message_id) DO UPDATE SET + subject = excluded.subject, + from_addr = excluded.from_addr, + to_addr = excluded.to_addr, + date = excluded.date, + body = excluded.body, + summary = excluded.summary`, + e.MessageID, e.Subject, e.FromAddr, e.ToAddr, + e.Date.UnixMilli(), e.Body, e.Summary, + ) + return err +} + +// GetEmail retrieves a stored email by its message-id. +func (s *Store) GetEmail(messageID string) (*StoredEmail, error) { + row := s.db.QueryRow(` + SELECT id, message_id, subject, from_addr, to_addr, date, body, summary + FROM emails WHERE message_id = ?`, messageID) + return scanEmail(row) +} + +// RecentEmails returns the most recent limit emails, newest first. +func (s *Store) RecentEmails(limit int) ([]StoredEmail, error) { + rows, err := s.db.Query(` + SELECT id, message_id, subject, from_addr, to_addr, date, body, summary + FROM emails ORDER BY date DESC LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []StoredEmail + for rows.Next() { + e, err := scanEmail(rows) + if err != nil { + return nil, err + } + out = append(out, *e) + } + return out, rows.Err() +} + +// UpdateSummary sets the AI-generated summary for a stored email. +func (s *Store) UpdateSummary(messageID, summary string) error { + _, err := s.db.Exec(`UPDATE emails SET summary = ? WHERE message_id = ?`, summary, messageID) + return err +} + +// SaveDraft persists an AI-generated draft; returns the new draft ID. +func (s *Store) SaveDraft(d Draft) (int64, error) { + res, err := s.db.Exec(` + INSERT INTO drafts (created_at, to_addr, subject, body, reply_to) + VALUES (?, ?, ?, ?, ?)`, + d.CreatedAt.UnixMilli(), d.To, d.Subject, d.Body, d.ReplyTo, + ) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// GetDraft retrieves a draft by ID. +func (s *Store) GetDraft(id int64) (*Draft, error) { + row := s.db.QueryRow(` + SELECT id, created_at, to_addr, subject, body, reply_to + FROM drafts WHERE id = ?`, id) + return scanDraft(row) +} + +// DeleteDraft removes a draft after it has been sent. +func (s *Store) DeleteDraft(id int64) error { + _, err := s.db.Exec(`DELETE FROM drafts WHERE id = ?`, id) + return err +} + +// ListDrafts returns all unsent drafts. +func (s *Store) ListDrafts() ([]Draft, error) { + rows, err := s.db.Query(` + SELECT id, created_at, to_addr, subject, body, reply_to + FROM drafts ORDER BY created_at DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Draft + for rows.Next() { + d, err := scanDraft(rows) + if err != nil { + return nil, err + } + out = append(out, *d) + } + return out, rows.Err() +} + +// scanner is satisfied by both *sql.Row and *sql.Rows. +type scanner interface { + Scan(dest ...any) error +} + +func scanEmail(s scanner) (*StoredEmail, error) { + var e StoredEmail + var dateMs int64 + if err := s.Scan(&e.ID, &e.MessageID, &e.Subject, &e.FromAddr, &e.ToAddr, &dateMs, &e.Body, &e.Summary); err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("email not found") + } + return nil, err + } + e.Date = time.UnixMilli(dateMs) + return &e, nil +} + +func scanDraft(s scanner) (*Draft, error) { + var d Draft + var createdMs int64 + if err := s.Scan(&d.ID, &createdMs, &d.To, &d.Subject, &d.Body, &d.ReplyTo); err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("draft not found") + } + return nil, err + } + d.CreatedAt = time.UnixMilli(createdMs) + return &d, nil +} From 33b15665c2f8798ab776c2d65540312e14d178c4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 14:05:35 +0000 Subject: [PATCH 3/3] fix: address code review comments (TLS min version, path safety, slug sanitization) Agent-Logs-Url: https://github.com/Kritarth-Dandapat/super-ollama/sessions/e08ab9f9-16ea-4242-b398-db9eef4ba004 Co-authored-by: Kritarth-Dandapat <141005022+Kritarth-Dandapat@users.noreply.github.com> --- cmd/super-ollama/email.go | 9 ++++++++- go.mod | 7 +++---- go.sum | 3 +-- internal/mail/config.go | 23 +++++++++++++++++++++-- internal/mail/imap.go | 4 ++-- internal/mail/mail_test.go | 4 ++-- internal/mail/smtp.go | 2 +- internal/mail/store.go | 13 ++++++++++++- 8 files changed, 50 insertions(+), 15 deletions(-) diff --git a/cmd/super-ollama/email.go b/cmd/super-ollama/email.go index 440dcf9839b..5b0d5b96224 100644 --- a/cmd/super-ollama/email.go +++ b/cmd/super-ollama/email.go @@ -423,7 +423,14 @@ func newEmailSendCmd() *cobra.Command { if !confirm { ui.Print("\nSend this email? [y/N] ") scanner := bufio.NewScanner(os.Stdin) - if !scanner.Scan() || !strings.EqualFold(strings.TrimSpace(scanner.Text()), "y") { + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return fmt.Errorf("reading confirmation: %w", err) + } + ui.Println("Aborted.") + return nil + } + if !strings.EqualFold(strings.TrimSpace(scanner.Text()), "y") { ui.Println("Aborted.") return nil } diff --git a/go.mod b/go.mod index 05e75f89e3a..7fbc567161b 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,8 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/d4l3k/go-bfloat16 v0.0.0-20211005043715-690c3bdd05f1 github.com/dlclark/regexp2 v1.11.4 + github.com/emersion/go-imap v1.2.1 + github.com/emersion/go-message v0.18.1 github.com/emirpasic/gods/v2 v2.0.0-alpha github.com/klauspost/compress v1.18.3 github.com/mattn/go-runewidth v0.0.16 @@ -32,6 +34,7 @@ require ( github.com/tree-sitter/go-tree-sitter v0.25.0 github.com/tree-sitter/tree-sitter-cpp v0.23.4 github.com/wk8/go-ordered-map/v2 v2.1.8 + github.com/zalando/go-keyring v0.2.5 golang.org/x/image v0.22.0 golang.org/x/mod v0.30.0 golang.org/x/tools v0.38.0 @@ -55,10 +58,7 @@ require ( github.com/cloudwego/iasm v0.2.0 // indirect github.com/danieljoos/wincred v1.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/emersion/go-imap v1.2.1 // indirect - github.com/emersion/go-message v0.18.1 // indirect github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect - github.com/emersion/go-smtp v0.21.3 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect @@ -76,7 +76,6 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xtgo/set v1.0.0 // indirect - github.com/zalando/go-keyring v0.2.5 // indirect go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect gorgonia.org/vecf32 v0.9.0 // indirect diff --git a/go.sum b/go.sum index db9b6224815..d6ad1e5b4ea 100644 --- a/go.sum +++ b/go.sum @@ -74,8 +74,6 @@ github.com/emersion/go-message v0.18.1/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0 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.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGVMY= -github.com/emersion/go-smtp v0.21.3/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU= github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A= @@ -238,6 +236,7 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.1.4/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= diff --git a/internal/mail/config.go b/internal/mail/config.go index f168c61e545..f7f5452f781 100644 --- a/internal/mail/config.go +++ b/internal/mail/config.go @@ -166,6 +166,23 @@ func LoadEmailSystemPrompt() (string, error) { return string(data), nil } +// contactSlug converts an email address to a safe file-system slug. +// Only alphanumeric characters, hyphens, and underscores are preserved; +// everything else is replaced with a hyphen. +func contactSlug(emailAddr string) string { + var b strings.Builder + for _, r := range emailAddr { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', + r >= '0' && r <= '9', r == '-', r == '_': + b.WriteRune(r) + default: + b.WriteRune('-') + } + } + return b.String() +} + // LoadContactNotes reads a contact note file from ~/.super-ollama/contacts/.md // where is derived from the email address. Returns empty string if absent. func LoadContactNotes(emailAddr string) (string, error) { @@ -173,8 +190,10 @@ func LoadContactNotes(emailAddr string) (string, error) { if err != nil { return "", err } - // Use the email address with @ and . replaced by - as the file slug. - slug := strings.NewReplacer("@", "-at-", ".", "-").Replace(emailAddr) + slug := contactSlug(emailAddr) + if slug == "" { + return "", nil + } path := filepath.Join(dir, "contacts", slug+".md") data, err := os.ReadFile(path) if os.IsNotExist(err) { diff --git a/internal/mail/imap.go b/internal/mail/imap.go index 164d599fff9..23c92f2c365 100644 --- a/internal/mail/imap.go +++ b/internal/mail/imap.go @@ -29,8 +29,8 @@ type Message struct { // imapDial connects to the IMAP server. func imapDial(cfg IMAPConfig) (*client.Client, error) { addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) + tlsCfg := &tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12} if cfg.TLS { - tlsCfg := &tls.Config{ServerName: cfg.Host} return client.DialTLS(addr, tlsCfg) } c, err := client.Dial(addr) @@ -38,7 +38,7 @@ func imapDial(cfg IMAPConfig) (*client.Client, error) { return nil, err } // Attempt STARTTLS; ignore the error if the server does not advertise it. - _ = c.StartTLS(&tls.Config{ServerName: cfg.Host}) + _ = c.StartTLS(tlsCfg) return c, nil } diff --git a/internal/mail/mail_test.go b/internal/mail/mail_test.go index e142a56547e..6f2f0332d3d 100644 --- a/internal/mail/mail_test.go +++ b/internal/mail/mail_test.go @@ -134,9 +134,9 @@ func TestLoadContactNotes_Present(t *testing.T) { if err := os.MkdirAll(cfgDir, 0o700); err != nil { t.Fatal(err) } - // alice@example.com → alice-at-example-com.md + // alice@example.com → alice-example-com.md (@ and . become -) notes := "Alice is the project lead.\n" - slug := "alice-at-example-com" + slug := "alice-example-com" if err := os.WriteFile(filepath.Join(cfgDir, slug+".md"), []byte(notes), 0o600); err != nil { t.Fatal(err) } diff --git a/internal/mail/smtp.go b/internal/mail/smtp.go index 935b99db2ae..ce90b7eccf5 100644 --- a/internal/mail/smtp.go +++ b/internal/mail/smtp.go @@ -56,7 +56,7 @@ func SendMessage(cfg SMTPConfig, password string, opts SendOptions) error { // sendImplicitTLS dials port 465 with an already-wrapped TLS connection and // then speaks SMTP over it (no STARTTLS negotiation needed). func sendImplicitTLS(addr, host string, auth smtp.Auth, from string, to []string, msg []byte) error { - conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: host}) + conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}) if err != nil { return fmt.Errorf("tls dial: %w", err) } diff --git a/internal/mail/store.go b/internal/mail/store.go index bfb262c51d8..b693d7108bc 100644 --- a/internal/mail/store.go +++ b/internal/mail/store.go @@ -61,10 +61,21 @@ func OpenStore(dbPath string) (*Store, error) { if err := os.MkdirAll(filepath.Dir(dbPath), 0o700); err != nil { return nil, err } - db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=on&_journal_mode=WAL") + db, err := sql.Open("sqlite3", dbPath) if err != nil { return nil, fmt.Errorf("open mail db: %w", err) } + // Enable foreign keys and WAL mode via pragmas instead of DSN query params + // to avoid issues with special characters in dbPath. + for _, pragma := range []string{ + "PRAGMA foreign_keys = ON", + "PRAGMA journal_mode = WAL", + } { + if _, err := db.Exec(pragma); err != nil { + _ = db.Close() + return nil, fmt.Errorf("configure mail db (%s): %w", pragma, err) + } + } if err := migrate(db); err != nil { _ = db.Close() return nil, err