- 增加收件箱功能(有BUG)
- 增加已发送|草稿箱|发送历史 本地记录
This commit is contained in:
2026-04-09 21:48:21 +08:00
parent 2ca198a81b
commit 7ab9f00c4f
15 changed files with 1899 additions and 33 deletions

37
AGENTS.md Normal file
View File

@@ -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
```

366
config.go Normal file
View File

@@ -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 ""
}

View File

@@ -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] 是否本地存储已接收邮件 - 轻量版,不存储

View File

@@ -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 流程调整
- [ ] 测试配置读取和写入

View File

@@ -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...).

53
go.mod
View File

@@ -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
)

146
go.sum
View File

@@ -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=

221
history.go Normal file
View File

@@ -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)
}
}
}

187
history_cmd.go Normal file
View File

@@ -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()))
}

181
imap.go Normal file
View File

@@ -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]
}
}
}
}

48
inbox.go Normal file
View File

@@ -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
}

189
inbox/model.go Normal file
View File

@@ -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"))
)

87
main.go
View File

@@ -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")

201
onboarding.go Normal file
View File

@@ -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)
}

BIN
pop.exe Normal file

Binary file not shown.