Compare commits
13 Commits
fd75abcd89
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 266be0bab5 | |||
| c9b77feabe | |||
| 52c5eb5ae8 | |||
| d54fd01001 | |||
| 7ab9f00c4f | |||
|
|
2ca198a81b | ||
|
|
298af5aa76 | ||
|
|
25202e88d1 | ||
|
|
2e5de3730c | ||
|
|
12ddc506a9 | ||
|
|
e2e6252912 | ||
|
|
c2494bfa79 | ||
|
|
6c42402ba3 |
@@ -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
11
.gitignore
vendored
@@ -3,3 +3,14 @@
|
|||||||
dist
|
dist
|
||||||
completions
|
completions
|
||||||
manpages
|
manpages
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
*.ini
|
||||||
|
|
||||||
|
.vs/
|
||||||
|
.vscode/
|
||||||
|
.fleet/
|
||||||
|
.zed/
|
||||||
|
|
||||||
|
*.exe
|
||||||
|
pop*
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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
74
AGENTS.md
Normal 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
|
||||||
|
```
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
FROM gcr.io/distroless/static
|
|
||||||
COPY pop /usr/local/bin/pop
|
|
||||||
ENTRYPOINT [ "/usr/local/bin/pop" ]
|
|
||||||
@@ -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
347
config.go
Normal 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,
|
||||||
|
},
|
||||||
|
}
|
||||||
99
doc/001-feature-planning.md
Normal file
99
doc/001-feature-planning.md
Normal 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] 是否本地存储已接收邮件 - 轻量版,不存储
|
||||||
85
doc/002-config-simplification.md
Normal file
85
doc/002-config-simplification.md
Normal 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 逻辑调整
|
||||||
|
- [ ] 测试配置读取和写入
|
||||||
102
doc/003-config-separation.md
Normal file
102
doc/003-config-separation.md
Normal 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 命令逻辑
|
||||||
|
- [ ] 更新文档
|
||||||
135
doc/003-inbox-ui-redesign.md
Normal file
135
doc/003-inbox-ui-redesign.md
Normal 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. **邮件操作**:查看、删除、标记已读等操作待实现
|
||||||
52
email.go
52
email.go
@@ -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
69
go.mod
@@ -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
155
go.sum
@@ -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
221
history.go
Normal 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
187
history_cmd.go
Normal 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
337
imap.go
Normal 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
67
inbox.go
Normal 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
481
inbox/model.go
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
110
main.go
110
main.go
@@ -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))
|
||||||
if envSMTPPort == 0 {
|
|
||||||
envSMTPPort = 587
|
|
||||||
}
|
|
||||||
rootCmd.Flags().IntVarP(&smtpPort, "smtp.port", "P", envSMTPPort, "Port of the SMTP server"+commentStyle.Render("($"+PopSMTPPort+")"))
|
|
||||||
envSMTPUsername := os.Getenv(PopSMTPUsername)
|
envSMTPUsername := os.Getenv(PopSMTPUsername)
|
||||||
rootCmd.Flags().StringVarP(&smtpUsername, "smtp.username", "U", envSMTPUsername, "Username of the SMTP server"+commentStyle.Render("($"+PopSMTPUsername+")"))
|
|
||||||
envSMTPPassword := os.Getenv(PopSMTPPassword)
|
envSMTPPassword := os.Getenv(PopSMTPPassword)
|
||||||
rootCmd.Flags().StringVarP(&smtpPassword, "smtp.password", "p", envSMTPPassword, "Password of the SMTP server"+commentStyle.Render("($"+PopSMTPPassword+")"))
|
|
||||||
envSMTPEncryption := os.Getenv(PopSMTPEncryption)
|
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+")"))
|
|
||||||
envInsecureSkipVerify := os.Getenv(PopSMTPInsecureSkipVerify) == "true"
|
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 {
|
||||||
|
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().StringVarP(&smtpUsername, "smtp.username", "U", envSMTPUsername, "Username of the SMTP server"+commentStyle.Render("($"+PopSMTPUsername+")"))
|
||||||
|
rootCmd.Flags().StringVarP(&smtpPassword, "smtp.password", "p", envSMTPPassword, "Password of the SMTP server"+commentStyle.Render("($"+PopSMTPPassword+")"))
|
||||||
|
rootCmd.Flags().StringVarP(&smtpEncryption, "smtp.encryption", "e", envSMTPEncryption, "Encryption type of the SMTP server (starttls, ssl, or none)"+commentStyle.Render("($"+PopSMTPEncryption+")"))
|
||||||
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")
|
||||||
|
|||||||
98
model.go
98
model.go
@@ -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
201
onboarding.go
Normal 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)
|
||||||
|
}
|
||||||
16
style.go
16
style.go
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user