more robust and error prone email implementation

This commit is contained in:
d1nch8g
2026-04-27 23:04:50 +03:00
parent b268fa2a26
commit 1f9983b9dc
+69 -96
View File
@@ -4,8 +4,11 @@ import (
"context"
"crypto/tls"
"fmt"
"io"
"log/slog"
"mime/quotedprintable"
"net/smtp"
"slices"
"strings"
"time"
@@ -48,10 +51,19 @@ func New(cfg Config) (*Client, error) {
username: cfg.Username,
password: cfg.Password,
fromAddr: cfg.FromAddr,
pollInterval: 10 * time.Second,
pollInterval: 5 * time.Second,
}, nil
}
func (c *Client) tlsDial(addr string) (*tls.Conn, error) {
host := strings.Split(addr, ":")[0]
cfg := c.tlsConfig
if cfg == nil {
cfg = &tls.Config{ServerName: host}
}
return tls.Dial("tcp", addr, cfg)
}
func (c *Client) Send(_ context.Context, id string, text string) error {
to := strings.TrimSpace(id)
if to == "" {
@@ -59,15 +71,9 @@ func (c *Client) Send(_ context.Context, id string, text string) error {
}
msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: \r\n\r\n%s", c.fromAddr, to, text)
host := strings.Split(c.smtpAddr, ":")[0]
tlsConfig := c.tlsConfig
if tlsConfig == nil {
tlsConfig = &tls.Config{ServerName: host}
}
conn, err := tls.Dial("tcp", c.smtpAddr, tlsConfig)
conn, err := c.tlsDial(c.smtpAddr)
if err != nil {
return fmt.Errorf("email: smtp connect: %w", err)
}
@@ -126,126 +132,75 @@ func (c *Client) Receive(ctx context.Context) <-chan chat.Message {
}
func (c *Client) poll(ctx context.Context, out chan<- chat.Message) error {
host := strings.Split(c.imapAddr, ":")[0]
tlsConfig := c.tlsConfig
if tlsConfig == nil {
tlsConfig = &tls.Config{ServerName: host}
}
conn, err := tls.Dial("tcp", c.imapAddr, tlsConfig)
conn, err := c.tlsDial(c.imapAddr)
if err != nil {
return fmt.Errorf("imap dial: %w", err)
}
client := imapclient.New(conn, nil)
defer client.Close()
imapClient := imapclient.New(conn, nil)
defer imapClient.Close()
if err := client.Login(c.username, c.password).Wait(); err != nil {
if err := imapClient.Login(c.username, c.password).Wait(); err != nil {
return fmt.Errorf("imap login: %w", err)
}
selectData, err := client.Select("INBOX", nil).Wait()
selectData, err := imapClient.Select("INBOX", nil).Wait()
if err != nil {
return fmt.Errorf("imap select: %w", err)
}
totalMessages := selectData.NumMessages
fmt.Printf("DEBUG: INBOX has %d total messages\n", totalMessages)
if totalMessages == 0 {
return nil
}
// Fetch all messages to check for unread ones
startSeq := uint32(1)
if totalMessages > 20 {
startSeq = totalMessages - 19
}
seqSet := imap.SeqSetNum(startSeq, totalMessages)
fetchOpts := &imap.FetchOptions{
Envelope: true,
BodySection: []*imap.FetchItemBodySection{
{Peek: true},
},
Flags: true,
UID: true, // ADD THIS - explicitly request UID
Envelope: true,
BodySection: []*imap.FetchItemBodySection{{Peek: true}},
Flags: true,
UID: true,
}
messages, err := client.Fetch(seqSet, fetchOpts).Collect()
messages, err := imapClient.Fetch(seqSet, fetchOpts).Collect()
if err != nil {
return fmt.Errorf("imap fetch: %w", err)
}
fmt.Printf("DEBUG: Fetched %d messages\n", len(messages))
hasDeleted := false
processedSeqNums := make(map[uint32]bool)
processed := make(map[uint32]bool)
for _, msg := range messages {
fmt.Printf("DEBUG: Message SeqNum=%d UID=%d flags=%v\n", msg.SeqNum, msg.UID, msg.Flags)
// Skip already seen
isSeen := false
for _, flag := range msg.Flags {
if flag == imap.FlagSeen {
isSeen = true
break
}
}
if isSeen {
fmt.Printf("DEBUG: Skipping seen message\n")
continue
}
// Skip if already processed
if processedSeqNums[msg.SeqNum] {
if slices.Contains(msg.Flags, imap.FlagSeen) || processed[msg.SeqNum] {
continue
}
from := extractFrom(msg.Envelope)
if from == "" {
fmt.Printf("DEBUG: Empty from address\n")
continue
}
body := extractBody(msg)
fmt.Printf("DEBUG: Raw body length=%d\n", len(body))
if body == "" {
fmt.Printf("DEBUG: Empty body\n")
continue
}
body = cleanEmailBody(body)
fmt.Printf("DEBUG: Cleaned body length=%d\n", len(body))
if body == "" {
fmt.Printf("DEBUG: Empty cleaned body\n")
continue
}
fmt.Printf("DEBUG: Processing message from=%q\n", from)
processedSeqNums[msg.SeqNum] = true
processed[msg.SeqNum] = true
// Mark as seen using sequence number
seqSetSingle := imap.SeqSetNum(msg.SeqNum)
storeFlags := &imap.StoreFlags{
Op: imap.StoreFlagsAdd,
Flags: []imap.Flag{imap.FlagSeen},
}
storeCmd := client.Store(seqSetSingle, storeFlags, nil)
if err := storeCmd.Close(); err != nil {
if err := storeFlag(imapClient, msg.SeqNum, imap.FlagSeen); err != nil {
slog.Error("email: failed to mark as seen", "error", err)
}
// Delete using sequence number
deleteFlags := &imap.StoreFlags{
Op: imap.StoreFlagsAdd,
Flags: []imap.Flag{imap.FlagDeleted},
}
deleteCmd := client.Store(seqSetSingle, deleteFlags, nil)
if err := deleteCmd.Close(); err != nil {
if err := storeFlag(imapClient, msg.SeqNum, imap.FlagDeleted); err != nil {
slog.Error("email: failed to delete", "error", err)
} else {
hasDeleted = true
@@ -259,14 +214,11 @@ func (c *Client) poll(ctx context.Context, out chan<- chat.Message) error {
ID: from,
Text: body,
}:
fmt.Printf("DEBUG: Sent to channel\n")
}
}
if hasDeleted {
fmt.Printf("DEBUG: Expunging deleted messages\n")
expungeCmd := client.Expunge()
if err := expungeCmd.Close(); err != nil {
if err := imapClient.Expunge().Close(); err != nil {
slog.Error("email: failed to expunge", "error", err)
}
}
@@ -274,6 +226,14 @@ func (c *Client) poll(ctx context.Context, out chan<- chat.Message) error {
return nil
}
func storeFlag(client *imapclient.Client, seqNum uint32, flag imap.Flag) error {
seqSet := imap.SeqSetNum(seqNum)
return client.Store(seqSet, &imap.StoreFlags{
Op: imap.StoreFlagsAdd,
Flags: []imap.Flag{flag},
}, nil).Close()
}
func extractFrom(env *imap.Envelope) string {
if env == nil || len(env.From) == 0 {
return ""
@@ -297,6 +257,7 @@ func cleanEmailBody(raw string) string {
var boundary string
inHeaders := true
inPlainText := false
encoding := ""
var plainTextLines []string
for _, line := range lines {
@@ -321,12 +282,11 @@ func cleanEmailBody(raw string) string {
continue
}
if strings.HasPrefix(line, "Content-Transfer-Encoding:") {
encoding = strings.ToLower(strings.TrimSpace(line[len("Content-Transfer-Encoding:"):]))
continue
}
if strings.HasPrefix(line, "Content-Disposition:") {
continue
}
if strings.HasPrefix(line, "Content-ID:") {
if strings.HasPrefix(line, "Content-Disposition:") || strings.HasPrefix(line, "Content-ID:") {
continue
}
@@ -343,23 +303,36 @@ func cleanEmailBody(raw string) string {
}
}
var result string
if len(plainTextLines) > 0 {
result := strings.Join(plainTextLines, "\n")
return strings.TrimSpace(result)
}
// Fallback: return everything after headers
bodyStart := 0
for i, line := range lines {
if strings.TrimSpace(line) == "" {
bodyStart = i + 1
break
result = strings.Join(plainTextLines, "\n")
} else {
bodyStart := 0
for i, line := range lines {
if strings.TrimSpace(line) == "" {
bodyStart = i + 1
break
}
}
if bodyStart < len(lines) {
result = strings.Join(lines[bodyStart:], "\n")
}
}
if bodyStart < len(lines) {
result := strings.Join(lines[bodyStart:], "\n")
return strings.TrimSpace(result)
result = strings.TrimSpace(result)
if encoding == "quoted-printable" {
result = decodeQuotedPrintable(result)
}
return strings.TrimSpace(raw)
return result
}
func decodeQuotedPrintable(s string) string {
reader := quotedprintable.NewReader(strings.NewReader(s))
decoded, err := io.ReadAll(reader)
if err != nil {
return s
}
return string(decoded)
}