more robust and error prone email implementation
This commit is contained in:
+69
-96
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user