Compare commits

..

13 Commits

Author SHA1 Message Date
266be0bab5 chore: 删除未使用的 golangci/goreleaser 配置文件 2026-04-10 04:41:48 +08:00
c9b77feabe feat: 收件箱功能新增按回车查看详情面板
- 添加邮件详情面板显示(主题、发件人、收件人、抄送、账户、时间、正文)
- 优化邮件列表卡片样式,增加选中高亮效果
- 窗口宽度 >= 80 时启用双面板布局,左侧列表右侧详情
- 简化依赖包,从 charm.land 使用统一导入路径
- 删除未使用的 golangci/goreleaser 配置文件
2026-04-10 04:41:22 +08:00
52c5eb5ae8 feat: 重构配置文件格式并添加 IMAP ID 命令支持
- 配置文件分离:用户配置与项目配置分离,项目级配置(客户端信息、需要 ID 命令的 provider)放在代码中
- 新增 check_id 字段:用户可选择禁用单个账户的 ID 命令发送
- 简化 provider:只保留 163 和 QQ,移除未使用的 Gmail/Outlook/188 等
- 修复 163 邮箱收件箱问题:通过发送 IMAP ID 命令解决 Unsafe Login 错误

BREAKING CHANGE: 配置文件格式变化,旧配置不兼容
2026-04-10 00:39:06 +08:00
d54fd01001 存档 GitHub 配置并清理未使用的文件
- 将所有 `.github` 目录的内容移动到 `.github.bak` 进行归档
   - 更新 `.gitignore` 文件,添加 IDE、系统和二进制文件的忽略规则
   - 删除未使用的 `Dockerfile` 和 `pop.exe` 二进制文件
2026-04-09 22:17:48 +08:00
7ab9f00c4f Fearure:
- 增加收件箱功能(有BUG)
- 增加已发送|草稿箱|发送历史 本地记录
2026-04-09 21:48:21 +08:00
dependabot[bot]
2ca198a81b chore(deps): bump github.com/charmbracelet/bubbles in the all group (#161)
Bumps the all group with 1 update: [github.com/charmbracelet/bubbles](https://github.com/charmbracelet/bubbles).


Updates `github.com/charmbracelet/bubbles` from 0.21.1 to 1.0.0
- [Release notes](https://github.com/charmbracelet/bubbles/releases)
- [Commits](https://github.com/charmbracelet/bubbles/compare/v0.21.1...v1.0.0)

---
updated-dependencies:
- dependency-name: github.com/charmbracelet/bubbles
  dependency-version: 1.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: all
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-16 11:00:51 +00:00
dependabot[bot]
298af5aa76 chore(deps): bump github.com/charmbracelet/bubbles in the all group (#159)
Bumps the all group with 1 update: [github.com/charmbracelet/bubbles](https://github.com/charmbracelet/bubbles).


Updates `github.com/charmbracelet/bubbles` from 0.21.0 to 0.21.1
- [Release notes](https://github.com/charmbracelet/bubbles/releases)
- [Commits](https://github.com/charmbracelet/bubbles/compare/v0.21.0...v0.21.1)

---
updated-dependencies:
- dependency-name: github.com/charmbracelet/bubbles
  dependency-version: 0.21.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-09 11:15:15 +00:00
dependabot[bot]
25202e88d1 chore(deps): bump github.com/yuin/goldmark in the all group (#158)
Bumps the all group with 1 update: [github.com/yuin/goldmark](https://github.com/yuin/goldmark).


Updates `github.com/yuin/goldmark` from 1.7.14 to 1.7.16
- [Release notes](https://github.com/yuin/goldmark/releases)
- [Commits](https://github.com/yuin/goldmark/compare/v1.7.14...v1.7.16)

---
updated-dependencies:
- dependency-name: github.com/yuin/goldmark
  dependency-version: 1.7.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-12 11:59:44 +00:00
dependabot[bot]
2e5de3730c chore(deps): bump github.com/yuin/goldmark in the all group (#157)
Bumps the all group with 1 update: [github.com/yuin/goldmark](https://github.com/yuin/goldmark).


Updates `github.com/yuin/goldmark` from 1.7.13 to 1.7.14
- [Release notes](https://github.com/yuin/goldmark/releases)
- [Commits](https://github.com/yuin/goldmark/compare/v1.7.13...v1.7.14)

---
updated-dependencies:
- dependency-name: github.com/yuin/goldmark
  dependency-version: 1.7.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-05 10:06:10 +00:00
dependabot[bot]
12ddc506a9 chore(deps): bump github.com/spf13/cobra in the all group (#156)
Bumps the all group with 1 update: [github.com/spf13/cobra](https://github.com/spf13/cobra).


Updates `github.com/spf13/cobra` from 1.10.1 to 1.10.2
- [Release notes](https://github.com/spf13/cobra/releases)
- [Commits](https://github.com/spf13/cobra/compare/v1.10.1...v1.10.2)

---
updated-dependencies:
- dependency-name: github.com/spf13/cobra
  dependency-version: 1.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-08 10:07:22 +00:00
dependabot[bot]
e2e6252912 chore(deps): bump actions/checkout from 5 to 6 in the all group (#155)
Bumps the all group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 5 to 6
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: all
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-24 11:51:27 +00:00
dependabot[bot]
c2494bfa79 chore(deps): bump golangci/golangci-lint-action in the all group (#154)
Bumps the all group with 1 update: [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action).


Updates `golangci/golangci-lint-action` from 8 to 9
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v8...v9)

---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: all
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-10 11:37:19 +00:00
dependabot[bot]
6c42402ba3 chore(deps): bump github.com/charmbracelet/bubbletea in the all group (#151)
Bumps the all group with 1 update: [github.com/charmbracelet/bubbletea](https://github.com/charmbracelet/bubbletea).


Updates `github.com/charmbracelet/bubbletea` from 1.3.9 to 1.3.10
- [Release notes](https://github.com/charmbracelet/bubbletea/releases)
- [Changelog](https://github.com/charmbracelet/bubbletea/blob/main/.goreleaser.yml)
- [Commits](https://github.com/charmbracelet/bubbletea/compare/v1.3.9...v1.3.10)

---
updated-dependencies:
- dependency-name: github.com/charmbracelet/bubbletea
  dependency-version: 1.3.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: all
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-22 09:15:52 +00:00
34 changed files with 2672 additions and 279 deletions

View File

@@ -8,12 +8,12 @@ jobs:
name: lint name: lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v6
- uses: actions/setup-go@v6 - uses: actions/setup-go@v6
with: with:
go-version: ^1 go-version: ^1
- name: golangci-lint - name: golangci-lint
uses: golangci/golangci-lint-action@v8 uses: golangci/golangci-lint-action@v9
with: with:
# Optional: golangci-lint command line arguments. # Optional: golangci-lint command line arguments.
args: --issues-exit-code=0 args: --issues-exit-code=0

11
.gitignore vendored
View File

@@ -3,3 +3,14 @@
dist dist
completions completions
manpages manpages
.DS_Store
*.ini
.vs/
.vscode/
.fleet/
.zed/
*.exe
pop*

View File

@@ -1,47 +0,0 @@
run:
tests: false
issues:
include:
- EXC0001
- EXC0005
- EXC0011
- EXC0012
- EXC0013
max-issues-per-linter: 0
max-same-issues: 0
linters:
enable:
# - dupl
- exhaustive
# - exhaustivestruct
- goconst
- godot
- godox
- gomnd
- gomoddirectives
- goprintffuncname
- ifshort
# - lll
- misspell
- nakedret
- nestif
- noctx
- nolintlint
- prealloc
- wrapcheck
# disable default linters, they are already enabled in .golangci.yml
disable:
- deadcode
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- structcheck
- typecheck
- unused
- varcheck

View File

@@ -1,29 +0,0 @@
run:
tests: false
issues:
include:
- EXC0001
- EXC0005
- EXC0011
- EXC0012
- EXC0013
max-issues-per-linter: 0
max-same-issues: 0
linters:
enable:
- bodyclose
- exportloopref
- goimports
- gosec
- nilerr
- predeclared
- revive
- rowserrcheck
- sqlclosecheck
- tparallel
- unconvert
- unparam
- whitespace

View File

@@ -1,15 +0,0 @@
# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json
includes:
- from_url:
url: charmbracelet/meta/main/goreleaser-full.yaml
variables:
main: "."
binary_name: pop
description: "Send emails from your terminal. 📬"
github_url: "https://github.com/charmbracelet/pop"
maintainer: "Maas Lalani <maas@charm.sh>"
brew_commit_author_name: "Maas Lalani"
brew_commit_author_email: "maas@charm.sh"
aur_project_name: charm-pop

74
AGENTS.md Normal file
View File

@@ -0,0 +1,74 @@
# Pop 开发记录
## Changelog
| 日期 | 版本 | 描述 |
|------|------|------|
| 2026-04-09 | v0.1.0 | 初始规划:发送历史、收件箱功能 |
| 2026-04-09 | v0.1.1 | 配置简化支持多账户、自动识别Provider |
| 2026-04-10 | v0.1.2 | 收件箱功能:按回车查看邮件详情 |
## 讨论记录
- [第1次功能规划](./doc/001-feature-planning.md)
- [第2次配置简化讨论](./doc/002-config-simplification.md)
## 功能说明
### 收件箱 (inbox)
```bash
pop inbox # 查看收件箱默认最近7天
pop inbox -d 3 # 查看最近3天的未读邮件
```
**操作:**
- `↑` `↓` - 上下移动选择邮件
- `Enter` - 查看邮件详情(在右侧面板显示完整内容)
- `/` - 搜索邮件
- `q` - 退出
**详情面板显示:**
- 主题
- 发件人
- 收件人
- 抄送(如有)
- 账户
- 时间
- 正文(文本内容优先,无文本则显示 HTML 提示)
## 配置文件格式
```yaml
from:
account: foolsecret@163.com
accounts:
- name: work
email: 邮箱
provider: 163
username: foolsecret@163.com
password: 密钥
encryption: ssl
insecure: false
imap:
host: imap.163.com
port: 993
smtp:
host: smtp.163.com
port: 465
- name: qqemail
email: 邮箱
provider: qq
username: xxx@qq.com
password: QQ邮箱IMAP授权码
encryption: ssl
insecure: fales
imap:
host: imap.qq.com
port: 993
smtp:
host: smtp.qq.com
port: 465
```

View File

@@ -1,3 +0,0 @@
FROM gcr.io/distroless/static
COPY pop /usr/local/bin/pop
ENTRYPOINT [ "/usr/local/bin/pop" ]

View File

@@ -4,8 +4,8 @@ import (
"io" "io"
"path/filepath" "path/filepath"
"github.com/charmbracelet/bubbles/list" "charm.land/bubbles/v2/list"
tea "github.com/charmbracelet/bubbletea" tea "charm.land/bubbletea/v2"
) )
type attachment string type attachment string

347
config.go Normal file
View File

@@ -0,0 +1,347 @@
package main
import (
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
const configFileName = "config.yml"
type Config struct {
From FromConfig `yaml:"from"`
Defaults DefaultsConfig `yaml:"defaults"`
Signature string `yaml:"signature"`
UnsafeHTML bool `yaml:"unsafe_html"`
Accounts []Account `yaml:"accounts"`
}
type FromConfig struct {
Account string `yaml:"account"`
}
type DefaultsConfig struct {
Encryption string `yaml:"encryption"`
Insecure bool `yaml:"insecure"`
}
type Account struct {
Name string `yaml:"name"`
Email string `yaml:"email"`
Provider string `yaml:"provider"`
Username string `yaml:"username"`
Password string `yaml:"password"`
CheckID *bool `yaml:"check_id"`
IMAP IMAPConfig `yaml:"imap"`
SMTP SMTPConfig `yaml:"smtp"`
}
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: FromConfig{},
Defaults: DefaultsConfig{Encryption: "starttls"},
Signature: "",
UnsafeHTML: false,
}
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",
},
"QQ": {
IMAPHost: "imap.qq.com",
IMAPPort: 993,
IMAPEncryption: "ssl",
SMTPHost: "smtp.qq.com",
SMTPPort: 465,
SMTPEncryption: "ssl",
},
}
var imapProviders = map[string]IMAPConfig{
"163": {
Host: "imap.163.com",
Port: 993,
Username: "",
Password: "",
},
"QQ": {
Host: "imap.qq.com",
Port: 993,
Username: "",
Password: "",
},
}
func normalizeAccount(acc Account, defaults DefaultsConfig) Account {
if acc.Provider == "" {
acc.Provider = getProviderName(acc.Email)
}
if providerDefaults, ok := providerDefaults[acc.Provider]; ok {
if acc.IMAP.Host == "" {
acc.IMAP.Host = providerDefaults.IMAPHost
acc.IMAP.Port = providerDefaults.IMAPPort
}
if acc.IMAP.Encryption == "" && providerDefaults.IMAPEncryption != "" {
acc.IMAP.Encryption = providerDefaults.IMAPEncryption
}
if acc.SMTP.Host == "" {
acc.SMTP.Host = providerDefaults.SMTPHost
acc.SMTP.Port = providerDefaults.SMTPPort
}
if acc.SMTP.Encryption == "" && providerDefaults.SMTPEncryption != "" {
acc.SMTP.Encryption = providerDefaults.SMTPEncryption
}
}
if defaults.Encryption != "" {
if acc.IMAP.Encryption == "" {
acc.IMAP.Encryption = defaults.Encryption
}
if acc.SMTP.Encryption == "" {
acc.SMTP.Encryption = defaults.Encryption
}
}
if acc.IMAP.InsecureSkipVerify == false && defaults.Insecure {
acc.IMAP.InsecureSkipVerify = defaults.Insecure
}
if acc.SMTP.InsecureSkipVerify == false && defaults.Insecure {
acc.SMTP.InsecureSkipVerify = defaults.Insecure
}
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
}
if acc.CheckID == nil {
defaultCheckID := ProjectConfig.ProvidersNeedingCheckID[acc.Provider]
acc.CheckID = &defaultCheckID
}
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
}
accounts := cfg.Accounts
for i := range accounts {
accounts[i] = normalizeAccount(accounts[i], cfg.Defaults)
}
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, "@qq.com") || strings.HasSuffix(email, "@vip.qq.com") {
return "QQ"
}
return "custom"
}
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"
}
return ""
}
func getDefaultFromEmail() string {
cfg, err := loadConfig()
if err != nil || cfg.From.Account == "" {
return ""
}
accounts, err := getAccounts()
if err != nil {
return ""
}
for _, acc := range accounts {
if acc.Name == cfg.From.Account {
if acc.Email != "" {
return acc.Email
}
return acc.Username
}
}
return ""
}
func getDefaultAccount(accounts []Account, accountName string) *Account {
if accountName == "" {
return nil
}
for i := range accounts {
if accounts[i].Name == accountName {
return &accounts[i]
}
}
return nil
}
type Info struct {
Name string
Version string
Vendor string
}
var ProjectConfig = struct {
Info Info
ProvidersNeedingCheckID map[string]bool
}{
Info: Info{
Name: "pop",
Version: "1.0",
Vendor: "charmbracelet",
},
ProvidersNeedingCheckID: map[string]bool{
"163": true,
"QQ": true,
},
}

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,85 @@
# Pop 配置简化讨论
**日期**: 2026-04-09
## 背景
之前的规划中,配置结构有冗余:
- IMAP 和 SMTP 各需要独立的 username/password
- host/port 需要用户手动填写
## 讨论内容
### 最终配置格式
```yaml
from:
account: work # 通过 name 引用账户
defaults:
encryption: ssl
insecure: false
unsafe_html: false
signature: ""
accounts:
- name: work
email: foolsecret@163.com
provider: 163
username: foolsecret@163.com
password: xxx
imap:
host: imap.163.com
port: 993
encryption: ssl
insecure: false
smtp:
host: smtp.163.com
port: 465
encryption: ssl
insecure: false
```
### 配置说明
| 字段 | 说明 |
|------|------|
| `from.account` | 通过账户的 `name` 引用默认发件账户 |
| `defaults.encryption` | 全局默认加密类型 (`ssl`/`starttls`/`none`) |
| `defaults.insecure` | 全局默认跳过 TLS 证书验证 |
| 账户内覆盖 | 可以在单个账户的 imap/smtp 内覆盖默认值 |
### 自动识别逻辑
1. **自动识别 Provider**: 通过邮箱后缀自动判断
- `@163.com` → 163
- `@qq.com` → QQ
- `@gmail.com` → Gmail
- `@outlook.com` / `@office365.com` → Outlook
- 其他 → custom
2. **自动填充**: 根据 provider 自动填充 imap/smtp 的 host/port/encryption
3. **统一认证**: username/password 只需在账户顶层配置,会自动复制到 IMAP 和 SMTP
### 向后兼容
**不兼容旧版配置**,需要用户重新配置。
## 实现计划
| 步骤 | 文件 | 修改内容 |
|------|------|---------|
| 1 | config.go | 新增 FromConfig 和 DefaultsConfig 结构 |
| 2 | config.go | 修改 normalizeAccount() 支持 defaults |
| 3 | config.go | 新增 getDefaultFromEmail() 和 getDefaultAccount() |
| 4 | main.go | 使用新函数获取默认账户信息 |
| 5 | 测试 | 验证配置读取和发送功能 |
## 已完成
- [x] 实现 config.go 修改
- [x] 实现 main.go 逻辑调整
- [ ] 测试配置读取和写入

View File

@@ -0,0 +1,102 @@
# 项目配置与用户配置分离讨论
**日期**: 2026-04-10
## 背景
在实现 IMAP ID 命令功能时需要在连接时发送客户端身份信息。这些信息name、version、vendor属于**应用开发配置**,而非用户运行时配置。
如果将这些信息放在用户配置文件中:
- 用户可以看到但不需要修改这些"隐藏"配置
- 暴露了应用内部实现细节
- 用户可能误修改导致功能异常
## 讨论内容
### 配置文件分离
| 配置文件 | 用途 | 位置 | 内容 |
|---------|------|------|------|
| **用户配置** | 运行时用户数据 | `~/.config/pop/config.yml` | 账户、邮箱服务、默认行为 |
| **项目配置** | 应用开发相关 | `config/project.go` | 客户端信息、需要 ID 命令的 provider 集合 |
### 用户配置示例
```yaml
from:
account: work
defaults:
encryption: ssl
insecure: false
unsafe_html: false
accounts:
- name: work
email: foolsecret@163.com
provider: "163"
username: foolsecret@163.com
password: xxx
imap:
host: imap.163.com
port: 993
smtp:
host: smtp.163.com
port: 465
```
### 项目配置示例 (config/project.go)
```go
var ProjectConfig = struct {
Info Info
ProvidersNeedingCheckID map[string]bool
}{
Info: Info{
Name: "pop",
Version: "1.0",
Vendor: "charmbracelet",
},
ProvidersNeedingCheckID: map[string]bool{
"163": true,
"QQ": true,
},
}
```
### CheckID 覆盖机制
用户可以在账户级别覆盖 CheckID 行为:
```yaml
accounts:
- name: work
check_id: false # 禁用该账户的 ID 命令发送
```
逻辑优先级:
1. 用户明确设置 `check_id: false` → 不发送
2. 用户明确设置 `check_id: true` → 发送
3. 未设置 → 使用项目配置的 `ProvidersNeedingCheckID` 判断
### 扩展场景
未来可以扩展项目配置:
- 环境变量控制
- 调试模式开关
- 不同 provider 的特殊处理
## 结论
项目配置与用户配置分离是合理的架构设计:
- 职责分离:开发者配置 vs 用户配置
- 安全性:隐藏实现细节
- 可维护性:修改项目配置不影响用户数据
## 待处理
- [ ] 实现 config/project.go
- [ ] 修改 config.go 移除敏感字段
- [ ] 修改 imap.go 添加 ID 命令逻辑
- [ ] 更新文档

View File

@@ -0,0 +1,135 @@
# 第3次收件箱界面改版讨论
## 日期
2026-04-10
## 背景
参考 charmbracelet/glow 的 TUI 设计,将 inbox 界面改为更现代化的双栏布局。
## 最终实现效果
### 双栏布局(终端宽度 ≥ 80
```
┌───────────────────────────────────────────────────────────────────────┐
│ 收件箱 (5 封新邮件) │ 邮件详情 │
├─────────────────────────────────────────┴────────────────────────────────┤
│ 新设备登录提醒 │ 主题:新设备登录提醒 │
│ ▣ 163 · 11小时前 │ 发件人: xxx@163.com │
│─────────────────────────────────────────│ 账户: 163 │
│ 您的账号正在一台新设备上登录 │ 时间: 2026-04-10 10:30 │
│ ▣ 163 · 11小时前 │ │
│─────────────────────────────────────────│ 预览: 您的账号正在一台新设备上登录... │
│ 项目进度更新 │ │
│ ▣ work · 昨天 │ │
├─────────────────────────────────────────┴────────────────────────────────┤
│ ↑ 上移 · ↓ 下移 · enter 查看详情 · / 搜索 · q 退出 │
└───────────────────────────────────────────────────────────────────────┘
```
### 布局说明
- **整体布局**:全屏 TTY 模式,清屏后占满整个终端窗口
- **列表区域**:占窗口宽度的 45%,高度的 80%
- **详情区域**:占窗口宽度的 55%
- **帮助栏**:固定在窗口最底部,使用系统内置帮助组件
## 卡片样式
### 卡片结构(每封邮件两行)
```
┌─────────────────────────────────────────┐
│ 主题:邮件标题内容... │ ← 第一行白色主题Bold
│ ▣ 账户 · 11小时前 │ ← 第二行:灰色账户+时间,有▣标记
└─────────────────────────────────────────┘
```
### 样式细节
- **未选中卡片**
- 主题行白色前景无背景上内边距1左右内边距1下内边距0
- 元信息行:灰色前景(#241无背景上内边距0左右内边距1下内边距1
- **选中卡片**
- 主题行:白色前景,浅紫色背景(#99上内边距1左右内边距1下内边距0
- 元信息行:浅紫色前景(#186),浅紫色背景(#99上内边距0左右内边距1下内边距1
### 主题色
- 背景色:`#99`(浅紫色)
- 选中背景:`#99`
- 标题前景:`#219`(粉色紫)
## 功能需求
| 功能 | 状态 | 说明 |
|------|------|------|
| 双栏布局(列表 + 详情) | ✅ | 列表45%详情55% |
| 全屏 TTY 模式 | ✅ | 使用 AltScreen清屏 |
| 卡片式邮件列表 | ✅ | 两行结构:主题 + 元信息 |
| 选中高亮 | ✅ | 浅紫色背景 |
| 帮助栏固定底部 | ✅ | 使用系统内置帮助组件 |
| 列表高度80% | ✅ | 根据窗口高度动态计算 |
| 搜索过滤功能 | ⏳ | 待实现 |
| 详情面板显示完整邮件 | ⏳ | 待实现(需要按需获取) |
## 功能需求
| 功能 | 状态 | 说明 |
|------|------|------|
| 双栏布局(列表 + 详情) | ✅ | 列表45%详情55% |
| 全屏 TTY 模式 | ✅ | 使用 AltScreen清屏 |
| 卡片式邮件列表 | ✅ | 两行结构:主题 + 元信息 |
| 选中高亮 | ✅ | 浅紫色背景 |
| 帮助栏固定底部 | ✅ | 使用系统内置帮助组件 |
| 列表高度80% | ✅ | 根据窗口高度动态计算 |
| 搜索过滤功能 | ⏳ | 待实现 |
| 详情面板显示完整邮件 | ✅ | 按回车获取完整邮件内容 |
## 技术要点
### 关键实现
1. **TTY 模式**`v.AltScreen = true` 在 View() 方法中设置
2. **宽度计算**`int(float64(totalWidth) * 0.45)` 计算列表宽度
3. **高度计算**`int(float64(msg.Height) * 0.8)` 设置列表高度为窗口的80%
4. **帮助栏**
- `l.SetShowHelp(true)` 启用内置帮助
- 自定义 `inboxHelpKeyMap` 实现 `ShortHelp()``FullHelp()` 接口
5. **卡片渲染**
- `emailDelegate.Height()` 返回 2每卡片2行
- `emailDelegate.Spacing()` 返回 0卡片间无间距
- 使用 `lipgloss.NewStyle().Width(width)` 固定宽度
- 使用 `truncateString()` 函数实现超出截断
### 按回车获取详情实现
1. **InboxModel 新增字段**
- `loadingDetail bool` - 是否正在加载详情
- `selectedDetail *EmailDetail` - 已获取的详情数据
- `detailFetcher DetailFetcher` - 详情获取回调函数
2. **DetailFetcher 模式**:通过回调函数解耦获取逻辑,便于测试和扩展
3. **DetailResultMsg 消息**:异步获取完成后通过消息传递结果
4. **Spinner 加载动画**:使用 bubbles spinner 组件显示加载状态
5. **IMAP ID 命令**QQ 邮箱需要发送 ID 命令通过 "Unsafe Login" 检查
### 文件修改
- `inbox/model.go`:主要实现文件
- `inbox.go`:使用 `tea.NewProgram()` 创建程序
- `imap.go`:新增 `FetchEmailDetailByUID` 函数获取完整邮件内容
## 实施步骤
| 步骤 | 描述 | 状态 |
|------|------|------|
| 1 | 创建讨论文档 | ✅ |
| 2 | 升级 charm.land v2 依赖 | ✅ |
| 3 | 实现双栏布局基础结构 | ✅ |
| 4 | 实现 View() 全屏渲染 | ✅ |
| 5 | 实现卡片样式(两行结构) | ✅ |
| 6 | 实现选中高亮效果 | ✅ |
| 7 | 添加帮助栏(系统内置) | ✅ |
| 8 | 调整列表高度为80% | ✅ |
| 9 | 移除边框,优化间距 | ✅ |
| 10 | 实现右侧详情面板 | ✅ |
| 11 | 添加搜索过滤功能 | ⏳ |
| 12 | 实现按需获取完整邮件内容 | ✅ |
## 待优化项
1. **搜索功能**:启用 `list.SetFilteringEnabled(true)` 后需要实现过滤逻辑
2. **邮件数量**:当前只显示未读邮件,可扩展为支持所有邮件
3. **邮件操作**:查看、删除、标记已读等操作待实现

View File

@@ -10,7 +10,7 @@ import (
"strings" "strings"
"time" "time"
tea "github.com/charmbracelet/bubbletea" tea "charm.land/bubbletea/v2"
"github.com/resendlabs/resend-go" "github.com/resendlabs/resend-go"
mail "github.com/xhit/go-simple-mail/v2" mail "github.com/xhit/go-simple-mail/v2"
"github.com/yuin/goldmark" "github.com/yuin/goldmark"
@@ -40,6 +40,39 @@ func (m Model) sendEmailCmd() tea.Cmd {
to := strings.Split(m.To.Value(), ToSeparator) to := strings.Split(m.To.Value(), ToSeparator)
cc := strings.Split(m.Cc.Value(), ToSeparator) cc := strings.Split(m.Cc.Value(), ToSeparator)
bcc := strings.Split(m.Bcc.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 { switch m.DeliveryMethod {
case SMTP: case SMTP:
err = sendSMTPEmail(to, cc, bcc, m.From.Value(), m.Subject.Value(), m.Body.Value(), attachments) 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) 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{} return sendEmailSuccessMsg{}
} }
} }
@@ -64,6 +104,8 @@ const gmailSMTPHost = "smtp.gmail.com"
const gmailSMTPPort = 587 const gmailSMTPPort = 587
func sendSMTPEmail(to, cc, bcc []string, from, subject, body string, attachments []string) error { func sendSMTPEmail(to, cc, bcc []string, from, subject, body string, attachments []string) error {
validFrom := getValidFromAddress(from, smtpUsername)
server := mail.NewSMTPClient() server := mail.NewSMTPClient()
var err error var err error
@@ -92,8 +134,8 @@ func sendSMTPEmail(to, cc, bcc []string, from, subject, body string, attachments
} }
server.KeepAlive = false server.KeepAlive = false
server.ConnectTimeout = 10 * time.Second server.ConnectTimeout = 30 * time.Second
server.SendTimeout = 10 * time.Second server.SendTimeout = 30 * time.Second
server.TLSConfig = &tls.Config{ server.TLSConfig = &tls.Config{
InsecureSkipVerify: smtpInsecureSkipVerify, //nolint:gosec InsecureSkipVerify: smtpInsecureSkipVerify, //nolint:gosec
ServerName: server.Host, ServerName: server.Host,
@@ -102,11 +144,11 @@ func sendSMTPEmail(to, cc, bcc []string, from, subject, body string, attachments
smtpClient, err := server.Connect() smtpClient, err := server.Connect()
if err != nil { if err != nil {
return err return fmt.Errorf("SMTP连接失败: %w", err)
} }
email := mail.NewMSG() email := mail.NewMSG()
email.SetFrom(from). email.SetFrom(validFrom).
AddTo(to...). AddTo(to...).
AddCc(cc...). AddCc(cc...).
AddBcc(bcc...). AddBcc(bcc...).

69
go.mod
View File

@@ -1,47 +1,70 @@
module github.com/charmbracelet/pop module github.com/charmbracelet/pop
go 1.24.0 go 1.26.1
toolchain go1.24.1
require ( require (
github.com/charmbracelet/bubbles v0.21.0 charm.land/bubbles/v2 v2.1.0
github.com/charmbracelet/bubbletea v1.3.9 charm.land/bubbletea/v2 v2.0.2
github.com/charmbracelet/lipgloss v1.1.0 charm.land/huh/v2 v2.0.3
charm.land/lipgloss/v2 v2.0.2
github.com/BrianLeishman/go-imap v0.1.27
github.com/charmbracelet/x/exp/ordered v0.1.0 github.com/charmbracelet/x/exp/ordered v0.1.0
github.com/mattn/go-sqlite3 v1.14.42
github.com/muesli/mango-cobra v1.3.0 github.com/muesli/mango-cobra v1.3.0
github.com/muesli/roff v0.1.0 github.com/muesli/roff v0.1.0
github.com/resendlabs/resend-go v1.7.0 github.com/resendlabs/resend-go v1.7.0
github.com/spf13/cobra v1.10.1 github.com/spf13/cobra v1.10.2
github.com/xhit/go-simple-mail/v2 v2.16.0 github.com/xhit/go-simple-mail/v2 v2.16.0
github.com/yuin/goldmark v1.7.13 github.com/yuin/goldmark v1.7.16
gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/StirlingMarketingGroup/go-retry v0.0.0-20190512160921-94a8eb23e893 // indirect
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.2 // 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/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fatih/color v1.19.0 // indirect
github.com/go-test/deep v1.1.0 // 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/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.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-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/mattn/go-runewidth v0.0.16 // 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/cancelreader v0.2.2 // indirect
github.com/muesli/mango v0.2.0 // indirect github.com/muesli/mango v0.2.0 // indirect
github.com/muesli/mango-pflag v0.1.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/rivo/uniseg v0.4.7 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/spf13/pflag v1.0.9 // indirect github.com/spf13/pflag v1.0.9 // indirect
github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92 // 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-20201103131630-e1cd1a0a5208 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.36.0 // indirect golang.org/x/net v0.52.0 // indirect
golang.org/x/text v0.16.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
) )

155
go.sum
View File

@@ -1,52 +1,90 @@
charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
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.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs=
charm.land/lipgloss/v2 v2.0.2/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 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= 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 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= 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-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI=
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8=
github.com/charmbracelet/bubbletea v1.3.9 h1:OBYdfRo6QnlIcXNmcoI2n1NNS65Nk6kI2L2FO1puS/4= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/charmbracelet/bubbletea v1.3.9/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 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/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 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 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/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 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/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/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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= 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.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 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 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ= github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ=
@@ -57,41 +95,58 @@ github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe
github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= 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/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:DycOqSXtw2q7aB+Nt9DDJUDtaYcrNPGn1t5RFposas0=
github.com/resendlabs/resend-go v1.7.0/go.mod h1:yip1STH7Bqfm4fD0So5HgyNbt5taG5Cplc4xXxETyLI= github.com/resendlabs/resend-go v1.7.0/go.mod h1:yip1STH7Bqfm4fD0So5HgyNbt5taG5Cplc4xXxETyLI=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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/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 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= 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 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= github.com/sqs/go-xoauth2 v0.0.0-20120917012134-0911dad68e56 h1:KCgKdj+ha4CgnVHIiJYGKzgZk3HfCc6XssESfOa6atM=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 h1:PM5hJF7HVfNWmCjMdEfbuOBNXSVF2cMFGgQTPdKCbwM=
github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92 h1:flbMkdl6HxQkLs6DDhH1UkcnFpNBOu70391STjMS0O4=
github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns=
github.com/xhit/go-simple-mail/v2 v2.16.0 h1:ouGy/Ww4kuaqu2E2UrDw7SvLaziWTB60ICLkIkNVccA= github.com/xhit/go-simple-mail/v2 v2.16.0 h1:ouGy/Ww4kuaqu2E2UrDw7SvLaziWTB60ICLkIkNVccA=
github.com/xhit/go-simple-mail/v2 v2.16.0/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98= 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 h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
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.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
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/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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
)
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() tea.View {
if m.loading {
return tea.NewView(historyLoadingStyle.Render("正在加载历史..."))
}
if m.err != nil {
return tea.NewView(historyErrorStyle.Render(fmt.Sprintf("错误: %v", m.err)))
}
if len(m.items) == 0 {
return tea.NewView(historyNoItemsStyle.Render("暂无发送历史"))
}
return tea.NewView(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()))
}

337
imap.go Normal file
View File

@@ -0,0 +1,337 @@
package main
import (
"fmt"
"strings"
"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()
if account.CheckID != nil && *account.CheckID {
info := ProjectConfig.Info
idCmd := fmt.Sprintf("ID (\"name\" \"%s\" \"version\" \"%s\" \"vendor\" \"%s\")",
info.Name, info.Version, info.Vendor)
_, err := m.Exec(idCmd, false, 0, func(line []byte) error {
return nil
})
if err != nil {
fmt.Printf("WARNING: failed to send IMAP ID: %v\n", err)
}
m.Exec("NOOP", false, 0, nil)
}
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]
}
}
}
}
type EmailDetail struct {
UID uint32
From string
FromName string
To string
Cc string
Subject string
Date time.Time
TextBody string
HTMLBody string
Account string
AccountID string
}
func FetchEmailDetailByUID(accountID string, uid uint32) (*EmailDetail, error) {
account, err := GetAccountByID(accountID)
if err != nil {
return nil, err
}
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()
if account.CheckID != nil && *account.CheckID {
info := ProjectConfig.Info
idCmd := fmt.Sprintf("ID (\"name\" \"%s\" \"version\" \"%s\" \"vendor\" \"%s\")",
info.Name, info.Version, info.Vendor)
_, err := m.Exec(idCmd, false, 0, func(line []byte) error {
return nil
})
if err != nil {
fmt.Printf("WARNING: failed to send IMAP ID: %v\n", err)
}
m.Exec("NOOP", false, 0, nil)
}
err = m.SelectFolder("INBOX")
if err != nil {
return nil, fmt.Errorf("failed to select inbox: %w", err)
}
emails, err := m.GetEmails(int(uid))
if err != nil {
return nil, fmt.Errorf("failed to fetch email: %w", err)
}
if len(emails) == 0 {
return nil, fmt.Errorf("email not found")
}
var email imap.Email
found := false
for i := range emails {
if emails[i] != nil {
email = *emails[i]
found = true
break
}
}
if !found {
return nil, fmt.Errorf("email not found or has been removed")
}
accountName := getEmailProviderName(account.Email)
accountIDName := account.Email
if account.Name != "" {
accountName = account.Name
accountIDName = account.Name
}
var fromName string
var fromAddr string
if email.From != nil {
for name, addr := range email.From {
fromName = name
fromAddr = addr
break
}
}
var toList []string
if email.To != nil {
for name, addr := range email.To {
if name != "" {
toList = append(toList, fmt.Sprintf("%s <%s>", name, addr))
} else {
toList = append(toList, addr)
}
}
}
var ccList []string
if email.CC != nil {
for name, addr := range email.CC {
if name != "" {
ccList = append(ccList, fmt.Sprintf("%s <%s>", name, addr))
} else {
ccList = append(ccList, addr)
}
}
}
fromStr := fromAddr
if fromName != "" {
fromStr = fmt.Sprintf("%s <%s>", fromName, fromAddr)
}
return &EmailDetail{
UID: uint32(email.UID),
From: fromStr,
FromName: fromName,
To: strings.Join(toList, ", "),
Cc: strings.Join(ccList, ", "),
Subject: email.Subject,
Date: email.Sent,
TextBody: email.Text,
HTMLBody: email.HTML,
Account: accountName,
AccountID: accountIDName,
}, nil
}

67
inbox.go Normal file
View File

@@ -0,0 +1,67 @@
package main
import (
"fmt"
"charm.land/bubbletea/v2"
"github.com/charmbracelet/pop/inbox"
)
func runInbox(days int) error {
m := inbox.NewInboxModel()
m.SetLoading(true)
m.SetDetailFetcher(func(accountID string, uid uint32) (*inbox.EmailDetail, error) {
detail, err := FetchEmailDetailByUID(accountID, uid)
if err != nil || detail == nil {
return nil, err
}
return &inbox.EmailDetail{
UID: detail.UID,
From: detail.From,
FromName: detail.FromName,
To: detail.To,
Cc: detail.Cc,
Subject: detail.Subject,
Date: detail.Date,
TextBody: detail.TextBody,
HTMLBody: detail.HTMLBody,
Account: detail.Account,
AccountID: detail.AccountID,
}, nil
})
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
}

481
inbox/model.go Normal file
View File

@@ -0,0 +1,481 @@
package inbox
import (
"fmt"
"io"
"strings"
"time"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/list"
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
_ "charm.land/bubbles/v2/help"
)
type EmailDetail struct {
UID uint32
From string
FromName string
To string
Cc string
Subject string
Date time.Time
TextBody string
HTMLBody string
Account string
AccountID string
}
type DetailResultMsg struct {
Detail *EmailDetail
Err error
}
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()
}
func truncateString(s string, maxWidth int) string {
if maxWidth <= 0 {
return ""
}
runes := []rune(s)
if len(runes) <= maxWidth {
return s
}
if maxWidth <= 3 {
return strings.Repeat(".", maxWidth)
}
return string(runes[:maxWidth-3]) + "..."
}
type InboxModel struct {
list list.Model
emails []EmailItem
loading bool
err error
selectedEmail *EmailItem
selectedDetail *EmailDetail
loadingDetail bool
windowWidth int
windowHeight int
helpKeyMap inboxHelpKeyMap
spinner spinner.Model
detailFetcher DetailFetcher
}
type inboxHelpKeyMap struct{}
func (i inboxHelpKeyMap) ShortHelp() []key.Binding {
return []key.Binding{
key.NewBinding(key.WithHelp("↑", "上移")),
key.NewBinding(key.WithHelp("↓", "下移")),
key.NewBinding(key.WithHelp("enter", "查看详情")),
key.NewBinding(key.WithHelp("/", "搜索")),
key.NewBinding(key.WithHelp("q", "退出")),
}
}
func (i inboxHelpKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{
key.NewBinding(key.WithHelp("↑↓", "移动")),
key.NewBinding(key.WithHelp("enter", "查看详情")),
key.NewBinding(key.WithHelp("/", "搜索")),
key.NewBinding(key.WithHelp("q", "退出")),
},
}
}
func NewInboxModel() *InboxModel {
l := list.New(nil, emailDelegate{}, 40, 14)
l.Title = "收件箱"
l.Styles = list.DefaultStyles(true)
l.Styles.Title = inboxTitleStyle
l.Styles.NoItems = inboxNoItemsStyle
l.SetShowHelp(true)
l.SetShowPagination(false)
l.SetFilteringEnabled(true)
s := spinner.New()
s.Spinner = spinner.Dot
s.Style = spinnerStyle
return &InboxModel{
list: l,
emails: []EmailItem{},
windowWidth: 0,
spinner: s,
}
}
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 tea.Batch(
func() tea.Msg {
return tea.WindowSizeMsg{Width: 120, Height: 30}
},
m.spinner.Tick,
)
}
func fetchEmailDetail(accountID string, uid uint32, fetcher DetailFetcher) tea.Cmd {
return func() tea.Msg {
detail, err := fetcher(accountID, uid)
return DetailResultMsg{Detail: detail, Err: err}
}
}
func (m *InboxModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tea.KeyMsg:
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
if msg.String() == "enter" {
email := m.SelectedEmail()
if email != nil && m.detailFetcher != nil {
m.loadingDetail = true
m.selectedDetail = nil
return m, fetchEmailDetail(email.AccountID, email.UID, m.detailFetcher)
}
}
case tea.WindowSizeMsg:
m.windowWidth = msg.Width
m.windowHeight = msg.Height
m.list.SetWidth(calculateListWidth(msg.Width))
m.list.SetHeight(int(float64(msg.Height) * 0.8))
case DetailResultMsg:
m.loadingDetail = false
if msg.Err != nil {
m.err = msg.Err
} else {
m.selectedDetail = msg.Detail
}
case spinner.TickMsg:
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
m.list, cmd = m.list.Update(msg)
return m, cmd
}
func calculateListWidth(totalWidth int) int {
if totalWidth <= 0 {
return 0
}
return int(float64(totalWidth) * 0.45)
}
func (m InboxModel) View() tea.View {
if m.loading {
return tea.NewView(loadingStyle.Render("正在加载邮件..."))
}
if m.err != nil {
return tea.NewView(errorStyle.Render(fmt.Sprintf("错误: %v", m.err)))
}
listWidth := calculateListWidth(m.windowWidth)
detailWidth := m.windowWidth - listWidth - 1
m.list.SetWidth(listWidth)
mainContentStyle := lipgloss.NewStyle().
Width(m.windowWidth - 1).
Height(m.windowHeight - 2)
helpBarStyle := lipgloss.NewStyle().
Width(m.windowWidth - 1).
Foreground(lipgloss.Color("241"))
helpView := helpBarStyle.Render(m.list.Help.View(m.helpKeyMap))
if m.windowWidth >= 80 {
detailView := m.renderDetailPanel(detailWidth)
listView := m.list.View()
mainContent := lipgloss.JoinHorizontal(lipgloss.Top, listView, detailView)
box := mainContentStyle.Render(mainContent)
content := lipgloss.JoinVertical(lipgloss.Bottom, box, helpView)
v := tea.NewView(content)
v.AltScreen = true
return v
}
listView := m.list.View()
box := mainContentStyle.Render(listView)
content := lipgloss.JoinVertical(lipgloss.Bottom, box, helpView)
v := tea.NewView(content)
v.AltScreen = true
return v
}
func (m InboxModel) renderHelpBar() string {
helpItems := "↑↓ 移动 │ enter 查看详情 │ / 搜索 │ q 退出"
return helpBarStyle.Render(helpItems)
}
func (m InboxModel) renderDetailPanel(listWidth int) string {
email := m.SelectedEmail()
if email == nil {
return detailPanelStyle.Render("选择一封邮件查看详情")
}
if m.loadingDetail {
spinnerView := m.spinner.View()
loadingText := detailMetaStyle.Render(spinnerView + " 正在加载邮件内容...")
return detailPanelStyle.Width(40).Render(loadingText)
}
if m.selectedDetail != nil {
detail := m.selectedDetail
subject := detailTitleStyle.Render(detail.Subject)
from := detailMetaStyle.Render(fmt.Sprintf("发件人: %s", detail.From))
to := detailMetaStyle.Render(fmt.Sprintf("收件人: %s", detail.To))
if detail.Cc != "" {
to += "\n" + detailMetaStyle.Render(fmt.Sprintf("抄送: %s", detail.Cc))
}
account := detailMetaStyle.Render(fmt.Sprintf("账户: %s", detail.Account))
date := detailMetaStyle.Render(fmt.Sprintf("时间: %s", detail.Date.Format("2006-01-02 15:04")))
var body string
if detail.TextBody != "" {
body = detailBodyStyle.Render(detail.TextBody)
} else if detail.HTMLBody != "" {
body = detailMetaStyle.Render("[HTML邮件内容]")
} else {
body = detailMetaStyle.Render("[无正文]")
}
content := fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n\n%s", subject, from, to, account, date, body)
detailWidth := 40
if m.windowWidth > 0 {
detailWidth = m.windowWidth - listWidth - 1
}
if detailWidth < 10 {
detailWidth = 40
}
return detailPanelStyle.Width(detailWidth).Render(content)
}
subject := detailTitleStyle.Render(email.Subject)
from := detailMetaStyle.Render(fmt.Sprintf("发件人: %s <%s>", email.FromName, email.From))
account := detailMetaStyle.Render(fmt.Sprintf("账户: %s", email.Account))
date := detailMetaStyle.Render(fmt.Sprintf("时间: %s", email.Date.Format("2006-01-02 15:04")))
preview := detailPanelStyle.Render(email.Preview)
content := fmt.Sprintf("%s\n%s\n%s\n%s\n\n%s", subject, from, account, date, preview)
detailWidth := 40
if m.windowWidth > 0 {
detailWidth = m.windowWidth - listWidth - 1
}
if detailWidth < 10 {
detailWidth = 40
}
return detailPanelStyle.Width(detailWidth).Render(content)
}
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("219")).
Bold(true)
inboxNoItemsStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
Padding(1, 2)
loadingStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("219")).
Padding(1, 2)
errorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("196")).
Padding(1, 2)
emailCardSubjectStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255")).
Bold(true).
Padding(0, 2)
emailCardMetaStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
Padding(0, 2)
emailCardSubjectSelectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255")).
Bold(true).
Background(lipgloss.Color("99")).
Border(lipgloss.NormalBorder()).
BorderLeft(true).
BorderForeground(lipgloss.Color("219")).
Padding(0, 2)
emailCardMetaSelectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("186")).
Background(lipgloss.Color("99")).
Border(lipgloss.NormalBorder()).
BorderLeft(true).
BorderForeground(lipgloss.Color("219")).
Padding(0, 2)
detailPanelStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("255")).
Padding(1, 2)
detailTitleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("219")).
Bold(true).
Padding(0, 1)
detailMetaStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
Padding(0, 1)
detailBodyStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("252")).
Padding(0, 1)
helpBarStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("241")).
Padding(0, 1)
spinnerStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("219"))
cardSpacing = 1
)
type DetailFetcher func(accountID string, uid uint32) (*EmailDetail, error)
func (m *InboxModel) SetDetailFetcher(fetcher DetailFetcher) {
m.detailFetcher = fetcher
}
type emailDelegate struct{}
func (d emailDelegate) Height() int { return 2 }
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()
width := m.Width() - 1
subjectWidth := width - 2
metaWidth := width - 2
subject := truncateString(email.Subject, subjectWidth)
meta := truncateString("▣ "+email.Account+" · "+formatTimeAgo(time.Since(email.Date)), metaWidth)
if isSelected {
subjectStyle := lipgloss.NewStyle().
Width(width).
Foreground(lipgloss.Color("255")).
Background(lipgloss.Color("99")).
Padding(1, 1, 0, 1)
metaStyle := lipgloss.NewStyle().
Width(width).
Foreground(lipgloss.Color("186")).
Background(lipgloss.Color("99")).
Padding(0, 1, 1, 1)
fmt.Fprintf(w, "%s\n%s",
subjectStyle.Render(subject),
metaStyle.Render(meta))
} else {
subjectStyle := lipgloss.NewStyle().
Width(width).
Foreground(lipgloss.Color("255")).
Padding(1, 1, 0, 1)
metaStyle := lipgloss.NewStyle().
Width(width).
Foreground(lipgloss.Color("241")).
Padding(0, 1, 1, 1)
fmt.Fprintf(w, "%s\n%s",
subjectStyle.Render(subject),
metaStyle.Render(meta))
}
}

View File

@@ -1,6 +1,6 @@
package main package main
import "github.com/charmbracelet/bubbles/key" import "charm.land/bubbles/v2/key"
// KeyMap represents the key bindings for the application. // KeyMap represents the key bindings for the application.
type KeyMap struct { type KeyMap struct {

104
main.go
View File

@@ -9,7 +9,7 @@ import (
"strconv" "strconv"
"strings" "strings"
tea "github.com/charmbracelet/bubbletea" tea "charm.land/bubbletea/v2"
mcobra "github.com/muesli/mango-cobra" mcobra "github.com/muesli/mango-cobra"
"github.com/muesli/roff" "github.com/muesli/roff"
"github.com/resendlabs/resend-go" "github.com/resendlabs/resend-go"
@@ -74,6 +74,12 @@ var rootCmd = &cobra.Command{
Short: "Send emails from your terminal", Short: "Send emails from your terminal",
Long: `Pop is a tool for sending emails from your terminal.`, Long: `Pop is a tool for sending emails from your terminal.`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if !configExists() && smtpUsername == "" && smtpPassword == "" && resendAPIKey == "" {
if err := runOnboarding(); err != nil {
return err
}
}
var deliveryMethod DeliveryMethod var deliveryMethod DeliveryMethod
switch { switch {
case resendAPIKey != "" && smtpUsername != "" && smtpPassword != "": case resendAPIKey != "" && smtpUsername != "" && smtpPassword != "":
@@ -89,11 +95,11 @@ var rootCmd = &cobra.Command{
switch deliveryMethod { switch deliveryMethod {
case None: case None:
fmt.Printf("\n %s %s %s\n\n", errorHeaderStyle.String(), inlineCodeStyle.Render(ResendAPIKey), "environment variable is required.") fmt.Printf("\n %s 未找到邮件配置\n\n", errorHeaderStyle.String())
fmt.Printf(" %s %s\n\n", commentStyle.Render("You can grab one at"), linkStyle.Render("https://resend.com/api-keys")) fmt.Printf(" %s 请运行 %s 进行首次配置\n\n", commentStyle.Render("提示:"), inlineCodeStyle.Render("pop --config"))
cmd.SilenceUsage = true cmd.SilenceUsage = true
cmd.SilenceErrors = true cmd.SilenceErrors = true
return errors.New("missing required environment variable") return errors.New("missing mail configuration")
case Unknown: case Unknown:
fmt.Printf("\n %s Unknown delivery method.\n", errorHeaderStyle.String()) 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_*")) fmt.Printf("\n You have set both %s and %s delivery methods.", inlineCodeStyle.Render(ResendAPIKey), inlineCodeStyle.Render("POP_SMPT_*"))
@@ -196,39 +202,119 @@ var ManCmd = &cobra.Command{
func init() { func init() {
rootCmd.AddCommand(ManCmd) rootCmd.AddCommand(ManCmd)
cfg, _ := loadConfig()
_ = cfg
rootCmd.Flags().StringSliceVar(&bcc, "bcc", []string{}, "BCC recipients") rootCmd.Flags().StringSliceVar(&bcc, "bcc", []string{}, "BCC recipients")
rootCmd.Flags().StringSliceVar(&cc, "cc", []string{}, "CC recipients") rootCmd.Flags().StringSliceVar(&cc, "cc", []string{}, "CC recipients")
rootCmd.Flags().StringSliceVarP(&attachments, "attach", "a", []string{}, "Email's attachments") rootCmd.Flags().StringSliceVarP(&attachments, "attach", "a", []string{}, "Email's attachments")
rootCmd.Flags().StringSliceVarP(&to, "to", "t", []string{}, "Recipients") rootCmd.Flags().StringSliceVarP(&to, "to", "t", []string{}, "Recipients")
rootCmd.Flags().StringVarP(&body, "body", "b", "", "Email's contents") rootCmd.Flags().StringVarP(&body, "body", "b", "", "Email's contents")
envFrom := os.Getenv(PopFrom) envFrom := os.Getenv(PopFrom)
if envFrom == "" {
envFrom = getDefaultFromEmail()
}
from = envFrom
rootCmd.Flags().StringVarP(&from, "from", "f", envFrom, "Email's sender"+commentStyle.Render("($"+PopFrom+")")) rootCmd.Flags().StringVarP(&from, "from", "f", envFrom, "Email's sender"+commentStyle.Render("($"+PopFrom+")"))
rootCmd.Flags().StringVarP(&subject, "subject", "s", "", "Email's subject") rootCmd.Flags().StringVarP(&subject, "subject", "s", "", "Email's subject")
rootCmd.Flags().BoolVar(&preview, "preview", false, "Whether to preview the email before sending") rootCmd.Flags().BoolVar(&preview, "preview", false, "Whether to preview the email before sending")
envUnsafe := os.Getenv(PopUnsafeHTML) == "true" 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)") 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) 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+")")) rootCmd.Flags().StringVarP(&signature, "signature", "x", envSignature, "Signature to display at the end of the email."+commentStyle.Render("($"+PopSignature+")"))
envSMTPHost := os.Getenv(PopSMTPHost) envSMTPHost := os.Getenv(PopSMTPHost)
rootCmd.Flags().StringVarP(&smtpHost, "smtp.host", "H", envSMTPHost, "Host of the SMTP server"+commentStyle.Render("($"+PopSMTPHost+")"))
envSMTPPort, _ := strconv.Atoi(os.Getenv(PopSMTPPort)) envSMTPPort, _ := strconv.Atoi(os.Getenv(PopSMTPPort))
envSMTPUsername := os.Getenv(PopSMTPUsername)
envSMTPPassword := os.Getenv(PopSMTPPassword)
envSMTPEncryption := os.Getenv(PopSMTPEncryption)
envInsecureSkipVerify := os.Getenv(PopSMTPInsecureSkipVerify) == "true"
defaultAccounts, _ := getAccounts()
defaultAccount := getDefaultAccount(defaultAccounts, cfg.From.Account)
if envSMTPHost == "" && defaultAccount != nil {
envSMTPHost = defaultAccount.SMTP.Host
}
if envSMTPPort == 0 && defaultAccount != nil {
envSMTPPort = defaultAccount.SMTP.Port
if envSMTPPort == 0 { if envSMTPPort == 0 {
envSMTPPort = 587 envSMTPPort = 587
} }
}
if envSMTPUsername == "" && defaultAccount != nil {
envSMTPUsername = defaultAccount.SMTP.Username
}
if envSMTPPassword == "" && defaultAccount != nil {
envSMTPPassword = defaultAccount.SMTP.Password
}
if envSMTPEncryption == "" && defaultAccount != nil {
envSMTPEncryption = defaultAccount.SMTP.Encryption
if envSMTPEncryption == "" {
envSMTPEncryption = "starttls"
}
}
if !envInsecureSkipVerify && defaultAccount != nil {
envInsecureSkipVerify = defaultAccount.SMTP.InsecureSkipVerify
}
smtpHost = envSMTPHost
smtpPort = envSMTPPort
smtpUsername = envSMTPUsername
smtpPassword = envSMTPPassword
smtpEncryption = envSMTPEncryption
smtpInsecureSkipVerify = envInsecureSkipVerify
rootCmd.Flags().StringVarP(&smtpHost, "smtp.host", "H", envSMTPHost, "Host of the SMTP server"+commentStyle.Render("($"+PopSMTPHost+")"))
rootCmd.Flags().IntVarP(&smtpPort, "smtp.port", "P", envSMTPPort, "Port of the SMTP server"+commentStyle.Render("($"+PopSMTPPort+")")) rootCmd.Flags().IntVarP(&smtpPort, "smtp.port", "P", envSMTPPort, "Port of the SMTP server"+commentStyle.Render("($"+PopSMTPPort+")"))
envSMTPUsername := os.Getenv(PopSMTPUsername)
rootCmd.Flags().StringVarP(&smtpUsername, "smtp.username", "U", envSMTPUsername, "Username of the SMTP server"+commentStyle.Render("($"+PopSMTPUsername+")")) rootCmd.Flags().StringVarP(&smtpUsername, "smtp.username", "U", envSMTPUsername, "Username of the SMTP server"+commentStyle.Render("($"+PopSMTPUsername+")"))
envSMTPPassword := os.Getenv(PopSMTPPassword)
rootCmd.Flags().StringVarP(&smtpPassword, "smtp.password", "p", envSMTPPassword, "Password of the SMTP server"+commentStyle.Render("($"+PopSMTPPassword+")")) rootCmd.Flags().StringVarP(&smtpPassword, "smtp.password", "p", envSMTPPassword, "Password of the SMTP server"+commentStyle.Render("($"+PopSMTPPassword+")"))
envSMTPEncryption := os.Getenv(PopSMTPEncryption)
rootCmd.Flags().StringVarP(&smtpEncryption, "smtp.encryption", "e", envSMTPEncryption, "Encryption type of the SMTP server (starttls, ssl, or none)"+commentStyle.Render("($"+PopSMTPEncryption+")")) 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"
rootCmd.Flags().BoolVarP(&smtpInsecureSkipVerify, "smtp.insecure", "i", envInsecureSkipVerify, "Skip TLS verification with SMTP server"+commentStyle.Render("($"+PopSMTPInsecureSkipVerify+")")) rootCmd.Flags().BoolVarP(&smtpInsecureSkipVerify, "smtp.insecure", "i", envInsecureSkipVerify, "Skip TLS verification with SMTP server"+commentStyle.Render("($"+PopSMTPInsecureSkipVerify+")"))
envResendAPIKey := os.Getenv(ResendAPIKey) envResendAPIKey := os.Getenv(ResendAPIKey)
rootCmd.Flags().StringVarP(&resendAPIKey, "resend.key", "r", envResendAPIKey, "API key for the Resend.com"+commentStyle.Render("($"+ResendAPIKey+")")) rootCmd.Flags().StringVarP(&resendAPIKey, "resend.key", "r", envResendAPIKey, "API key for the Resend.com"+commentStyle.Render("($"+ResendAPIKey+")"))
rootCmd.CompletionOptions.HiddenDefaultCmd = true 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 if len(CommitSHA) >= 7 { //nolint:gomnd
vt := rootCmd.VersionTemplate() vt := rootCmd.VersionTemplate()
rootCmd.SetVersionTemplate(vt[:len(vt)-1] + " (" + CommitSHA[0:7] + ")\n") rootCmd.SetVersionTemplate(vt[:len(vt)-1] + " (" + CommitSHA[0:7] + ")\n")

View File

@@ -5,14 +5,14 @@ import (
"strings" "strings"
"time" "time"
"github.com/charmbracelet/bubbles/filepicker" "charm.land/bubbles/v2/filepicker"
"github.com/charmbracelet/bubbles/help" "charm.land/bubbles/v2/help"
"github.com/charmbracelet/bubbles/key" "charm.land/bubbles/v2/key"
"github.com/charmbracelet/bubbles/list" "charm.land/bubbles/v2/list"
"github.com/charmbracelet/bubbles/spinner" "charm.land/bubbles/v2/spinner"
"github.com/charmbracelet/bubbles/textarea" "charm.land/bubbles/v2/textarea"
"github.com/charmbracelet/bubbles/textinput" "charm.land/bubbles/v2/textinput"
tea "github.com/charmbracelet/bubbletea" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/exp/ordered" "github.com/charmbracelet/x/exp/ordered"
"github.com/resendlabs/resend-go" "github.com/resendlabs/resend-go"
) )
@@ -89,68 +89,32 @@ func NewModel(defaults resend.SendEmailRequest, deliveryMethod DeliveryMethod) M
from := textinput.New() from := textinput.New()
from.Prompt = "From " from.Prompt = "From "
from.Placeholder = "me@example.com" from.Placeholder = "me@example.com"
from.PromptStyle = labelStyle.Copy()
from.PromptStyle = labelStyle
from.TextStyle = textStyle
from.Cursor.Style = cursorStyle
from.PlaceholderStyle = placeholderStyle
from.SetValue(defaults.From) from.SetValue(defaults.From)
to := textinput.New() to := textinput.New()
to.Prompt = "To " to.Prompt = "To "
to.PromptStyle = labelStyle.Copy()
to.Cursor.Style = cursorStyle
to.PlaceholderStyle = placeholderStyle
to.TextStyle = textStyle
to.Placeholder = "you@example.com" to.Placeholder = "you@example.com"
to.SetValue(strings.Join(defaults.To, ToSeparator)) to.SetValue(strings.Join(defaults.To, ToSeparator))
cc := textinput.New() cc := textinput.New()
cc.Prompt = "Cc " cc.Prompt = "Cc "
cc.PromptStyle = labelStyle.Copy()
cc.Cursor.Style = cursorStyle
cc.PlaceholderStyle = placeholderStyle
cc.TextStyle = textStyle
cc.Placeholder = "cc@example.com" cc.Placeholder = "cc@example.com"
cc.SetValue(strings.Join(defaults.Cc, ToSeparator)) cc.SetValue(strings.Join(defaults.Cc, ToSeparator))
bcc := textinput.New() bcc := textinput.New()
bcc.Prompt = "Bcc " bcc.Prompt = "Bcc "
bcc.PromptStyle = labelStyle.Copy()
bcc.Cursor.Style = cursorStyle
bcc.PlaceholderStyle = placeholderStyle
bcc.TextStyle = textStyle
bcc.Placeholder = "bcc@example.com" bcc.Placeholder = "bcc@example.com"
bcc.SetValue(strings.Join(defaults.Bcc, ToSeparator)) bcc.SetValue(strings.Join(defaults.Bcc, ToSeparator))
subject := textinput.New() subject := textinput.New()
subject.Prompt = "Subject " subject.Prompt = "Subject "
subject.PromptStyle = labelStyle.Copy()
subject.Cursor.Style = cursorStyle
subject.PlaceholderStyle = placeholderStyle
subject.TextStyle = textStyle
subject.Placeholder = "Hello!" subject.Placeholder = "Hello!"
subject.SetValue(defaults.Subject) subject.SetValue(defaults.Subject)
body := textarea.New() body := textarea.New()
body.Placeholder = "# Email" body.Placeholder = "# Email"
body.ShowLineNumbers = false
body.FocusedStyle.CursorLine = activeTextStyle
body.FocusedStyle.Prompt = activeLabelStyle
body.FocusedStyle.Text = activeTextStyle
body.FocusedStyle.Placeholder = placeholderStyle
body.BlurredStyle.CursorLine = textStyle
body.BlurredStyle.Prompt = labelStyle
body.BlurredStyle.Text = textStyle
body.BlurredStyle.Placeholder = placeholderStyle
body.Cursor.Style = cursorStyle
body.CharLimit = 4000
body.SetValue(defaults.Text) body.SetValue(defaults.Text)
body.CharLimit = 4000
// Adjust for signature (if none, this is a no-op)
body.CursorUp()
body.CursorUp()
body.Blur() body.Blur()
// Decide which input to focus. // Decide which input to focus.
@@ -170,9 +134,6 @@ func NewModel(defaults resend.SendEmailRequest, deliveryMethod DeliveryMethod) M
attachments.DisableQuitKeybindings() attachments.DisableQuitKeybindings()
attachments.SetShowTitle(true) attachments.SetShowTitle(true)
attachments.Title = "Attachments" attachments.Title = "Attachments"
attachments.Styles.Title = labelStyle
attachments.Styles.TitleBar = labelStyle
attachments.Styles.NoItems = placeholderStyle
attachments.SetShowHelp(false) attachments.SetShowHelp(false)
attachments.SetShowStatusBar(false) attachments.SetShowStatusBar(false)
attachments.SetStatusBarItemName("attachment", "attachments") attachments.SetStatusBarItemName("attachment", "attachments")
@@ -186,7 +147,6 @@ func NewModel(defaults resend.SendEmailRequest, deliveryMethod DeliveryMethod) M
picker.CurrentDirectory, _ = os.UserHomeDir() picker.CurrentDirectory, _ = os.UserHomeDir()
loadingSpinner := spinner.New() loadingSpinner := spinner.New()
loadingSpinner.Style = activeLabelStyle
loadingSpinner.Spinner = spinner.Dot loadingSpinner.Spinner = spinner.Dot
m := Model{ m := Model{
@@ -213,9 +173,7 @@ func NewModel(defaults resend.SendEmailRequest, deliveryMethod DeliveryMethod) M
// Init initializes the model. // Init initializes the model.
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
return tea.Batch( return nil
m.From.Cursor.BlinkCmd(),
)
} }
type clearErrMsg struct{} type clearErrMsg struct{}
@@ -370,70 +328,46 @@ func (m *Model) blurInputs() {
m.Cc.Blur() m.Cc.Blur()
m.Bcc.Blur() m.Bcc.Blur()
} }
m.From.PromptStyle = labelStyle
m.To.PromptStyle = labelStyle
if m.showCc {
m.Cc.PromptStyle = labelStyle
m.Cc.TextStyle = textStyle
m.Bcc.PromptStyle = labelStyle
m.Bcc.TextStyle = textStyle
}
m.Subject.PromptStyle = labelStyle
m.From.TextStyle = textStyle
m.To.TextStyle = textStyle
m.Subject.TextStyle = textStyle
m.Attachments.Styles.Title = labelStyle
m.Attachments.SetDelegate(attachmentDelegate{false}) m.Attachments.SetDelegate(attachmentDelegate{false})
} }
func (m *Model) focusActiveInput() { func (m *Model) focusActiveInput() {
switch m.state { switch m.state {
case editingFrom: case editingFrom:
m.From.PromptStyle = activeLabelStyle
m.From.TextStyle = activeTextStyle
m.From.Focus() m.From.Focus()
m.From.CursorEnd() m.From.CursorEnd()
case editingTo: case editingTo:
m.To.PromptStyle = activeLabelStyle
m.To.TextStyle = activeTextStyle
m.To.Focus() m.To.Focus()
m.To.CursorEnd() m.To.CursorEnd()
case editingCc: case editingCc:
m.Cc.PromptStyle = activeLabelStyle
m.Cc.TextStyle = activeTextStyle
m.Cc.Focus() m.Cc.Focus()
m.Cc.CursorEnd() m.Cc.CursorEnd()
case editingBcc: case editingBcc:
m.Bcc.PromptStyle = activeLabelStyle
m.Bcc.TextStyle = activeTextStyle
m.Bcc.Focus() m.Bcc.Focus()
m.Bcc.CursorEnd() m.Bcc.CursorEnd()
case editingSubject: case editingSubject:
m.Subject.PromptStyle = activeLabelStyle
m.Subject.TextStyle = activeTextStyle
m.Subject.Focus() m.Subject.Focus()
m.Subject.CursorEnd() m.Subject.CursorEnd()
case editingBody: case editingBody:
m.Body.Focus() m.Body.Focus()
m.Body.CursorEnd() m.Body.CursorEnd()
case editingAttachments: case editingAttachments:
m.Attachments.Styles.Title = activeLabelStyle
m.Attachments.SetDelegate(attachmentDelegate{true}) m.Attachments.SetDelegate(attachmentDelegate{true})
} }
} }
// View displays the application. // View displays the application.
func (m Model) View() string { func (m Model) View() tea.View {
if m.quitting { if m.quitting {
return "" return tea.NewView("")
} }
switch m.state { switch m.state {
case pickingFile: case pickingFile:
return "\n" + activeLabelStyle.Render("Attachments") + " " + commentStyle.Render(m.filepicker.CurrentDirectory) + return tea.NewView("\n" + activeLabelStyle.Render("Attachments") + " " + commentStyle.Render(m.filepicker.CurrentDirectory) +
"\n\n" + m.filepicker.View() "\n\n" + m.filepicker.View())
case sendingEmail: case sendingEmail:
return "\n " + m.loadingSpinner.View() + "Sending email" return tea.NewView("\n " + m.loadingSpinner.View() + "Sending email")
} }
var s strings.Builder var s strings.Builder
@@ -469,5 +403,5 @@ func (m Model) View() string {
s.WriteString(errorStyle.Render(m.err.Error())) s.WriteString(errorStyle.Render(m.err.Error()))
} }
return paddedStyle.Render(s.String()) return tea.NewView(paddedStyle.Render(s.String()))
} }

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: FromConfig{Account: accountName},
Signature: "",
UnsafeHTML: unsafeHTML,
Defaults: DefaultsConfig{Encryption: smtpEncryption},
Accounts: []Account{
{
Name: accountName,
Email: email,
Provider: provider,
Username: smtpUsername,
Password: smtpPassword,
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)
}

View File

@@ -3,17 +3,17 @@ package main
import ( import (
"strings" "strings"
"github.com/charmbracelet/lipgloss" "charm.land/lipgloss/v2"
) )
const accentColor = lipgloss.Color("99")
const yellowColor = lipgloss.Color("#ECFD66")
const whiteColor = lipgloss.Color("255")
const grayColor = lipgloss.Color("241")
const darkGrayColor = lipgloss.Color("236")
const lightGrayColor = lipgloss.Color("247")
var ( var (
accentColor = lipgloss.Color("99")
yellowColor = lipgloss.Color("#ECFD66")
whiteColor = lipgloss.Color("255")
grayColor = lipgloss.Color("241")
darkGrayColor = lipgloss.Color("236")
lightGrayColor = lipgloss.Color("247")
activeTextStyle = lipgloss.NewStyle().Foreground(whiteColor) activeTextStyle = lipgloss.NewStyle().Foreground(whiteColor)
textStyle = lipgloss.NewStyle().Foreground(lightGrayColor) textStyle = lipgloss.NewStyle().Foreground(lightGrayColor)