feat: 重构配置文件格式并添加 IMAP ID 命令支持

- 配置文件分离:用户配置与项目配置分离,项目级配置(客户端信息、需要 ID 命令的 provider)放在代码中
- 新增 check_id 字段:用户可选择禁用单个账户的 ID 命令发送
- 简化 provider:只保留 163 和 QQ,移除未使用的 Gmail/Outlook/188 等
- 修复 163 邮箱收件箱问题:通过发送 IMAP ID 命令解决 Unsafe Login 错误

BREAKING CHANGE: 配置文件格式变化,旧配置不兼容
This commit is contained in:
2026-04-10 00:39:06 +08:00
parent d54fd01001
commit 52c5eb5ae8
7 changed files with 334 additions and 200 deletions

View File

@@ -16,11 +16,14 @@
## 配置文件格式
```yaml
from:
account: foolsecret@163.com
accounts:
-
- name: work
email: 邮箱
provider: 163
username: 用户名
username: foolsecret@163.com
password: 密钥
encryption: ssl
insecure: false
@@ -30,8 +33,17 @@ accounts:
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
```

241
config.go
View File

@@ -11,22 +11,31 @@ import (
const configFileName = "config.yml"
type Config struct {
From string `yaml:"from"`
Signature string `yaml:"signature"`
SMTP SMTPConfig `yaml:"smtp"`
UnsafeHTML bool `yaml:"unsafe_html"`
Accounts []Account `yaml:"accounts"`
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"`
IMAP IMAPConfig `yaml:"imap"`
SMTP SMTPConfig `yaml:"smtp"`
SMTPEncryption string `yaml:"smtp_encryption"`
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 {
@@ -48,42 +57,12 @@ type SMTPConfig struct {
}
var defaultConfig = Config{
From: "",
Signature: "",
SMTP: SMTPConfig{
Host: "",
Port: 587,
Username: "",
Password: "",
Encryption: "starttls",
InsecureSkipVerify: false,
},
From: FromConfig{},
Defaults: DefaultsConfig{Encryption: "starttls"},
Signature: "",
UnsafeHTML: false,
}
var emailProviders = map[string]SMTPConfig{
"163": {
Host: "smtp.163.com",
Port: 465,
Encryption: "ssl",
},
"QQ": {
Host: "smtp.qq.com",
Port: 465,
Encryption: "ssl",
},
"Gmail": {
Host: "smtp.gmail.com",
Port: 465,
Encryption: "ssl",
},
"Outlook": {
Host: "smtp.office365.com",
Port: 587,
Encryption: "starttls",
},
}
var providerDefaults = map[string]struct {
IMAPHost string
IMAPPort int
@@ -100,14 +79,6 @@ var providerDefaults = map[string]struct {
SMTPPort: 465,
SMTPEncryption: "ssl",
},
"188": {
IMAPHost: "imap.188.com",
IMAPPort: 993,
IMAPEncryption: "ssl",
SMTPHost: "smtp.188.com",
SMTPPort: 465,
SMTPEncryption: "ssl",
},
"QQ": {
IMAPHost: "imap.qq.com",
IMAPPort: 993,
@@ -116,22 +87,6 @@ var providerDefaults = map[string]struct {
SMTPPort: 465,
SMTPEncryption: "ssl",
},
"Gmail": {
IMAPHost: "imap.gmail.com",
IMAPPort: 993,
IMAPEncryption: "ssl",
SMTPHost: "smtp.gmail.com",
SMTPPort: 465,
SMTPEncryption: "ssl",
},
"Outlook": {
IMAPHost: "outlook.office365.com",
IMAPPort: 993,
IMAPEncryption: "ssl",
SMTPHost: "smtp.office365.com",
SMTPPort: 587,
SMTPEncryption: "starttls",
},
}
var imapProviders = map[string]IMAPConfig{
@@ -147,43 +102,44 @@ var imapProviders = map[string]IMAPConfig{
Username: "",
Password: "",
},
"Gmail": {
Host: "imap.gmail.com",
Port: 993,
Username: "",
Password: "",
},
"Outlook": {
Host: "outlook.office365.com",
Port: 993,
Username: "",
Password: "",
},
}
func normalizeAccount(acc Account) Account {
func normalizeAccount(acc Account, defaults DefaultsConfig) Account {
if acc.Provider == "" {
acc.Provider = getProviderName(acc.Email)
}
if defaults, ok := providerDefaults[acc.Provider]; ok {
if providerDefaults, ok := providerDefaults[acc.Provider]; ok {
if acc.IMAP.Host == "" {
acc.IMAP.Host = defaults.IMAPHost
acc.IMAP.Port = defaults.IMAPPort
acc.IMAP.Host = providerDefaults.IMAPHost
acc.IMAP.Port = providerDefaults.IMAPPort
}
if acc.IMAP.Encryption == "" && defaults.IMAPEncryption != "" {
acc.IMAP.Encryption = defaults.IMAPEncryption
if acc.IMAP.Encryption == "" && providerDefaults.IMAPEncryption != "" {
acc.IMAP.Encryption = providerDefaults.IMAPEncryption
}
if acc.SMTP.Host == "" {
acc.SMTP.Host = defaults.SMTPHost
acc.SMTP.Port = defaults.SMTPPort
acc.SMTP.Host = providerDefaults.SMTPHost
acc.SMTP.Port = providerDefaults.SMTPPort
}
if acc.SMTPEncryption == "" {
acc.SMTPEncryption = defaults.SMTPEncryption
if acc.SMTP.Encryption == "" && providerDefaults.SMTPEncryption != "" {
acc.SMTP.Encryption = providerDefaults.SMTPEncryption
}
if acc.SMTP.Encryption == "" && acc.SMTPEncryption != "" {
acc.SMTP.Encryption = acc.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 == "" {
@@ -206,6 +162,11 @@ func normalizeAccount(acc Account) Account {
acc.Name = acc.Provider
}
if acc.CheckID == nil {
defaultCheckID := ProjectConfig.ProvidersNeedingCheckID[acc.Provider]
acc.CheckID = &defaultCheckID
}
return acc
}
@@ -301,26 +262,10 @@ func getAccounts() ([]Account, error) {
return nil, err
}
var accounts []Account
if cfg.SMTP.Username != "" {
accounts = append(accounts, Account{
Name: getProviderName(cfg.SMTP.Username),
Email: cfg.SMTP.Username,
SMTP: cfg.SMTP,
IMAP: IMAPConfig{
Host: getIMAPHost(cfg.SMTP.Username),
Port: 993,
Username: cfg.SMTP.Username,
Password: cfg.SMTP.Password,
},
})
}
accounts = append(accounts, cfg.Accounts...)
accounts := cfg.Accounts
for i := range accounts {
accounts[i] = normalizeAccount(accounts[i])
accounts[i] = normalizeAccount(accounts[i], cfg.Defaults)
}
return accounts, nil
@@ -330,23 +275,10 @@ func getProviderName(email string) string {
if strings.HasSuffix(email, "@163.com") || strings.HasSuffix(email, "@vip.163.com") {
return "163"
}
if strings.HasSuffix(email, "@188.com") || strings.HasSuffix(email, "@vip.188.com") {
return "188"
}
if strings.HasSuffix(email, "@qq.com") || strings.HasSuffix(email, "@vip.qq.com") {
return "QQ"
}
if strings.HasSuffix(email, "@gmail.com") {
return "Gmail"
}
if strings.HasSuffix(email, "@outlook.com") || strings.HasSuffix(email, "@office365.com") {
return "Outlook"
}
parts := strings.Split(email, "@")
if len(parts) > 1 {
return strings.Split(parts[1], ".")[0]
}
return "Email"
return "custom"
}
func getIMAPHost(email string) string {
@@ -356,11 +288,60 @@ func getIMAPHost(email string) string {
if strings.HasSuffix(email, "@qq.com") {
return "imap.qq.com"
}
if strings.HasSuffix(email, "@gmail.com") {
return "imap.gmail.com"
return ""
}
func getDefaultFromEmail() string {
cfg, err := loadConfig()
if err != nil || cfg.From.Account == "" {
return ""
}
if strings.HasSuffix(email, "@outlook.com") || strings.HasSuffix(email, "@office365.com") {
return "outlook.office365.com"
accounts, err := getAccounts()
if err != nil {
return ""
}
for _, acc := range accounts {
if acc.Name == cfg.From.Account {
if acc.Email != "" {
return acc.Email
}
return acc.Username
}
}
return ""
}
func getDefaultAccount(accounts []Account, accountName string) *Account {
if accountName == "" {
return nil
}
for i := range accounts {
if accounts[i].Name == accountName {
return &accounts[i]
}
}
return nil
}
type Info struct {
Name string
Version string
Vendor string
}
var ProjectConfig = struct {
Info Info
ProvidersNeedingCheckID map[string]bool
}{
Info: Info{
Name: "pop",
Version: "1.0",
Vendor: "charmbracelet",
},
ProvidersNeedingCheckID: map[string]bool{
"163": true,
"QQ": true,
},
}

View File

@@ -10,20 +10,47 @@
## 讨论内容
### 简化方案
用户建议采用简化配置:
### 最终配置格式
```yaml
from:
account: work # 通过 name 引用账户
defaults:
encryption: ssl
insecure: false
unsafe_html: false
signature: ""
accounts:
- name: "工作邮箱"
email: "user@163.com"
provider: "163" # 自动填充 imap/smtp
username: "user@163.com" # 统一认证
password: "xxx"
smtp_encryption: "ssl" # 可选
- 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**: 通过邮箱后缀自动判断
@@ -33,35 +60,26 @@ accounts:
- `@outlook.com` / `@office365.com` → Outlook
- 其他 → custom
2. **Provider 不匹配时**: 视为 custom需手动配置 host/port
2. **自动填充**: 根据 provider 自动填充 imap/smtp 的 host/port/encryption
### Onboarding 流程
3. **统一认证**: username/password 只需在账户顶层配置,会自动复制到 IMAP 和 SMTP
```
1. 输入邮箱地址
2. 自动识别 provider
3. ┌─ 已知服务商 → 显示默认配置,提示确认
└─ 自定义邮箱 → 提示手动配置 IMAP/SMTP
4. 输入密码
5. 保存配置
```
### 向后兼容
**不兼容旧版配置**,需要用户重新配置。
## 实现计划
| 步骤 | 文件 | 修改内容 |
|------|------|---------|
| 1 | config.go | 新增 Provider、SMTPEncryption 字段 |
| 2 | config.go | 添加 normalizeAccount() 自动填充默认值 |
| 3 | config.go | 添加 providerDefaults 映射表 |
| 4 | config.go | 修改 getAccounts() 调用 normalize |
| 5 | onboarding.go | 修改交互流程,支持自动识别和默认值 |
| 1 | config.go | 新增 FromConfig 和 DefaultsConfig 结构 |
| 2 | config.go | 修改 normalizeAccount() 支持 defaults |
| 3 | config.go | 新增 getDefaultFromEmail() 和 getDefaultAccount() |
| 4 | main.go | 使用新函数获取默认账户信息 |
| 5 | 测试 | 验证配置读取和发送功能 |
## 待处理
## 已完成
- [ ] 实现 config.go 修改
- [ ] 实现 onboarding.go 流程调整
- [x] 实现 config.go 修改
- [x] 实现 main.go 逻辑调整
- [ ] 测试配置读取和写入

View File

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

14
imap.go
View File

@@ -62,6 +62,20 @@ func FetchUnreadEmails(account Account, days int) ([]ReceivedEmail, error) {
}
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)

63
main.go
View File

@@ -203,6 +203,7 @@ func init() {
rootCmd.AddCommand(ManCmd)
cfg, _ := loadConfig()
_ = cfg
rootCmd.Flags().StringSliceVar(&bcc, "bcc", []string{}, "BCC recipients")
rootCmd.Flags().StringSliceVar(&cc, "cc", []string{}, "CC recipients")
@@ -211,7 +212,7 @@ func init() {
rootCmd.Flags().StringVarP(&body, "body", "b", "", "Email's contents")
envFrom := os.Getenv(PopFrom)
if envFrom == "" {
envFrom = cfg.From
envFrom = getDefaultFromEmail()
}
from = envFrom
rootCmd.Flags().StringVarP(&from, "from", "f", envFrom, "Email's sender"+commentStyle.Render("($"+PopFrom+")"))
@@ -228,46 +229,52 @@ func init() {
}
rootCmd.Flags().StringVarP(&signature, "signature", "x", envSignature, "Signature to display at the end of the email."+commentStyle.Render("($"+PopSignature+")"))
envSMTPHost := os.Getenv(PopSMTPHost)
if envSMTPHost == "" {
envSMTPHost = cfg.SMTP.Host
}
smtpHost = envSMTPHost
rootCmd.Flags().StringVarP(&smtpHost, "smtp.host", "H", envSMTPHost, "Host of the SMTP server"+commentStyle.Render("($"+PopSMTPHost+")"))
envSMTPPort, _ := strconv.Atoi(os.Getenv(PopSMTPPort))
if envSMTPPort == 0 {
envSMTPPort = cfg.SMTP.Port
envSMTPUsername := os.Getenv(PopSMTPUsername)
envSMTPPassword := os.Getenv(PopSMTPPassword)
envSMTPEncryption := os.Getenv(PopSMTPEncryption)
envInsecureSkipVerify := os.Getenv(PopSMTPInsecureSkipVerify) == "true"
defaultAccounts, _ := getAccounts()
defaultAccount := getDefaultAccount(defaultAccounts, cfg.From.Account)
if envSMTPHost == "" && defaultAccount != nil {
envSMTPHost = defaultAccount.SMTP.Host
}
if envSMTPPort == 0 && defaultAccount != nil {
envSMTPPort = defaultAccount.SMTP.Port
if envSMTPPort == 0 {
envSMTPPort = 587
}
}
smtpPort = envSMTPPort
rootCmd.Flags().IntVarP(&smtpPort, "smtp.port", "P", envSMTPPort, "Port of the SMTP server"+commentStyle.Render("($"+PopSMTPPort+")"))
envSMTPUsername := os.Getenv(PopSMTPUsername)
if envSMTPUsername == "" {
envSMTPUsername = cfg.SMTP.Username
if envSMTPUsername == "" && defaultAccount != nil {
envSMTPUsername = defaultAccount.SMTP.Username
}
smtpUsername = envSMTPUsername
rootCmd.Flags().StringVarP(&smtpUsername, "smtp.username", "U", envSMTPUsername, "Username of the SMTP server"+commentStyle.Render("($"+PopSMTPUsername+")"))
envSMTPPassword := os.Getenv(PopSMTPPassword)
if envSMTPPassword == "" {
envSMTPPassword = cfg.SMTP.Password
if envSMTPPassword == "" && defaultAccount != nil {
envSMTPPassword = defaultAccount.SMTP.Password
}
smtpPassword = envSMTPPassword
rootCmd.Flags().StringVarP(&smtpPassword, "smtp.password", "p", envSMTPPassword, "Password of the SMTP server"+commentStyle.Render("($"+PopSMTPPassword+")"))
envSMTPEncryption := os.Getenv(PopSMTPEncryption)
if envSMTPEncryption == "" {
envSMTPEncryption = cfg.SMTP.Encryption
if envSMTPEncryption == "" && defaultAccount != nil {
envSMTPEncryption = defaultAccount.SMTP.Encryption
if envSMTPEncryption == "" {
envSMTPEncryption = "starttls"
}
}
smtpEncryption = envSMTPEncryption
rootCmd.Flags().StringVarP(&smtpEncryption, "smtp.encryption", "e", envSMTPEncryption, "Encryption type of the SMTP server (starttls, ssl, or none)"+commentStyle.Render("($"+PopSMTPEncryption+")"))
envInsecureSkipVerify := os.Getenv(PopSMTPInsecureSkipVerify) == "true"
if !envInsecureSkipVerify {
envInsecureSkipVerify = cfg.SMTP.InsecureSkipVerify
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+")"))
envResendAPIKey := os.Getenv(ResendAPIKey)
rootCmd.Flags().StringVarP(&resendAPIKey, "resend.key", "r", envResendAPIKey, "API key for the Resend.com"+commentStyle.Render("($"+ResendAPIKey+")"))

View File

@@ -156,17 +156,17 @@ func runOnboarding() error {
}
cfg := Config{
From: email,
From: FromConfig{Account: accountName},
Signature: "",
UnsafeHTML: unsafeHTML,
Defaults: DefaultsConfig{Encryption: smtpEncryption},
Accounts: []Account{
{
Name: accountName,
Email: email,
Provider: provider,
Username: smtpUsername,
Password: smtpPassword,
SMTPEncryption: smtpEncryption,
Name: accountName,
Email: email,
Provider: provider,
Username: smtpUsername,
Password: smtpPassword,
IMAP: IMAPConfig{
Host: imapHost,
Password: smtpPassword,