Files
mail/email.go
titor c9b77feabe feat: 收件箱功能新增按回车查看详情面板
- 添加邮件详情面板显示(主题、发件人、收件人、抄送、账户、时间、正文)
- 优化邮件列表卡片样式,增加选中高亮效果
- 窗口宽度 >= 80 时启用双面板布局,左侧列表右侧详情
- 简化依赖包,从 charm.land 使用统一导入路径
- 删除未使用的 golangci/goreleaser 配置文件
2026-04-10 04:41:22 +08:00

252 lines
5.8 KiB
Go

package main
import (
"bytes"
"crypto/tls"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"github.com/resendlabs/resend-go"
mail "github.com/xhit/go-simple-mail/v2"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
renderer "github.com/yuin/goldmark/renderer/html"
)
// ToSeparator is the separator used to split the To, Cc, and Bcc fields.
const ToSeparator = ","
// sendEmailSuccessMsg is the tea.Msg handled by Bubble Tea when the email has
// been sent successfully.
type sendEmailSuccessMsg struct{}
// sendEmailFailureMsg is the tea.Msg handled by Bubble Tea when the email has
// failed to send.
type sendEmailFailureMsg error
// sendEmailCmd returns a tea.Cmd that sends the email.
func (m Model) sendEmailCmd() tea.Cmd {
return func() tea.Msg {
attachments := make([]string, len(m.Attachments.Items()))
for i, a := range m.Attachments.Items() {
attachments[i] = a.FilterValue()
}
var err error
to := strings.Split(m.To.Value(), ToSeparator)
cc := strings.Split(m.Cc.Value(), ToSeparator)
bcc := strings.Split(m.Bcc.Value(), ToSeparator)
deliveryMethod := "smtp"
if m.DeliveryMethod == Resend {
deliveryMethod = "resend"
}
toStr := m.To.Value()
ccStr := m.Cc.Value()
bccStr := m.Bcc.Value()
bodyText := m.Body.Value()
html := bytes.NewBufferString("")
goldmark.Convert([]byte(bodyText), html)
bodyHTML := html.String()
attachmentsJSON := getAttachmentsJSON(attachments)
emailID, saveErr := SaveEmail(
m.From.Value(),
toStr,
ccStr,
bccStr,
m.Subject.Value(),
bodyText,
bodyHTML,
attachmentsJSON,
deliveryMethod,
StatusDraft,
)
if saveErr != nil {
fmt.Fprintf(os.Stderr, "Failed to save email to history: %v\n", saveErr)
}
switch m.DeliveryMethod {
case SMTP:
err = sendSMTPEmail(to, cc, bcc, m.From.Value(), m.Subject.Value(), m.Body.Value(), attachments)
case Resend:
err = sendResendEmail(to, cc, bcc, m.From.Value(), m.Subject.Value(), m.Body.Value(), attachments)
default:
err = errors.New("[ERROR]: unknown delivery method")
}
if err != nil {
path, storeErr := saveTmp(m.Body.Value())
if storeErr == nil {
err = fmt.Errorf("%w\nEmail saved to: %s", err, path)
}
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{}
}
}
const gmailSuffix = "@gmail.com"
const gmailSMTPHost = "smtp.gmail.com"
const gmailSMTPPort = 587
func sendSMTPEmail(to, cc, bcc []string, from, subject, body string, attachments []string) error {
validFrom := getValidFromAddress(from, smtpUsername)
server := mail.NewSMTPClient()
var err error
server.Username = smtpUsername
server.Password = smtpPassword
server.Host = smtpHost
server.Port = smtpPort
// Set defaults for gmail.
if strings.HasSuffix(server.Username, gmailSuffix) {
if server.Port == 0 {
server.Port = gmailSMTPPort
}
if server.Host == "" {
server.Host = gmailSMTPHost
}
}
switch strings.ToLower(smtpEncryption) {
case "ssl":
server.Encryption = mail.EncryptionSSLTLS
case "none":
server.Encryption = mail.EncryptionNone
default:
server.Encryption = mail.EncryptionSTARTTLS
}
server.KeepAlive = false
server.ConnectTimeout = 30 * time.Second
server.SendTimeout = 30 * time.Second
server.TLSConfig = &tls.Config{
InsecureSkipVerify: smtpInsecureSkipVerify, //nolint:gosec
ServerName: server.Host,
}
smtpClient, err := server.Connect()
if err != nil {
return fmt.Errorf("SMTP连接失败: %w", err)
}
email := mail.NewMSG()
email.SetFrom(validFrom).
AddTo(to...).
AddCc(cc...).
AddBcc(bcc...).
SetSubject(subject)
html := bytes.NewBufferString("")
convertErr := goldmark.Convert([]byte(body), html)
if convertErr != nil {
email.SetBody(mail.TextPlain, body)
} else {
email.SetBody(mail.TextHTML, html.String())
}
for _, a := range attachments {
email.Attach(&mail.File{
FilePath: a,
Name: filepath.Base(a),
})
}
return email.Send(smtpClient)
}
func sendResendEmail(to, _, _ []string, from, subject, body string, attachments []string) error {
client := resend.NewClient(resendAPIKey)
html := bytes.NewBufferString("")
// If the conversion fails, we'll simply send the plain-text body.
if unsafe {
markdown := goldmark.New(
goldmark.WithRendererOptions(
renderer.WithUnsafe(),
),
goldmark.WithExtensions(
extension.Strikethrough,
extension.Table,
extension.Linkify,
),
)
_ = markdown.Convert([]byte(body), html)
} else {
_ = goldmark.Convert([]byte(body), html)
}
request := &resend.SendEmailRequest{
From: from,
To: to,
Subject: subject,
Cc: cc,
Bcc: bcc,
Html: html.String(),
Text: body,
Attachments: makeAttachments(attachments),
}
_, err := client.Emails.Send(request)
if err != nil {
return err
}
return nil
}
func makeAttachments(paths []string) []resend.Attachment {
if len(paths) == 0 {
return nil
}
attachments := make([]resend.Attachment, len(paths))
for i, a := range paths {
f, err := os.ReadFile(a)
if err != nil {
continue
}
attachments[i] = resend.Attachment{
Content: string(f),
Filename: filepath.Base(a),
}
}
return attachments
}
// saveTmp is a helper function that stores a string in a temporary file.
// It returns the path of the file created.
func saveTmp(s string) (string, error) {
f, err := os.CreateTemp("", fmt.Sprintf("pop-%s-*.txt", time.Now().Format("2006-01-02")))
if err != nil {
return "", fmt.Errorf("creating temp file: %w", err)
}
defer f.Close()
_, err = f.WriteString(s)
if err != nil {
return "", fmt.Errorf("error writing to %s: %w", f.Name(), err)
}
return f.Name(), nil
}