Files
web_server/backend/services/email_imap.go
Adam French 1e22bacdc9
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m40s
Add email sync service for automated job application tracking
Background poller fetches emails via IMAP or Microsoft Graph API,
classifies them with Claude Haiku, and creates/updates JobApplication
records automatically. Includes manual sync endpoint and OAuth callback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 13:59:24 +01:00

134 lines
2.9 KiB
Go

package services
import (
"fmt"
"io"
"log"
"time"
"github.com/emersion/go-imap/v2"
"github.com/emersion/go-imap/v2/imapclient"
)
type IMAPConfig struct {
Host string
Port string
Email string
Password string
}
// fetchEmailsIMAP connects to an IMAP server and retrieves emails since the given time.
func (s *EmailSyncService) fetchEmailsIMAP(since time.Time) ([]graphMessage, error) {
addr := fmt.Sprintf("%s:%s", s.Config.IMAP.Host, s.Config.IMAP.Port)
c, err := imapclient.DialTLS(addr, nil)
if err != nil {
return nil, fmt.Errorf("IMAP connect: %w", err)
}
defer c.Close()
if err := c.Login(s.Config.IMAP.Email, s.Config.IMAP.Password).Wait(); err != nil {
return nil, fmt.Errorf("IMAP login: %w", err)
}
if _, err := c.Select("INBOX", nil).Wait(); err != nil {
return nil, fmt.Errorf("IMAP select INBOX: %w", err)
}
// Search for emails since the given time
sinceDate := since.UTC()
searchCriteria := &imap.SearchCriteria{
Since: sinceDate,
}
searchData, err := c.Search(searchCriteria, nil).Wait()
if err != nil {
return nil, fmt.Errorf("IMAP search: %w", err)
}
if searchData.AllSeqNums() == nil || len(searchData.AllSeqNums()) == 0 {
return nil, nil
}
seqNums := searchData.AllSeqNums()
log.Printf("[EmailSync/IMAP] Found %d messages since %s", len(seqNums), sinceDate.Format(time.RFC3339))
seqSet := imap.SeqSetNum(seqNums...)
fetchOptions := &imap.FetchOptions{
Envelope: true,
BodySection: []*imap.FetchItemBodySection{{}},
}
fetchCmd := c.Fetch(seqSet, fetchOptions)
defer fetchCmd.Close()
var messages []graphMessage
for {
msg := fetchCmd.Next()
if msg == nil {
break
}
var envelope *imap.Envelope
var bodyContent string
for {
item := msg.Next()
if item == nil {
break
}
switch data := item.(type) {
case imapclient.FetchItemDataEnvelope:
envelope = data.Envelope
case imapclient.FetchItemDataBodySection:
body, err := io.ReadAll(data.Literal)
if err != nil {
log.Printf("[EmailSync/IMAP] Error reading body: %v", err)
continue
}
bodyContent = string(body)
}
}
if envelope == nil {
continue
}
// Convert to graphMessage format for unified processing
var fromName, fromAddr string
if len(envelope.From) > 0 {
fromName = envelope.From[0].Name
fromAddr = envelope.From[0].Addr()
}
gm := graphMessage{
ID: envelope.MessageID,
Subject: envelope.Subject,
ReceivedDateTime: envelope.Date.Format(time.RFC3339),
From: graphFrom{
EmailAddress: graphEmailAddress{
Name: fromName,
Address: fromAddr,
},
},
Body: graphBody{
ContentType: "text",
Content: bodyContent,
},
}
messages = append(messages, gm)
}
if err := fetchCmd.Close(); err != nil {
return nil, fmt.Errorf("IMAP fetch: %w", err)
}
if err := c.Logout().Wait(); err != nil {
log.Printf("[EmailSync/IMAP] Logout error: %v", err)
}
return messages, nil
}