224 lines
4.6 KiB
Go
224 lines
4.6 KiB
Go
package email
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/smtp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/d1nch8g/jules/chat"
|
|
"github.com/emersion/go-imap/v2"
|
|
"github.com/emersion/go-imap/v2/imapclient"
|
|
)
|
|
|
|
type Config struct {
|
|
SMTPHost string
|
|
SMTPPort string
|
|
IMAPHost string
|
|
IMAPPort string
|
|
Username string
|
|
Password string
|
|
FromAddr string
|
|
}
|
|
|
|
type Client struct {
|
|
imapAddr string
|
|
smtpAddr string
|
|
username string
|
|
password string
|
|
fromAddr string
|
|
pollInterval time.Duration
|
|
tlsConfig *tls.Config // ADD THIS LINE ONLY
|
|
}
|
|
|
|
func New(cfg Config) (*Client, error) {
|
|
if cfg.SMTPPort == "" {
|
|
cfg.SMTPPort = "465"
|
|
}
|
|
if cfg.IMAPPort == "" {
|
|
cfg.IMAPPort = "993"
|
|
}
|
|
|
|
return &Client{
|
|
imapAddr: fmt.Sprintf("%s:%s", cfg.IMAPHost, cfg.IMAPPort),
|
|
smtpAddr: fmt.Sprintf("%s:%s", cfg.SMTPHost, cfg.SMTPPort),
|
|
username: cfg.Username,
|
|
password: cfg.Password,
|
|
fromAddr: cfg.FromAddr,
|
|
pollInterval: 10 * time.Second,
|
|
}, nil
|
|
}
|
|
|
|
func (c *Client) Send(_ context.Context, id string, text string) error {
|
|
to := strings.TrimSpace(id)
|
|
if to == "" {
|
|
return fmt.Errorf("email: empty recipient")
|
|
}
|
|
|
|
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]
|
|
|
|
// MODIFY: Use client's tlsConfig if set
|
|
tlsConfig := c.tlsConfig
|
|
if tlsConfig == nil {
|
|
tlsConfig = &tls.Config{ServerName: host}
|
|
}
|
|
|
|
conn, err := tls.Dial("tcp", c.smtpAddr, tlsConfig)
|
|
if err != nil {
|
|
return fmt.Errorf("email: smtp connect: %w", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
client, err := smtp.NewClient(conn, host)
|
|
if err != nil {
|
|
return fmt.Errorf("email: smtp client: %w", err)
|
|
}
|
|
defer client.Quit()
|
|
|
|
auth := smtp.PlainAuth("", c.username, c.password, host)
|
|
if err = client.Auth(auth); err != nil {
|
|
return fmt.Errorf("email: smtp auth: %w", err)
|
|
}
|
|
|
|
if err = client.Mail(c.fromAddr); err != nil {
|
|
return fmt.Errorf("email: smtp mail: %w", err)
|
|
}
|
|
if err = client.Rcpt(to); err != nil {
|
|
return fmt.Errorf("email: smtp rcpt: %w", err)
|
|
}
|
|
|
|
w, err := client.Data()
|
|
if err != nil {
|
|
return fmt.Errorf("email: smtp data: %w", err)
|
|
}
|
|
_, err = fmt.Fprint(w, msg)
|
|
if err != nil {
|
|
return fmt.Errorf("email: smtp write: %w", err)
|
|
}
|
|
w.Close()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) Receive(ctx context.Context) <-chan chat.Message {
|
|
out := make(chan chat.Message)
|
|
|
|
go func() {
|
|
defer close(out)
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-time.After(c.pollInterval):
|
|
if err := c.poll(ctx, out); err != nil {
|
|
slog.ErrorContext(ctx, "email poll failed", "error", err)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
return out
|
|
}
|
|
|
|
func (c *Client) poll(ctx context.Context, out chan<- chat.Message) error {
|
|
host := strings.Split(c.imapAddr, ":")[0]
|
|
|
|
// MODIFY: Use client's tlsConfig if set
|
|
tlsConfig := c.tlsConfig
|
|
if tlsConfig == nil {
|
|
tlsConfig = &tls.Config{ServerName: host}
|
|
}
|
|
|
|
conn, err := tls.Dial("tcp", c.imapAddr, tlsConfig)
|
|
if err != nil {
|
|
return fmt.Errorf("imap dial: %w", err)
|
|
}
|
|
|
|
client := imapclient.New(conn, nil)
|
|
defer client.Close()
|
|
|
|
if err := client.Login(c.username, c.password).Wait(); err != nil {
|
|
return fmt.Errorf("imap login: %w", err)
|
|
}
|
|
|
|
_, err = client.Select("INBOX", nil).Wait()
|
|
if err != nil {
|
|
return fmt.Errorf("imap select: %w", err)
|
|
}
|
|
|
|
criteria := &imap.SearchCriteria{
|
|
NotFlag: []imap.Flag{imap.FlagSeen},
|
|
}
|
|
searchRes, err := client.Search(criteria, nil).Wait()
|
|
if err != nil {
|
|
return fmt.Errorf("imap search: %w", err)
|
|
}
|
|
|
|
uids := searchRes.AllUIDs()
|
|
if len(uids) == 0 {
|
|
return nil
|
|
}
|
|
|
|
seqSet := imap.UIDSetNum(uids...)
|
|
fetchOpts := &imap.FetchOptions{
|
|
Envelope: true,
|
|
BodySection: []*imap.FetchItemBodySection{{}},
|
|
}
|
|
|
|
messages, err := client.Fetch(seqSet, fetchOpts).Collect()
|
|
if err != nil {
|
|
return fmt.Errorf("imap fetch: %w", err)
|
|
}
|
|
|
|
// Mark as seen using STORE command
|
|
storeFlags := &imap.StoreFlags{
|
|
Op: imap.StoreFlagsAdd,
|
|
Flags: []imap.Flag{imap.FlagSeen},
|
|
}
|
|
storeCmd := client.Store(seqSet, storeFlags, nil)
|
|
if err := storeCmd.Close(); err != nil {
|
|
slog.Error("email: failed to mark as seen", "error", err)
|
|
}
|
|
|
|
for _, msg := range messages {
|
|
from := extractFrom(msg.Envelope)
|
|
body := extractBody(msg)
|
|
|
|
if from == "" || body == "" {
|
|
continue
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil
|
|
case out <- chat.Message{
|
|
Chat: chat.Email,
|
|
ID: from,
|
|
Text: body,
|
|
}:
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func extractFrom(env *imap.Envelope) string {
|
|
if env == nil || len(env.From) == 0 {
|
|
return ""
|
|
}
|
|
return env.From[0].Addr()
|
|
}
|
|
|
|
func extractBody(msg *imapclient.FetchMessageBuffer) string {
|
|
for _, section := range msg.BodySection {
|
|
return string(section.Bytes)
|
|
}
|
|
return ""
|
|
}
|