diff --git a/AGENTS.md b/AGENTS.md index b08339a..5d8e27d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,12 +6,37 @@ |------|------|------| | 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 提示) + ## 配置文件格式 diff --git a/attachments.go b/attachments.go index 3967d4c..26d15db 100644 --- a/attachments.go +++ b/attachments.go @@ -4,8 +4,8 @@ import ( "io" "path/filepath" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" + "charm.land/bubbles/v2/list" + tea "charm.land/bubbletea/v2" ) type attachment string diff --git a/doc/003-inbox-ui-redesign.md b/doc/003-inbox-ui-redesign.md new file mode 100644 index 0000000..bbfcd08 --- /dev/null +++ b/doc/003-inbox-ui-redesign.md @@ -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. **邮件操作**:查看、删除、标记已读等操作待实现 diff --git a/email.go b/email.go index d05b26d..8dcde5c 100644 --- a/email.go +++ b/email.go @@ -10,7 +10,7 @@ import ( "strings" "time" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" "github.com/resendlabs/resend-go" mail "github.com/xhit/go-simple-mail/v2" "github.com/yuin/goldmark" diff --git a/go.mod b/go.mod index da8c9de..39aed05 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,12 @@ module github.com/charmbracelet/pop go 1.26.1 require ( + charm.land/bubbles/v2 v2.1.0 + charm.land/bubbletea/v2 v2.0.2 charm.land/huh/v2 v2.0.3 - github.com/charmbracelet/bubbles v1.0.0 - github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/lipgloss v1.1.0 + 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/emersion/go-imap/v2 v2.0.0-beta.8 github.com/mattn/go-sqlite3 v1.14.42 github.com/muesli/mango-cobra v1.3.0 github.com/muesli/roff v0.1.0 @@ -20,20 +20,14 @@ require ( ) require ( - charm.land/bubbles/v2 v2.0.0 // indirect - charm.land/bubbletea/v2 v2.0.2 // indirect - charm.land/lipgloss/v2 v2.0.1 // indirect - github.com/BrianLeishman/go-imap v0.1.27 // indirect github.com/StirlingMarketingGroup/go-retry v0.0.0-20190512160921-94a8eb23e893 // indirect github.com/atotto/clipboard v0.1.4 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.2.0 // indirect github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect - github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect @@ -42,11 +36,7 @@ require ( github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/emersion/go-message v0.18.2 // indirect - github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fatih/color v1.19.0 // indirect - github.com/go-test/deep v1.1.1 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect github.com/inbucket/html2text v1.0.0 // indirect @@ -55,14 +45,11 @@ require ( github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.21 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/mango v0.2.0 // indirect github.com/muesli/mango-pflag v0.1.0 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect github.com/olekukonko/errors v1.2.0 // indirect github.com/olekukonko/ll v0.1.8 // indirect @@ -74,7 +61,7 @@ require ( github.com/spf13/pflag v1.0.9 // indirect github.com/sqs/go-xoauth2 v0.0.0-20120917012134-0911dad68e56 // indirect github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect - github.com/toorop/go-dkim v0.0.0-20240103092955-90b7d1423f92 // indirect + github.com/toorop/go-dkim v0.0.0-20201103131630-e1cd1a0a5208 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/sync v0.20.0 // indirect diff --git a/go.sum b/go.sum index 61087b9..0440379 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,11 @@ -charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= -charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= +charm.land/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.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys= -charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= +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= @@ -14,8 +14,6 @@ github.com/StirlingMarketingGroup/go-retry v0.0.0-20190512160921-94a8eb23e893 h1 github.com/StirlingMarketingGroup/go-retry v0.0.0-20190512160921-94a8eb23e893/go.mod h1:RHK0VFlYDZQeNFg4C2dp7cPE6urfbpgyEZIGxa9f5zw= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA= @@ -24,20 +22,12 @@ github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= -github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= -github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= -github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= @@ -63,23 +53,13 @@ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= -github.com/davecgh/go-spew v1.1.1+incompatible/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug= -github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48= -github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= -github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= -github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= -github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= -github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= -github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= 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= @@ -99,18 +79,12 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= -github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ= @@ -121,8 +95,6 @@ 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/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= github.com/olekukonko/errors v1.2.0 h1:10Zcn4GeV59t/EGqJc8fUjtFT/FuUh5bTMzZ1XwmCRo= @@ -152,65 +124,28 @@ github.com/sqs/go-xoauth2 v0.0.0-20120917012134-0911dad68e56 h1:KCgKdj+ha4CgnVHI github.com/sqs/go-xoauth2 v0.0.0-20120917012134-0911dad68e56/go.mod h1:ghDEBrT4oFcM4rv18bzcZaAWXbHPGpDa4e2hh9oXL8A= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= -github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/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-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/go.mod h1:b7P5ygho6SYE+VIqpxA6QkYfv4teeyG4MKqB3utRu98= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/history_cmd.go b/history_cmd.go index 146cc7f..3c955fd 100644 --- a/history_cmd.go +++ b/history_cmd.go @@ -4,9 +4,9 @@ import ( "fmt" "io" - "github.com/charmbracelet/bubbles/list" - "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "charm.land/bubbles/v2/list" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" ) type HistoryItem struct { @@ -125,17 +125,17 @@ func (m *HistoryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } -func (m *HistoryModel) View() string { +func (m *HistoryModel) View() tea.View { if m.loading { - return historyLoadingStyle.Render("正在加载历史...") + return tea.NewView(historyLoadingStyle.Render("正在加载历史...")) } if m.err != nil { - return historyErrorStyle.Render(fmt.Sprintf("错误: %v", m.err)) + return tea.NewView(historyErrorStyle.Render(fmt.Sprintf("错误: %v", m.err))) } if len(m.items) == 0 { - return historyNoItemsStyle.Render("暂无发送历史") + return tea.NewView(historyNoItemsStyle.Render("暂无发送历史")) } - return m.list.View() + return tea.NewView(m.list.View()) } var ( diff --git a/imap.go b/imap.go index 005eee8..7ce1690 100644 --- a/imap.go +++ b/imap.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "strings" "time" imap "github.com/BrianLeishman/go-imap" @@ -193,3 +194,144 @@ func sortEmailsByDate(emails []ReceivedEmail) { } } } + +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 +} diff --git a/inbox.go b/inbox.go index 64fffb5..acb7934 100644 --- a/inbox.go +++ b/inbox.go @@ -3,13 +3,32 @@ package main import ( "fmt" - "github.com/charmbracelet/bubbletea" + "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 { diff --git a/inbox/model.go b/inbox/model.go index e855abb..fd3b08e 100644 --- a/inbox/model.go +++ b/inbox/model.go @@ -3,13 +3,37 @@ package inbox import ( "fmt" "io" + "strings" "time" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" + "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 @@ -52,25 +76,77 @@ func formatTimeAgo(d time.Duration) string { 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 + 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{}, 0, 10) + 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{}, + list: l, + emails: []EmailItem{}, + windowWidth: 0, + spinner: s, } } @@ -85,31 +161,176 @@ func (m *InboxModel) SetEmails(emails []EmailItem) { } func (m InboxModel) Init() tea.Cmd { - return nil + 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.list.SetWidth(msg.Width) + 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 } - var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) return m, cmd } -func (m InboxModel) View() string { +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 loadingStyle.Render("正在加载邮件...") + return tea.NewView(loadingStyle.Render("正在加载邮件...")) } if m.err != nil { - return errorStyle.Render(fmt.Sprintf("错误: %v", m.err)) + return tea.NewView(errorStyle.Render(fmt.Sprintf("错误: %v", m.err))) } - return m.list.View() + + 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) { @@ -130,7 +351,7 @@ func (m InboxModel) SelectedEmail() *EmailItem { var ( inboxTitleStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("86")). + Foreground(lipgloss.Color("219")). Bold(true) inboxNoItemsStyle = lipgloss.NewStyle(). @@ -138,17 +359,75 @@ var ( Padding(1, 2) loadingStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("86")). + 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 1 } +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 @@ -161,29 +440,42 @@ func (d emailDelegate) Render(w io.Writer, m list.Model, index int, item list.It isSelected := index == m.Index() - titleStyle := emailTitleStyle - descStyle := emailDescStyle + 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 { - titleStyle = emailTitleStyleSelected - descStyle = emailDescStyleSelected + 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)) } - - fmt.Fprintf(w, "%s\n%s", titleStyle.Render(email.Title()), descStyle.Render(email.Description())) } - -var ( - emailTitleStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("255")) - - emailTitleStyleSelected = lipgloss.NewStyle(). - Foreground(lipgloss.Color("255")). - Background(lipgloss.Color("68")) - - emailDescStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("241")) - - emailDescStyleSelected = lipgloss.NewStyle(). - Foreground(lipgloss.Color("186")). - Background(lipgloss.Color("68")) -) diff --git a/keymap.go b/keymap.go index 234971e..886ed9d 100644 --- a/keymap.go +++ b/keymap.go @@ -1,6 +1,6 @@ package main -import "github.com/charmbracelet/bubbles/key" +import "charm.land/bubbles/v2/key" // KeyMap represents the key bindings for the application. type KeyMap struct { diff --git a/main.go b/main.go index d214208..bee9649 100644 --- a/main.go +++ b/main.go @@ -9,7 +9,7 @@ import ( "strconv" "strings" - tea "github.com/charmbracelet/bubbletea" + tea "charm.land/bubbletea/v2" mcobra "github.com/muesli/mango-cobra" "github.com/muesli/roff" "github.com/resendlabs/resend-go" diff --git a/model.go b/model.go index 244fdfc..1f1dca2 100644 --- a/model.go +++ b/model.go @@ -5,14 +5,14 @@ import ( "strings" "time" - "github.com/charmbracelet/bubbles/filepicker" - "github.com/charmbracelet/bubbles/help" - "github.com/charmbracelet/bubbles/key" - "github.com/charmbracelet/bubbles/list" - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/textarea" - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" + "charm.land/bubbles/v2/filepicker" + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/list" + "charm.land/bubbles/v2/spinner" + "charm.land/bubbles/v2/textarea" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/exp/ordered" "github.com/resendlabs/resend-go" ) @@ -89,68 +89,32 @@ func NewModel(defaults resend.SendEmailRequest, deliveryMethod DeliveryMethod) M from := textinput.New() from.Prompt = "From " 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) to := textinput.New() to.Prompt = "To " - to.PromptStyle = labelStyle.Copy() - to.Cursor.Style = cursorStyle - to.PlaceholderStyle = placeholderStyle - to.TextStyle = textStyle to.Placeholder = "you@example.com" to.SetValue(strings.Join(defaults.To, ToSeparator)) cc := textinput.New() cc.Prompt = "Cc " - cc.PromptStyle = labelStyle.Copy() - cc.Cursor.Style = cursorStyle - cc.PlaceholderStyle = placeholderStyle - cc.TextStyle = textStyle cc.Placeholder = "cc@example.com" cc.SetValue(strings.Join(defaults.Cc, ToSeparator)) bcc := textinput.New() bcc.Prompt = "Bcc " - bcc.PromptStyle = labelStyle.Copy() - bcc.Cursor.Style = cursorStyle - bcc.PlaceholderStyle = placeholderStyle - bcc.TextStyle = textStyle bcc.Placeholder = "bcc@example.com" bcc.SetValue(strings.Join(defaults.Bcc, ToSeparator)) subject := textinput.New() subject.Prompt = "Subject " - subject.PromptStyle = labelStyle.Copy() - subject.Cursor.Style = cursorStyle - subject.PlaceholderStyle = placeholderStyle - subject.TextStyle = textStyle subject.Placeholder = "Hello!" subject.SetValue(defaults.Subject) body := textarea.New() 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) - - // Adjust for signature (if none, this is a no-op) - body.CursorUp() - body.CursorUp() - + body.CharLimit = 4000 body.Blur() // Decide which input to focus. @@ -170,9 +134,6 @@ func NewModel(defaults resend.SendEmailRequest, deliveryMethod DeliveryMethod) M attachments.DisableQuitKeybindings() attachments.SetShowTitle(true) attachments.Title = "Attachments" - attachments.Styles.Title = labelStyle - attachments.Styles.TitleBar = labelStyle - attachments.Styles.NoItems = placeholderStyle attachments.SetShowHelp(false) attachments.SetShowStatusBar(false) attachments.SetStatusBarItemName("attachment", "attachments") @@ -186,7 +147,6 @@ func NewModel(defaults resend.SendEmailRequest, deliveryMethod DeliveryMethod) M picker.CurrentDirectory, _ = os.UserHomeDir() loadingSpinner := spinner.New() - loadingSpinner.Style = activeLabelStyle loadingSpinner.Spinner = spinner.Dot m := Model{ @@ -213,9 +173,7 @@ func NewModel(defaults resend.SendEmailRequest, deliveryMethod DeliveryMethod) M // Init initializes the model. func (m Model) Init() tea.Cmd { - return tea.Batch( - m.From.Cursor.BlinkCmd(), - ) + return nil } type clearErrMsg struct{} @@ -370,70 +328,46 @@ func (m *Model) blurInputs() { m.Cc.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}) } func (m *Model) focusActiveInput() { switch m.state { case editingFrom: - m.From.PromptStyle = activeLabelStyle - m.From.TextStyle = activeTextStyle m.From.Focus() m.From.CursorEnd() case editingTo: - m.To.PromptStyle = activeLabelStyle - m.To.TextStyle = activeTextStyle m.To.Focus() m.To.CursorEnd() case editingCc: - m.Cc.PromptStyle = activeLabelStyle - m.Cc.TextStyle = activeTextStyle m.Cc.Focus() m.Cc.CursorEnd() case editingBcc: - m.Bcc.PromptStyle = activeLabelStyle - m.Bcc.TextStyle = activeTextStyle m.Bcc.Focus() m.Bcc.CursorEnd() case editingSubject: - m.Subject.PromptStyle = activeLabelStyle - m.Subject.TextStyle = activeTextStyle m.Subject.Focus() m.Subject.CursorEnd() case editingBody: m.Body.Focus() m.Body.CursorEnd() case editingAttachments: - m.Attachments.Styles.Title = activeLabelStyle m.Attachments.SetDelegate(attachmentDelegate{true}) } } // View displays the application. -func (m Model) View() string { +func (m Model) View() tea.View { if m.quitting { - return "" + return tea.NewView("") } switch m.state { case pickingFile: - return "\n" + activeLabelStyle.Render("Attachments") + " " + commentStyle.Render(m.filepicker.CurrentDirectory) + - "\n\n" + m.filepicker.View() + return tea.NewView("\n" + activeLabelStyle.Render("Attachments") + " " + commentStyle.Render(m.filepicker.CurrentDirectory) + + "\n\n" + m.filepicker.View()) case sendingEmail: - return "\n " + m.loadingSpinner.View() + "Sending email" + return tea.NewView("\n " + m.loadingSpinner.View() + "Sending email") } var s strings.Builder @@ -469,5 +403,5 @@ func (m Model) View() string { s.WriteString(errorStyle.Render(m.err.Error())) } - return paddedStyle.Render(s.String()) + return tea.NewView(paddedStyle.Render(s.String())) } diff --git a/style.go b/style.go index 99ad181..5ffa560 100644 --- a/style.go +++ b/style.go @@ -3,17 +3,17 @@ package main import ( "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 ( + 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) textStyle = lipgloss.NewStyle().Foreground(lightGrayColor)