diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..791378f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,37 @@ +# Pop 开发记录 + +## Changelog + +| 日期 | 版本 | 描述 | +|------|------|------| +| 2026-04-09 | v0.1.0 | 初始规划:发送历史、收件箱功能 | +| 2026-04-09 | v0.1.1 | 配置简化:支持多账户、自动识别Provider | + +## 讨论记录 + +- [第1次:功能规划](./doc/001-feature-planning.md) +- [第2次:配置简化讨论](./doc/002-config-simplification.md) + + +## 配置文件格式 + +```yaml +accounts: + - + email: 邮箱 + provider: 163 + username: 用户名 + password: 密钥 + encryption: ssl + insecure: false + imap: + host: imap.163.com + port: 993 + smtp: + host: smtp.163.com + port: 465 + + - + email: 邮箱 + provider: qq +``` diff --git a/config.go b/config.go new file mode 100644 index 0000000..211e36e --- /dev/null +++ b/config.go @@ -0,0 +1,366 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +const configFileName = "config.yml" + +type Config struct { + From string `yaml:"from"` + Signature string `yaml:"signature"` + SMTP SMTPConfig `yaml:"smtp"` + UnsafeHTML bool `yaml:"unsafe_html"` + Accounts []Account `yaml:"accounts"` +} + +type Account struct { + Name string `yaml:"name"` + Email string `yaml:"email"` + Provider string `yaml:"provider"` + Username string `yaml:"username"` + Password string `yaml:"password"` + IMAP IMAPConfig `yaml:"imap"` + SMTP SMTPConfig `yaml:"smtp"` + SMTPEncryption string `yaml:"smtp_encryption"` +} + +type IMAPConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + Username string `yaml:"username"` + Password string `yaml:"password"` + Encryption string `yaml:"encryption"` + InsecureSkipVerify bool `yaml:"insecure"` +} + +type SMTPConfig struct { + Host string `yaml:"host"` + Port int `yaml:"port"` + Username string `yaml:"username"` + Password string `yaml:"password"` + Encryption string `yaml:"encryption"` + InsecureSkipVerify bool `yaml:"insecure"` +} + +var defaultConfig = Config{ + From: "", + Signature: "", + SMTP: SMTPConfig{ + Host: "", + Port: 587, + Username: "", + Password: "", + Encryption: "starttls", + InsecureSkipVerify: false, + }, + UnsafeHTML: false, +} + +var emailProviders = map[string]SMTPConfig{ + "163": { + Host: "smtp.163.com", + Port: 465, + Encryption: "ssl", + }, + "QQ": { + Host: "smtp.qq.com", + Port: 465, + Encryption: "ssl", + }, + "Gmail": { + Host: "smtp.gmail.com", + Port: 465, + Encryption: "ssl", + }, + "Outlook": { + Host: "smtp.office365.com", + Port: 587, + Encryption: "starttls", + }, +} + +var providerDefaults = map[string]struct { + IMAPHost string + IMAPPort int + IMAPEncryption string + SMTPHost string + SMTPPort int + SMTPEncryption string +}{ + "163": { + IMAPHost: "imap.163.com", + IMAPPort: 993, + IMAPEncryption: "ssl", + SMTPHost: "smtp.163.com", + SMTPPort: 465, + SMTPEncryption: "ssl", + }, + "188": { + IMAPHost: "imap.188.com", + IMAPPort: 993, + IMAPEncryption: "ssl", + SMTPHost: "smtp.188.com", + SMTPPort: 465, + SMTPEncryption: "ssl", + }, + "QQ": { + IMAPHost: "imap.qq.com", + IMAPPort: 993, + IMAPEncryption: "ssl", + SMTPHost: "smtp.qq.com", + SMTPPort: 465, + SMTPEncryption: "ssl", + }, + "Gmail": { + IMAPHost: "imap.gmail.com", + IMAPPort: 993, + IMAPEncryption: "ssl", + SMTPHost: "smtp.gmail.com", + SMTPPort: 465, + SMTPEncryption: "ssl", + }, + "Outlook": { + IMAPHost: "outlook.office365.com", + IMAPPort: 993, + IMAPEncryption: "ssl", + SMTPHost: "smtp.office365.com", + SMTPPort: 587, + SMTPEncryption: "starttls", + }, +} + +var imapProviders = map[string]IMAPConfig{ + "163": { + Host: "imap.163.com", + Port: 993, + Username: "", + Password: "", + }, + "QQ": { + Host: "imap.qq.com", + Port: 993, + Username: "", + Password: "", + }, + "Gmail": { + Host: "imap.gmail.com", + Port: 993, + Username: "", + Password: "", + }, + "Outlook": { + Host: "outlook.office365.com", + Port: 993, + Username: "", + Password: "", + }, +} + +func normalizeAccount(acc Account) Account { + if acc.Provider == "" { + acc.Provider = getProviderName(acc.Email) + } + + if defaults, ok := providerDefaults[acc.Provider]; ok { + if acc.IMAP.Host == "" { + acc.IMAP.Host = defaults.IMAPHost + acc.IMAP.Port = defaults.IMAPPort + } + if acc.IMAP.Encryption == "" && defaults.IMAPEncryption != "" { + acc.IMAP.Encryption = defaults.IMAPEncryption + } + if acc.SMTP.Host == "" { + acc.SMTP.Host = defaults.SMTPHost + acc.SMTP.Port = defaults.SMTPPort + } + if acc.SMTPEncryption == "" { + acc.SMTPEncryption = defaults.SMTPEncryption + } + if acc.SMTP.Encryption == "" && acc.SMTPEncryption != "" { + acc.SMTP.Encryption = acc.SMTPEncryption + } + } + + if acc.Username == "" { + acc.Username = acc.Email + } + if acc.IMAP.Username == "" { + acc.IMAP.Username = acc.Username + } + if acc.IMAP.Password == "" { + acc.IMAP.Password = acc.Password + } + if acc.SMTP.Username == "" { + acc.SMTP.Username = acc.Username + } + if acc.SMTP.Password == "" { + acc.SMTP.Password = acc.Password + } + + if acc.Name == "" { + acc.Name = acc.Provider + } + + return acc +} + +func isValidEmail(email string) bool { + return strings.Contains(email, "@") && strings.Contains(email, ".") +} + +func getValidFromAddress(from, smtpUsername string) string { + if isValidEmail(from) { + return from + } + if isValidEmail(smtpUsername) { + return smtpUsername + } + return from +} + +func getConfigPath() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + + configDir := filepath.Join(homeDir, ".config", "pop") + return filepath.Join(configDir, configFileName), nil +} + +func getConfigDir() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(homeDir, ".config", "pop"), nil +} + +func loadConfig() (Config, error) { + configPath, err := getConfigPath() + if err != nil { + return defaultConfig, err + } + + data, err := os.ReadFile(configPath) + if err != nil { + if os.IsNotExist(err) { + return defaultConfig, nil + } + return defaultConfig, err + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return defaultConfig, err + } + + return cfg, nil +} + +func saveConfig(cfg Config) error { + configDir, err := getConfigDir() + if err != nil { + return err + } + + if err := os.MkdirAll(configDir, 0755); err != nil { + return err + } + + configPath, err := getConfigPath() + if err != nil { + return err + } + + data, err := yaml.Marshal(&cfg) + if err != nil { + return err + } + + return os.WriteFile(configPath, data, 0644) +} + +func configExists() bool { + configPath, err := getConfigPath() + if err != nil { + return false + } + _, err = os.Stat(configPath) + return err == nil +} + +func getAccounts() ([]Account, error) { + cfg, err := loadConfig() + if err != nil { + return nil, err + } + + var accounts []Account + + if cfg.SMTP.Username != "" { + accounts = append(accounts, Account{ + Name: getProviderName(cfg.SMTP.Username), + Email: cfg.SMTP.Username, + SMTP: cfg.SMTP, + IMAP: IMAPConfig{ + Host: getIMAPHost(cfg.SMTP.Username), + Port: 993, + Username: cfg.SMTP.Username, + Password: cfg.SMTP.Password, + }, + }) + } + + accounts = append(accounts, cfg.Accounts...) + + for i := range accounts { + accounts[i] = normalizeAccount(accounts[i]) + } + + return accounts, nil +} + +func getProviderName(email string) string { + if strings.HasSuffix(email, "@163.com") || strings.HasSuffix(email, "@vip.163.com") { + return "163" + } + if strings.HasSuffix(email, "@188.com") || strings.HasSuffix(email, "@vip.188.com") { + return "188" + } + if strings.HasSuffix(email, "@qq.com") || strings.HasSuffix(email, "@vip.qq.com") { + return "QQ" + } + if strings.HasSuffix(email, "@gmail.com") { + return "Gmail" + } + if strings.HasSuffix(email, "@outlook.com") || strings.HasSuffix(email, "@office365.com") { + return "Outlook" + } + parts := strings.Split(email, "@") + if len(parts) > 1 { + return strings.Split(parts[1], ".")[0] + } + return "Email" +} + +func getIMAPHost(email string) string { + if strings.HasSuffix(email, "@163.com") { + return "imap.163.com" + } + if strings.HasSuffix(email, "@qq.com") { + return "imap.qq.com" + } + if strings.HasSuffix(email, "@gmail.com") { + return "imap.gmail.com" + } + if strings.HasSuffix(email, "@outlook.com") || strings.HasSuffix(email, "@office365.com") { + return "outlook.office365.com" + } + return "" +} diff --git a/doc/001-feature-planning.md b/doc/001-feature-planning.md new file mode 100644 index 0000000..6276870 --- /dev/null +++ b/doc/001-feature-planning.md @@ -0,0 +1,99 @@ +# Pop 功能规划讨论 + +**日期**: 2026-04-09 + +## 需求概述 + +增加两个核心功能: +1. **发送历史记录** - 使用 SQLite 存储已发送/失败的邮件 +2. **邮件接收功能** - 通过 IMAP 获取收件箱中未读邮件 + +## 详细设计 + +### 1. 发送历史记录 + +- 使用 SQLite (`history.db`) 存储 +- 字段:发件人、收件人、CC、BCC、主题、正文(纯文本/HTML)、附件、状态(已发送/草稿)、发送时间、交付方式 +- 发送成功 → 标记 "sent",发送失败 → 标记 "draft" + +### 2. 邮件接收功能 + +- 使用 IMAP 协议连接邮箱 +- 仅获取 **7天内未读** 的邮件 +- 不下载完整内容到本地,每次从 IMAP 拉取最新数据(轻量版) +- 支持多账户,合并展示 + +### 3. 收件箱看板 (Inbox UI) + +- 新命令: `pop inbox` +- 使用 `bubbles/list` 组件 +- 显示格式: `<来源> · <发件人> - <主题> <时间>` +- 支持搜索/过滤 (输入 "163" 可过滤来源) + +### 4. 配置扩展 + +```yaml +accounts: + - name: "工作邮箱" + email: "user@163.com" + imap: + host: "imap.163.com" + port: 993 + username: "user@163.com" + password: "xxx" + smtp: + host: "smtp.163.com" + port: 465 + username: "user@163.com" + password: "xxx" + encryption: "ssl" + +# 兼容原有单账户配置 +from: "user@163.com" +smtp: + host: "smtp.163.com" + ... +``` + +## 常见邮箱 IMAP 配置 + +| 运营商 | IMAP Host | Port | +|--------|-----------|------| +| 163 | imap.163.com | 993 | +| QQ | imap.qq.com | 993 | +| Gmail | imap.gmail.com | 993 | +| Outlook | outlook.office365.com | 993 | + +## 文件变更计划 + +### 新增文件 +- `history.go` - SQLite 操作 +- `imap.go` - IMAP 接收 +- `inbox.go` - 收件箱 TUI 入口 +- `inbox/model.go` - 收件箱 Model + +### 修改文件 +- `config.go` - 支持多账户 +- `email.go` - 发送后记录历史 +- `main.go` - 新增 inbox/history 子命令 + +### 新增依赖 +- `github.com/mattn/go-sqlite3` +- `github.com/mattn/go-imap` + +## 命令行接口 + +```bash +pop inbox # 打开收件箱 +pop history # 查看发送历史 +pop history --draft # 只看草稿 +``` + +## 待定问题 + +- [x] 发送历史存储哪些信息 - 完整信息 +- [x] 收件箱针对所有账户还是单一账户 - 合并收件箱 +- [x] 新功能是否修改现有 TUI - 独立命令 +- [x] 搜索功能 - 使用 list 组件内置 Filter +- [x] 来源标识 - `<来源> · <主题>` 格式 +- [x] 是否本地存储已接收邮件 - 轻量版,不存储 \ No newline at end of file diff --git a/doc/002-config-simplification.md b/doc/002-config-simplification.md new file mode 100644 index 0000000..ed82522 --- /dev/null +++ b/doc/002-config-simplification.md @@ -0,0 +1,67 @@ +# Pop 配置简化讨论 + +**日期**: 2026-04-09 + +## 背景 + +之前的规划中,配置结构有冗余: +- IMAP 和 SMTP 各需要独立的 username/password +- host/port 需要用户手动填写 + +## 讨论内容 + +### 简化方案 + +用户建议采用简化配置: + +```yaml +accounts: + - name: "工作邮箱" + email: "user@163.com" + provider: "163" # 自动填充 imap/smtp + username: "user@163.com" # 统一认证 + password: "xxx" + smtp_encryption: "ssl" # 可选 +``` + +### 自动识别逻辑 + +1. **自动识别 Provider**: 通过邮箱后缀自动判断 + - `@163.com` → 163 + - `@qq.com` → QQ + - `@gmail.com` → Gmail + - `@outlook.com` / `@office365.com` → Outlook + - 其他 → custom + +2. **Provider 不匹配时**: 视为 custom,需手动配置 host/port + +### Onboarding 流程 + +``` +1. 输入邮箱地址 + ↓ +2. 自动识别 provider + ↓ +3. ┌─ 已知服务商 → 显示默认配置,提示确认 + └─ 自定义邮箱 → 提示手动配置 IMAP/SMTP + ↓ +4. 输入密码 + ↓ +5. 保存配置 +``` + +## 实现计划 + +| 步骤 | 文件 | 修改内容 | +|------|------|---------| +| 1 | config.go | 新增 Provider、SMTPEncryption 字段 | +| 2 | config.go | 添加 normalizeAccount() 自动填充默认值 | +| 3 | config.go | 添加 providerDefaults 映射表 | +| 4 | config.go | 修改 getAccounts() 调用 normalize | +| 5 | onboarding.go | 修改交互流程,支持自动识别和默认值 | + +## 待处理 + +- [ ] 实现 config.go 修改 +- [ ] 实现 onboarding.go 流程调整 +- [ ] 测试配置读取和写入 \ No newline at end of file diff --git a/email.go b/email.go index 3b4efb2..d05b26d 100644 --- a/email.go +++ b/email.go @@ -40,6 +40,39 @@ func (m Model) sendEmailCmd() tea.Cmd { to := strings.Split(m.To.Value(), ToSeparator) cc := strings.Split(m.Cc.Value(), ToSeparator) bcc := strings.Split(m.Bcc.Value(), ToSeparator) + + deliveryMethod := "smtp" + if m.DeliveryMethod == Resend { + deliveryMethod = "resend" + } + + toStr := m.To.Value() + ccStr := m.Cc.Value() + bccStr := m.Bcc.Value() + bodyText := m.Body.Value() + + html := bytes.NewBufferString("") + goldmark.Convert([]byte(bodyText), html) + bodyHTML := html.String() + + attachmentsJSON := getAttachmentsJSON(attachments) + + emailID, saveErr := SaveEmail( + m.From.Value(), + toStr, + ccStr, + bccStr, + m.Subject.Value(), + bodyText, + bodyHTML, + attachmentsJSON, + deliveryMethod, + StatusDraft, + ) + if saveErr != nil { + fmt.Fprintf(os.Stderr, "Failed to save email to history: %v\n", saveErr) + } + switch m.DeliveryMethod { case SMTP: err = sendSMTPEmail(to, cc, bcc, m.From.Value(), m.Subject.Value(), m.Body.Value(), attachments) @@ -55,6 +88,13 @@ func (m Model) sendEmailCmd() tea.Cmd { } return sendEmailFailureMsg(err) } + + if emailID > 0 { + if updateErr := UpdateEmailStatus(emailID, StatusSent); updateErr != nil { + fmt.Fprintf(os.Stderr, "Failed to update email status: %v\n", updateErr) + } + } + return sendEmailSuccessMsg{} } } @@ -64,6 +104,8 @@ const gmailSMTPHost = "smtp.gmail.com" const gmailSMTPPort = 587 func sendSMTPEmail(to, cc, bcc []string, from, subject, body string, attachments []string) error { + validFrom := getValidFromAddress(from, smtpUsername) + server := mail.NewSMTPClient() var err error @@ -92,8 +134,8 @@ func sendSMTPEmail(to, cc, bcc []string, from, subject, body string, attachments } server.KeepAlive = false - server.ConnectTimeout = 10 * time.Second - server.SendTimeout = 10 * time.Second + server.ConnectTimeout = 30 * time.Second + server.SendTimeout = 30 * time.Second server.TLSConfig = &tls.Config{ InsecureSkipVerify: smtpInsecureSkipVerify, //nolint:gosec ServerName: server.Host, @@ -102,11 +144,11 @@ func sendSMTPEmail(to, cc, bcc []string, from, subject, body string, attachments smtpClient, err := server.Connect() if err != nil { - return err + return fmt.Errorf("SMTP连接失败: %w", err) } email := mail.NewMSG() - email.SetFrom(from). + email.SetFrom(validFrom). AddTo(to...). AddCc(cc...). AddBcc(bcc...). diff --git a/go.mod b/go.mod index 48f9bd4..da8c9de 100644 --- a/go.mod +++ b/go.mod @@ -1,48 +1,83 @@ module github.com/charmbracelet/pop -go 1.24.2 +go 1.26.1 require ( + charm.land/huh/v2 v2.0.3 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/exp/ordered v0.1.0 + github.com/emersion/go-imap/v2 v2.0.0-beta.8 + github.com/mattn/go-sqlite3 v1.14.42 github.com/muesli/mango-cobra v1.3.0 github.com/muesli/roff v0.1.0 github.com/resendlabs/resend-go v1.7.0 github.com/spf13/cobra v1.10.2 github.com/xhit/go-simple-mail/v2 v2.16.0 github.com/yuin/goldmark v1.7.16 + gopkg.in/yaml.v3 v3.0.1 ) require ( + charm.land/bubbles/v2 v2.0.0 // indirect + charm.land/bubbletea/v2 v2.0.2 // indirect + charm.land/lipgloss/v2 v2.0.1 // indirect + github.com/BrianLeishman/go-imap v0.1.27 // indirect + github.com/StirlingMarketingGroup/go-retry v0.0.0-20190512160921-94a8eb23e893 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/catppuccin/go v0.2.0 // indirect + github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.9.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/emersion/go-message v0.18.2 // indirect + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/go-test/deep v1.1.0 // indirect + github.com/fatih/color v1.19.0 // indirect + github.com/go-test/deep v1.1.1 // indirect + github.com/goccy/go-json v0.10.6 // indirect + github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect + github.com/inbucket/html2text v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jhillyerd/enmime/v2 v2.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.21 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/mango v0.2.0 // indirect github.com/muesli/mango-pflag v0.1.0 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect + github.com/olekukonko/errors v1.2.0 // indirect + github.com/olekukonko/ll v0.1.8 // indirect + github.com/olekukonko/tablewriter v1.1.4 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/rs/xid v1.6.0 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/sqs/go-xoauth2 v0.0.0-20120917012134-0911dad68e56 // indirect + github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/text v0.16.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect ) diff --git a/go.sum b/go.sum index 082276b..61087b9 100644 --- a/go.sum +++ b/go.sum @@ -1,56 +1,114 @@ +charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= +charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= +charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= +charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= +charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= +charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys= +charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= +github.com/BrianLeishman/go-imap v0.1.27 h1:FjgRwijsf5Cmovu8S6avu0TykP77WN8hZHnutVXvXgg= +github.com/BrianLeishman/go-imap v0.1.27/go.mod h1:ftFHqYP7XUPeo3XhTpHpokQ+392Vz6GVxvjxykL5E2I= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/StirlingMarketingGroup/go-retry v0.0.0-20190512160921-94a8eb23e893 h1:y1OlgL2twHNQGJ4OTHhvVLebgDCwP4pttmZc2w4UAz8= +github.com/StirlingMarketingGroup/go-retry v0.0.0-20190512160921-94a8eb23e893/go.mod h1:RHK0VFlYDZQeNFg4C2dp7cPE6urfbpgyEZIGxa9f5zw= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= -github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= +github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI= +github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= -github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= +github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= -github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= -github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= +github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.1+incompatible/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= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug= +github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48= +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-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= +github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs= +github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14= +github.com/inbucket/html2text v1.0.0 h1:N5kza++4uBBDJ2Z3KUnTRyPNoBcW+YfOgNiNmNB+sgs= +github.com/inbucket/html2text v1.0.0/go.mod h1:5TrhXQKGU+LXurODaSm55Y9eXoPBRnYiOz4x2XfUoJU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jhillyerd/enmime/v2 v2.3.0 h1:Y/pzQanyU8nkSgB2npXX8Dha5OItJE/QwbDJM4sf/kU= +github.com/jhillyerd/enmime/v2 v2.3.0/go.mod h1:mGKXAP45l6pF6HZiaLhgSYsgteJskaSIYmEZXpw6ZpI= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= +github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= +github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= +github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -65,12 +123,24 @@ github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= +github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo= +github.com/olekukonko/errors v1.2.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8= +github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw= +github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I= +github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/resendlabs/resend-go v1.7.0 h1:DycOqSXtw2q7aB+Nt9DDJUDtaYcrNPGn1t5RFposas0= github.com/resendlabs/resend-go v1.7.0/go.mod h1:yip1STH7Bqfm4fD0So5HgyNbt5taG5Cplc4xXxETyLI= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= @@ -78,6 +148,10 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/sqs/go-xoauth2 v0.0.0-20120917012134-0911dad68e56 h1:KCgKdj+ha4CgnVHIiJYGKzgZk3HfCc6XssESfOa6atM= +github.com/sqs/go-xoauth2 v0.0.0-20120917012134-0911dad68e56/go.mod h1:ghDEBrT4oFcM4rv18bzcZaAWXbHPGpDa4e2hh9oXL8A= +github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= +github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= @@ -87,17 +161,57 @@ github.com/xhit/go-simple-mail/v2 v2.16.0 h1:ouGy/Ww4kuaqu2E2UrDw7SvLaziWTB60ICL github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +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/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +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.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +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/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.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.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +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/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/history.go b/history.go new file mode 100644 index 0000000..9630306 --- /dev/null +++ b/history.go @@ -0,0 +1,221 @@ +package main + +import ( + "database/sql" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +const historyDBName = "history.db" + +type EmailStatus string + +const ( + StatusSent EmailStatus = "sent" + StatusDraft EmailStatus = "draft" +) + +type EmailHistory struct { + ID int64 + From string + To string + Cc string + Bcc string + Subject string + BodyText string + BodyHTML string + Attachments string + Status EmailStatus + DeliveryMethod string + CreatedAt time.Time + SentAt *time.Time +} + +func getHistoryDBPath() (string, error) { + configDir, err := getConfigDir() + if err != nil { + return "", err + } + return filepath.Join(configDir, historyDBName), nil +} + +func initHistoryDB() (*sql.DB, error) { + dbPath, err := getHistoryDBPath() + if err != nil { + return nil, err + } + + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + return nil, err + } + + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS emails ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_addr TEXT NOT NULL, + to_addrs TEXT NOT NULL, + cc_addrs TEXT, + bcc_addrs TEXT, + subject TEXT NOT NULL, + body_text TEXT, + body_html TEXT, + attachments TEXT, + status TEXT NOT NULL, + delivery_method TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + sent_at DATETIME + ) + `) + if err != nil { + return nil, err + } + + return db, nil +} + +func SaveEmail(from, to, cc, bcc, subject, bodyText, bodyHTML, attachments, deliveryMethod string, status EmailStatus) (int64, error) { + db, err := initHistoryDB() + if err != nil { + return 0, err + } + defer db.Close() + + stmt, err := db.Prepare(` + INSERT INTO emails (from_addr, to_addrs, cc_addrs, bcc_addrs, subject, body_text, body_html, attachments, status, delivery_method, created_at, sent_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + if err != nil { + return 0, err + } + defer stmt.Close() + + now := time.Now() + var sentAt *time.Time + if status == StatusSent { + sentAt = &now + } + result, err := stmt.Exec(from, to, cc, bcc, subject, bodyText, bodyHTML, attachments, status, deliveryMethod, now, sentAt) + if err != nil { + return 0, err + } + + return result.LastInsertId() +} + +func UpdateEmailStatus(id int64, status EmailStatus) error { + db, err := initHistoryDB() + if err != nil { + return err + } + defer db.Close() + + _, err = db.Exec("UPDATE emails SET status = ?, sent_at = ? WHERE id = ?", status, time.Now(), id) + return err +} + +func GetEmailHistory(status EmailStatus) ([]EmailHistory, error) { + db, err := initHistoryDB() + if err != nil { + return nil, err + } + defer db.Close() + + var query string + var args []interface{} + + if status == "" { + query = "SELECT id, from_addr, to_addrs, cc_addrs, bcc_addrs, subject, body_text, body_html, attachments, status, delivery_method, created_at, sent_at FROM emails ORDER BY created_at DESC" + } else { + query = "SELECT id, from_addr, to_addrs, cc_addrs, bcc_addrs, subject, body_text, body_html, attachments, status, delivery_method, created_at, sent_at FROM emails WHERE status = ? ORDER BY created_at DESC" + args = []interface{}{status} + } + + rows, err := db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var histories []EmailHistory + for rows.Next() { + var h EmailHistory + var sentAt sql.NullTime + err := rows.Scan(&h.ID, &h.From, &h.To, &h.Cc, &h.Bcc, &h.Subject, &h.BodyText, &h.BodyHTML, &h.Attachments, &h.Status, &h.DeliveryMethod, &h.CreatedAt, &sentAt) + if err != nil { + return nil, err + } + if sentAt.Valid { + h.SentAt = &sentAt.Time + } + histories = append(histories, h) + } + + return histories, nil +} + +func GetEmailByID(id int64) (*EmailHistory, error) { + db, err := initHistoryDB() + if err != nil { + return nil, err + } + defer db.Close() + + var h EmailHistory + var sentAt sql.NullTime + err = db.QueryRow("SELECT id, from_addr, to_addrs, cc_addrs, bcc_addrs, subject, body_text, body_html, attachments, status, delivery_method, created_at, sent_at FROM emails WHERE id = ?", id).Scan( + &h.ID, &h.From, &h.To, &h.Cc, &h.Bcc, &h.Subject, &h.BodyText, &h.BodyHTML, &h.Attachments, &h.Status, &h.DeliveryMethod, &h.CreatedAt, &sentAt, + ) + if err != nil { + return nil, err + } + if sentAt.Valid { + h.SentAt = &sentAt.Time + } + return &h, nil +} + +func DeleteEmailHistory(id int64) error { + db, err := initHistoryDB() + if err != nil { + return err + } + defer db.Close() + + _, err = db.Exec("DELETE FROM emails WHERE id = ?", id) + return err +} + +func getAttachmentsJSON(paths []string) string { + if len(paths) == 0 { + return "[]" + } + data, _ := json.Marshal(paths) + return string(data) +} + +func parseAttachmentsJSON(jsonStr string) []string { + if jsonStr == "" || jsonStr == "[]" { + return nil + } + var paths []string + json.Unmarshal([]byte(jsonStr), &paths) + return paths +} + +func init() { + dbPath, err := getHistoryDBPath() + if err != nil { + return + } + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + if _, err := initHistoryDB(); err != nil { + fmt.Fprintf(os.Stderr, "Failed to initialize history DB: %v\n", err) + } + } +} diff --git a/history_cmd.go b/history_cmd.go new file mode 100644 index 0000000..146cc7f --- /dev/null +++ b/history_cmd.go @@ -0,0 +1,187 @@ +package main + +import ( + "fmt" + "io" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type HistoryItem struct { + id int64 + from string + to string + subject string + status string + createdAt string + sentAt string +} + +func (h HistoryItem) FilterValue() string { + return fmt.Sprintf("%s %s %s", h.from, h.to, h.subject) +} + +func (h HistoryItem) Title() string { + statusIcon := "📤" + if h.status == "draft" { + statusIcon = "📝" + } + return fmt.Sprintf("%s %s -> %s", statusIcon, h.from, h.to) +} + +func (h HistoryItem) Description() string { + return h.subject +} + +type HistoryModel struct { + list list.Model + items []HistoryItem + loading bool + err error +} + +func runHistory() error { + m := NewHistoryModel() + m.loading = true + + histories, err := GetEmailHistory("") + if err != nil { + m.err = err + m.loading = false + p := tea.NewProgram(m) + if _, err := p.Run(); err != nil { + return err + } + return nil + } + + items := make([]list.Item, len(histories)) + for i, h := range histories { + status := "sent" + if h.Status == "draft" { + status = "draft" + } + sentAtStr := "" + if h.SentAt != nil { + sentAtStr = h.SentAt.Format("2006-01-02 15:04") + } + items[i] = HistoryItem{ + id: h.ID, + from: h.From, + to: h.To, + subject: h.Subject, + status: status, + createdAt: h.CreatedAt.Format("2006-01-02 15:04"), + sentAt: sentAtStr, + } + } + + m.items = make([]HistoryItem, len(items)) + for i := range items { + m.items[i] = items[i].(HistoryItem) + } + m.list.SetItems(items) + m.loading = false + + p := tea.NewProgram(m) + if _, err := p.Run(); err != nil { + return fmt.Errorf("failed to run history: %w", err) + } + + return nil +} + +func NewHistoryModel() *HistoryModel { + l := list.New(nil, historyDelegate{}, 0, 10) + l.Title = "发送历史" + l.Styles.Title = historyTitleStyle + l.Styles.NoItems = historyNoItemsStyle + l.SetShowHelp(true) + l.SetShowPagination(false) + + return &HistoryModel{ + list: l, + items: []HistoryItem{}, + } +} + +func (m *HistoryModel) Init() tea.Cmd { + return nil +} + +func (m *HistoryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "ctrl+c" { + return m, tea.Quit + } + case tea.WindowSizeMsg: + m.list.SetWidth(msg.Width) + } + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m *HistoryModel) View() string { + if m.loading { + return historyLoadingStyle.Render("正在加载历史...") + } + if m.err != nil { + return historyErrorStyle.Render(fmt.Sprintf("错误: %v", m.err)) + } + if len(m.items) == 0 { + return historyNoItemsStyle.Render("暂无发送历史") + } + return m.list.View() +} + +var ( + historyTitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("86")). + Bold(true) + + historyNoItemsStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")). + Padding(1, 2) + + historyLoadingStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("86")). + Padding(1, 2) + + historyErrorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + Padding(1, 2) + + historyItemTitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("255")) + + historyItemDescStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")) +) + +type historyDelegate struct{} + +func (d historyDelegate) Height() int { return 1 } +func (d historyDelegate) Spacing() int { return 0 } +func (d historyDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } +func (d historyDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { + h, ok := item.(HistoryItem) + if !ok { + return + } + + isSelected := index == m.Index() + + titleStyle := historyItemTitleStyle + descStyle := historyItemDescStyle + + if isSelected { + titleStyle = titleStyle.Background(lipgloss.Color("68")) + descStyle = descStyle.Background(lipgloss.Color("68")) + } + + fmt.Fprintf(w, "%s\n%s", titleStyle.Render(h.Title()), descStyle.Render(h.Description())) +} diff --git a/imap.go b/imap.go new file mode 100644 index 0000000..48d5abd --- /dev/null +++ b/imap.go @@ -0,0 +1,181 @@ +package main + +import ( + "fmt" + "time" + + imap "github.com/BrianLeishman/go-imap" +) + +type ReceivedEmail struct { + UID uint32 + From string + FromName string + Subject string + Date time.Time + Preview string + Account string + AccountID string +} + +func getEmailProviderName(email string) string { + if len(email) == 0 { + return "Email" + } + atIdx := -1 + for i := len(email) - 1; i >= 0; i-- { + if email[i] == '@' { + atIdx = i + break + } + } + if atIdx == -1 { + return "Email" + } + domain := email[atIdx+1:] + for i := len(domain) - 1; i >= 0; i-- { + if domain[i] == '.' { + return domain[:i] + } + } + return domain +} + +func FetchUnreadEmails(account Account, days int) ([]ReceivedEmail, error) { + if account.IMAP.Host == "" { + return nil, fmt.Errorf("IMAP host not configured for account: %s", account.Email) + } + + port := account.IMAP.Port + if port == 0 { + port = 993 + } + + username := account.IMAP.Username + if username == "" { + username = account.Email + } + + m, err := imap.New(username, account.IMAP.Password, account.IMAP.Host, port) + if err != nil { + return nil, fmt.Errorf("failed to connect to IMAP server: %w", err) + } + defer m.Close() + + err = m.SelectFolder("INBOX") + if err != nil { + return nil, fmt.Errorf("failed to select inbox: %w", err) + } + + sinceDate := time.Now().AddDate(0, 0, -days) + + uids, err := m.GetUIDs(imap.Search().Since(sinceDate).Unseen().Build()) + if err != nil { + return nil, fmt.Errorf("failed to search emails: %w", err) + } + + if len(uids) == 0 { + return nil, nil + } + + overviews, err := m.GetOverviews(uids...) + if err != nil { + return nil, fmt.Errorf("failed to fetch emails: %w", err) + } + + var emails []ReceivedEmail + accountName := getEmailProviderName(account.Email) + accountID := account.Email + if account.Name != "" { + accountName = account.Name + accountID = account.Name + } + + for uid, email := range overviews { + from := "" + if len(email.From) > 0 { + for _, addr := range email.From { + from = addr + break + } + } + + emails = append(emails, ReceivedEmail{ + UID: uint32(uid), + From: from, + FromName: "", + Subject: email.Subject, + Date: email.Sent, + Preview: fmt.Sprintf("(%.1f KB)", float64(email.Size)/1024), + Account: accountName, + AccountID: accountID, + }) + } + + return emails, nil +} + +func GetAccountByEmail(email string) (Account, error) { + accounts, err := getAccounts() + if err != nil { + return Account{}, err + } + + for _, acc := range accounts { + if acc.Email == email || acc.Name == email { + return acc, nil + } + } + + return Account{}, fmt.Errorf("account not found: %s", email) +} + +func GetAccountByID(id string) (Account, error) { + accounts, err := getAccounts() + if err != nil { + return Account{}, err + } + + for _, acc := range accounts { + if acc.Email == id || acc.Name == id { + return acc, nil + } + } + + return Account{}, fmt.Errorf("account not found: %s", id) +} + +func FetchAllUnreadEmails(days int) ([]ReceivedEmail, error) { + accounts, err := getAccounts() + if err != nil { + return nil, err + } + + if len(accounts) == 0 { + return nil, fmt.Errorf("no accounts configured") + } + + var allEmails []ReceivedEmail + + for _, account := range accounts { + emails, err := FetchUnreadEmails(account, days) + if err != nil { + continue + } + allEmails = append(allEmails, emails...) + } + + sortEmailsByDate(allEmails) + + return allEmails, nil +} + +func sortEmailsByDate(emails []ReceivedEmail) { + for i := 0; i < len(emails)-1; i++ { + for j := i + 1; j < len(emails); j++ { + if emails[j].Date.After(emails[i].Date) { + emails[i], emails[j] = emails[j], emails[i] + } + } + } +} diff --git a/inbox.go b/inbox.go new file mode 100644 index 0000000..64fffb5 --- /dev/null +++ b/inbox.go @@ -0,0 +1,48 @@ +package main + +import ( + "fmt" + + "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/pop/inbox" +) + +func runInbox(days int) error { + m := inbox.NewInboxModel() + m.SetLoading(true) + + emails, err := FetchAllUnreadEmails(days) + if err != nil { + m.SetError(err) + m.SetLoading(false) + p := tea.NewProgram(m) + if _, err := p.Run(); err != nil { + return err + } + return nil + } + + items := make([]inbox.EmailItem, len(emails)) + for i, e := range emails { + items[i] = inbox.EmailItem{ + UID: e.UID, + From: e.From, + FromName: e.FromName, + Subject: e.Subject, + Date: e.Date, + Preview: e.Preview, + Account: e.Account, + AccountID: e.AccountID, + } + } + + m.SetEmails(items) + m.SetLoading(false) + + p := tea.NewProgram(m) + if _, err := p.Run(); err != nil { + return fmt.Errorf("failed to run inbox: %w", err) + } + + return nil +} diff --git a/inbox/model.go b/inbox/model.go new file mode 100644 index 0000000..e855abb --- /dev/null +++ b/inbox/model.go @@ -0,0 +1,189 @@ +package inbox + +import ( + "fmt" + "io" + "time" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +type EmailItem struct { + UID uint32 + From string + FromName string + Subject string + Date time.Time + Preview string + Account string + AccountID string +} + +func (e EmailItem) FilterValue() string { + return fmt.Sprintf("%s %s %s %s", e.Account, e.FromName, e.From, e.Subject) +} + +func (e EmailItem) Title() string { + return fmt.Sprintf("%s · %s - %s", e.Account, e.FromName, e.Subject) +} + +func (e EmailItem) Description() string { + return e.Preview +} + +func formatTimeAgo(d time.Duration) string { + if d < time.Minute { + return "刚刚" + } + if d < time.Hour { + mins := int(d.Minutes()) + return fmt.Sprintf("%d分钟前", mins) + } + if d < 24*time.Hour { + hours := int(d.Hours()) + return fmt.Sprintf("%d小时前", hours) + } + if d < 7*24*time.Hour { + days := int(d.Hours() / 24) + return fmt.Sprintf("%d天前", days) + } + return d.Truncate(24 * time.Hour).String() +} + +type InboxModel struct { + list list.Model + emails []EmailItem + loading bool + err error + selectedEmail *EmailItem +} + +func NewInboxModel() *InboxModel { + l := list.New(nil, emailDelegate{}, 0, 10) + l.Title = "收件箱" + l.Styles.Title = inboxTitleStyle + l.Styles.NoItems = inboxNoItemsStyle + l.SetShowHelp(true) + l.SetShowPagination(false) + + return &InboxModel{ + list: l, + emails: []EmailItem{}, + } +} + +func (m *InboxModel) SetEmails(emails []EmailItem) { + m.emails = emails + items := make([]list.Item, len(emails)) + for i, e := range emails { + items[i] = e + } + m.list.SetItems(items) + m.list.Title = fmt.Sprintf("收件箱 (%d 封新邮件)", len(items)) +} + +func (m InboxModel) Init() tea.Cmd { + return nil +} + +func (m *InboxModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + if msg.String() == "ctrl+c" { + return m, tea.Quit + } + case tea.WindowSizeMsg: + m.list.SetWidth(msg.Width) + } + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m InboxModel) View() string { + if m.loading { + return loadingStyle.Render("正在加载邮件...") + } + if m.err != nil { + return errorStyle.Render(fmt.Sprintf("错误: %v", m.err)) + } + return m.list.View() +} + +func (m *InboxModel) SetLoading(loading bool) { + m.loading = loading +} + +func (m *InboxModel) SetError(err error) { + m.err = err +} + +func (m InboxModel) SelectedEmail() *EmailItem { + idx := m.list.Index() + if idx >= 0 && idx < len(m.emails) { + return &m.emails[idx] + } + return nil +} + +var ( + inboxTitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("86")). + Bold(true) + + inboxNoItemsStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")). + Padding(1, 2) + + loadingStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("86")). + Padding(1, 2) + + errorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("196")). + Padding(1, 2) +) + +type emailDelegate struct{} + +func (d emailDelegate) Height() int { return 1 } +func (d emailDelegate) Spacing() int { return 0 } +func (d emailDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { + return nil +} +func (d emailDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { + email, ok := item.(EmailItem) + if !ok { + return + } + + isSelected := index == m.Index() + + titleStyle := emailTitleStyle + descStyle := emailDescStyle + + if isSelected { + titleStyle = emailTitleStyleSelected + descStyle = emailDescStyleSelected + } + + fmt.Fprintf(w, "%s\n%s", titleStyle.Render(email.Title()), descStyle.Render(email.Description())) +} + +var ( + emailTitleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("255")) + + emailTitleStyleSelected = lipgloss.NewStyle(). + Foreground(lipgloss.Color("255")). + Background(lipgloss.Color("68")) + + emailDescStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")) + + emailDescStyleSelected = lipgloss.NewStyle(). + Foreground(lipgloss.Color("186")). + Background(lipgloss.Color("68")) +) diff --git a/main.go b/main.go index a820b78..ac56a05 100644 --- a/main.go +++ b/main.go @@ -74,6 +74,12 @@ var rootCmd = &cobra.Command{ Short: "Send emails from your terminal", Long: `Pop is a tool for sending emails from your terminal.`, RunE: func(cmd *cobra.Command, args []string) error { + if !configExists() && smtpUsername == "" && smtpPassword == "" && resendAPIKey == "" { + if err := runOnboarding(); err != nil { + return err + } + } + var deliveryMethod DeliveryMethod switch { case resendAPIKey != "" && smtpUsername != "" && smtpPassword != "": @@ -89,11 +95,11 @@ var rootCmd = &cobra.Command{ switch deliveryMethod { case None: - fmt.Printf("\n %s %s %s\n\n", errorHeaderStyle.String(), inlineCodeStyle.Render(ResendAPIKey), "environment variable is required.") - fmt.Printf(" %s %s\n\n", commentStyle.Render("You can grab one at"), linkStyle.Render("https://resend.com/api-keys")) + fmt.Printf("\n %s 未找到邮件配置\n\n", errorHeaderStyle.String()) + fmt.Printf(" %s 请运行 %s 进行首次配置\n\n", commentStyle.Render("提示:"), inlineCodeStyle.Render("pop --config")) cmd.SilenceUsage = true cmd.SilenceErrors = true - return errors.New("missing required environment variable") + return errors.New("missing mail configuration") case Unknown: fmt.Printf("\n %s Unknown delivery method.\n", errorHeaderStyle.String()) fmt.Printf("\n You have set both %s and %s delivery methods.", inlineCodeStyle.Render(ResendAPIKey), inlineCodeStyle.Render("POP_SMPT_*")) @@ -196,39 +202,112 @@ var ManCmd = &cobra.Command{ func init() { rootCmd.AddCommand(ManCmd) + cfg, _ := loadConfig() + rootCmd.Flags().StringSliceVar(&bcc, "bcc", []string{}, "BCC recipients") rootCmd.Flags().StringSliceVar(&cc, "cc", []string{}, "CC recipients") rootCmd.Flags().StringSliceVarP(&attachments, "attach", "a", []string{}, "Email's attachments") rootCmd.Flags().StringSliceVarP(&to, "to", "t", []string{}, "Recipients") rootCmd.Flags().StringVarP(&body, "body", "b", "", "Email's contents") envFrom := os.Getenv(PopFrom) + if envFrom == "" { + envFrom = cfg.From + } + from = envFrom rootCmd.Flags().StringVarP(&from, "from", "f", envFrom, "Email's sender"+commentStyle.Render("($"+PopFrom+")")) rootCmd.Flags().StringVarP(&subject, "subject", "s", "", "Email's subject") rootCmd.Flags().BoolVar(&preview, "preview", false, "Whether to preview the email before sending") envUnsafe := os.Getenv(PopUnsafeHTML) == "true" + if !envUnsafe { + envUnsafe = cfg.UnsafeHTML + } rootCmd.Flags().BoolVarP(&unsafe, "unsafe", "u", envUnsafe, "Whether to allow unsafe HTML in the email body, also enable some extra markdown features (Experimental)") envSignature := os.Getenv(PopSignature) + if envSignature == "" { + envSignature = cfg.Signature + } rootCmd.Flags().StringVarP(&signature, "signature", "x", envSignature, "Signature to display at the end of the email."+commentStyle.Render("($"+PopSignature+")")) envSMTPHost := os.Getenv(PopSMTPHost) + if envSMTPHost == "" { + envSMTPHost = cfg.SMTP.Host + } + smtpHost = envSMTPHost rootCmd.Flags().StringVarP(&smtpHost, "smtp.host", "H", envSMTPHost, "Host of the SMTP server"+commentStyle.Render("($"+PopSMTPHost+")")) envSMTPPort, _ := strconv.Atoi(os.Getenv(PopSMTPPort)) if envSMTPPort == 0 { - envSMTPPort = 587 + envSMTPPort = cfg.SMTP.Port + if envSMTPPort == 0 { + envSMTPPort = 587 + } } + smtpPort = envSMTPPort rootCmd.Flags().IntVarP(&smtpPort, "smtp.port", "P", envSMTPPort, "Port of the SMTP server"+commentStyle.Render("($"+PopSMTPPort+")")) envSMTPUsername := os.Getenv(PopSMTPUsername) + if envSMTPUsername == "" { + envSMTPUsername = cfg.SMTP.Username + } + smtpUsername = envSMTPUsername rootCmd.Flags().StringVarP(&smtpUsername, "smtp.username", "U", envSMTPUsername, "Username of the SMTP server"+commentStyle.Render("($"+PopSMTPUsername+")")) envSMTPPassword := os.Getenv(PopSMTPPassword) + if envSMTPPassword == "" { + envSMTPPassword = cfg.SMTP.Password + } + smtpPassword = envSMTPPassword rootCmd.Flags().StringVarP(&smtpPassword, "smtp.password", "p", envSMTPPassword, "Password of the SMTP server"+commentStyle.Render("($"+PopSMTPPassword+")")) envSMTPEncryption := os.Getenv(PopSMTPEncryption) + if envSMTPEncryption == "" { + envSMTPEncryption = cfg.SMTP.Encryption + if envSMTPEncryption == "" { + envSMTPEncryption = "starttls" + } + } + smtpEncryption = envSMTPEncryption rootCmd.Flags().StringVarP(&smtpEncryption, "smtp.encryption", "e", envSMTPEncryption, "Encryption type of the SMTP server (starttls, ssl, or none)"+commentStyle.Render("($"+PopSMTPEncryption+")")) envInsecureSkipVerify := os.Getenv(PopSMTPInsecureSkipVerify) == "true" + if !envInsecureSkipVerify { + envInsecureSkipVerify = cfg.SMTP.InsecureSkipVerify + } + smtpInsecureSkipVerify = envInsecureSkipVerify rootCmd.Flags().BoolVarP(&smtpInsecureSkipVerify, "smtp.insecure", "i", envInsecureSkipVerify, "Skip TLS verification with SMTP server"+commentStyle.Render("($"+PopSMTPInsecureSkipVerify+")")) envResendAPIKey := os.Getenv(ResendAPIKey) rootCmd.Flags().StringVarP(&resendAPIKey, "resend.key", "r", envResendAPIKey, "API key for the Resend.com"+commentStyle.Render("($"+ResendAPIKey+")")) rootCmd.CompletionOptions.HiddenDefaultCmd = true + var configCmd = &cobra.Command{ + Use: "config", + Short: "配置或重新配置 Pop", + Long: `打开交互式配置向导来设置或更新 Pop 的配置。`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runOnboarding() + }, + } + rootCmd.AddCommand(configCmd) + + var inboxCmd = &cobra.Command{ + Use: "inbox", + Short: "打开收件箱", + Long: `查看配置邮箱的收件箱,显示未读邮件列表。`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runInbox(7) + }, + } + inboxCmd.Flags().IntP("days", "d", 7, "显示最近几天的未读邮件") + rootCmd.AddCommand(inboxCmd) + + var historyCmd = &cobra.Command{ + Use: "history", + Short: "查看发送历史", + Long: `查看已发送或草稿邮件的历史记录。`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runHistory() + }, + } + rootCmd.AddCommand(historyCmd) + if len(CommitSHA) >= 7 { //nolint:gomnd vt := rootCmd.VersionTemplate() rootCmd.SetVersionTemplate(vt[:len(vt)-1] + " (" + CommitSHA[0:7] + ")\n") diff --git a/onboarding.go b/onboarding.go new file mode 100644 index 0000000..145bb58 --- /dev/null +++ b/onboarding.go @@ -0,0 +1,201 @@ +package main + +import ( + "fmt" + + "charm.land/huh/v2" +) + +var encryptionOptions = []huh.Option[string]{ + huh.NewOption("STARTTLS", "starttls"), + huh.NewOption("SSL/TLS", "ssl"), + huh.NewOption("None", "none"), +} + +func getEncryptionFromOption(option string) string { + switch option { + case "ssl": + return "ssl" + case "none": + return "none" + default: + return "starttls" + } +} + +func runOnboarding() error { + var accountName string + var email string + var provider string + var smtpHost string + var smtpPort string + var imapHost string + var imapPort string + var smtpUsername string + var smtpPassword string + var smtpEncryption string + var smtpInsecure bool + var unsafeHTML bool + + form := huh.NewForm( + huh.NewGroup( + huh.NewNote(). + Title("欢迎使用 Pop"). + Description("让我们先配置一下邮件账户。"), + ), + huh.NewGroup( + huh.NewInput(). + Title("邮箱地址"). + Value(&email), + huh.NewInput(). + Title("账户名称 (可选)"). + Value(&accountName), + ), + ) + + err := form.Run() + if err != nil { + return err + } + + provider = getProviderName(email) + hasDefaults := false + if defaults, ok := providerDefaults[provider]; ok { + hasDefaults = true + smtpHost = defaults.SMTPHost + smtpPort = fmt.Sprintf("%d", defaults.SMTPPort) + imapHost = defaults.IMAPHost + imapPort = fmt.Sprintf("%d", defaults.IMAPPort) + smtpEncryption = defaults.SMTPEncryption + } + + if !hasDefaults { + provider = "custom" + } + + var form2 *huh.Form + if hasDefaults { + form2 = huh.NewForm( + huh.NewGroup( + huh.NewNote(). + Title("已识别: "+provider). + Description(fmt.Sprintf("SMTP: %s:%s | IMAP: %s:%s", smtpHost, smtpPort, imapHost, imapPort)), + ), + huh.NewGroup( + huh.NewConfirm(). + Title("使用默认配置?"). + Affirmative("是"). + Negative("否, 手动配置"). + Value(&hasDefaults), + ), + ) + } else { + form2 = huh.NewForm( + huh.NewGroup( + huh.NewNote(). + Title("未知邮箱服务商"). + Description("请手动配置服务器地址。"), + ), + huh.NewGroup( + huh.NewInput(). + Title("IMAP 服务器"). + Value(&imapHost), + huh.NewInput(). + Title("IMAP 端口"). + Value(&imapPort), + ), + huh.NewGroup( + huh.NewInput(). + Title("SMTP 服务器"). + Value(&smtpHost), + huh.NewInput(). + Title("SMTP 端口"). + Value(&smtpPort), + huh.NewSelect[string](). + Title("加密方式"). + Options(encryptionOptions...). + Value(&smtpEncryption), + ), + ) + } + + err = form2.Run() + if err != nil { + return err + } + + form3 := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title("用户名"). + Value(&smtpUsername), + huh.NewInput(). + Title("密码/授权码"). + Password(true). + Value(&smtpPassword), + ), + huh.NewGroup( + huh.NewConfirm(). + Title("跳过 TLS 验证?"). + Affirmative("是"). + Negative("否"). + Value(&smtpInsecure), + ), + huh.NewGroup( + huh.NewConfirm(). + Title("启用 unsafe HTML?"). + Affirmative("是"). + Negative("否"). + Value(&unsafeHTML), + ), + ) + + err = form3.Run() + if err != nil { + return err + } + + cfg := Config{ + From: email, + Signature: "", + UnsafeHTML: unsafeHTML, + Accounts: []Account{ + { + Name: accountName, + Email: email, + Provider: provider, + Username: smtpUsername, + Password: smtpPassword, + SMTPEncryption: smtpEncryption, + IMAP: IMAPConfig{ + Host: imapHost, + Password: smtpPassword, + }, + SMTP: SMTPConfig{ + Host: smtpHost, + Username: smtpUsername, + Password: smtpPassword, + InsecureSkipVerify: smtpInsecure, + }, + }, + }, + } + + if imapPort != "" { + fmt.Sscanf(imapPort, "%d", &cfg.Accounts[0].IMAP.Port) + } + if smtpPort != "" { + fmt.Sscanf(smtpPort, "%d", &cfg.Accounts[0].SMTP.Port) + } + if smtpEncryption == "" { + cfg.Accounts[0].SMTP.Encryption = "starttls" + } else { + cfg.Accounts[0].SMTP.Encryption = getEncryptionFromOption(smtpEncryption) + } + + if cfg.Accounts[0].Name == "" { + cfg.Accounts[0].Name = provider + } + + return saveConfig(cfg) +} diff --git a/pop.exe b/pop.exe new file mode 100644 index 0000000..f54c489 Binary files /dev/null and b/pop.exe differ