mirror of
https://github.com/abhinavxd/libredesk.git
synced 2025-10-23 05:11:57 +00:00
269 lines
8.2 KiB
Go
269 lines
8.2 KiB
Go
package email
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/abhinavxd/libredesk/internal/attachment"
|
|
"github.com/abhinavxd/libredesk/internal/conversation"
|
|
"github.com/abhinavxd/libredesk/internal/conversation/models"
|
|
"github.com/abhinavxd/libredesk/internal/user"
|
|
umodels "github.com/abhinavxd/libredesk/internal/user/models"
|
|
"github.com/emersion/go-imap/v2"
|
|
"github.com/emersion/go-imap/v2/imapclient"
|
|
"github.com/jhillyerd/enmime"
|
|
"github.com/volatiletech/null/v9"
|
|
)
|
|
|
|
const (
|
|
DefaultReadInterval = time.Duration(5 * time.Minute)
|
|
)
|
|
|
|
// ReadIncomingMessages reads and processes incoming messages from an IMAP server based on the provided configuration.
|
|
func (e *Email) ReadIncomingMessages(ctx context.Context, cfg IMAPConfig) error {
|
|
readInterval, err := time.ParseDuration(cfg.ReadInterval)
|
|
if err != nil {
|
|
e.lo.Warn("could not parse IMAP read interval, using the default read interval", "interval", cfg.ReadInterval, "inbox_id", e.Identifier(), "error", err)
|
|
readInterval = DefaultReadInterval
|
|
}
|
|
|
|
readTicker := time.NewTicker(readInterval)
|
|
defer readTicker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil
|
|
case <-readTicker.C:
|
|
if err := e.processMailbox(cfg); err != nil {
|
|
e.lo.Error("error processing mailbox", "error", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// processMailbox processes emails in the specified mailbox.
|
|
func (e *Email) processMailbox(cfg IMAPConfig) error {
|
|
e.lo.Debug("processing emails from mailbox", "mailbox", cfg.Mailbox, "inbox_id", e.Identifier())
|
|
client, err := imapclient.DialTLS(cfg.Host+":"+fmt.Sprint(cfg.Port), &imapclient.Options{})
|
|
if err != nil {
|
|
return fmt.Errorf("error connecting to IMAP server: %w", err)
|
|
}
|
|
defer client.Logout()
|
|
|
|
if err := client.Login(cfg.Username, cfg.Password).Wait(); err != nil {
|
|
return fmt.Errorf("error logging in to the IMAP server: %w", err)
|
|
}
|
|
|
|
if _, err := client.Select(cfg.Mailbox, &imap.SelectOptions{ReadOnly: true}).Wait(); err != nil {
|
|
return fmt.Errorf("error selecting mailbox: %w", err)
|
|
}
|
|
|
|
// TODO: Set value from config.
|
|
since := time.Now().Add(-24 * time.Hour)
|
|
|
|
searchData, err := e.searchMessages(client, since)
|
|
if err != nil {
|
|
return fmt.Errorf("error searching messages: %w", err)
|
|
}
|
|
|
|
return e.fetchAndProcessMessages(client, searchData, e.Identifier())
|
|
}
|
|
|
|
// searchMessages searches for messages in the specified time range.
|
|
func (e *Email) searchMessages(client *imapclient.Client, since time.Time) (*imap.SearchData, error) {
|
|
searchCMD := client.Search(&imap.SearchCriteria{
|
|
Since: since,
|
|
},
|
|
&imap.SearchOptions{
|
|
ReturnMin: true,
|
|
ReturnMax: true,
|
|
ReturnAll: true,
|
|
ReturnCount: true,
|
|
},
|
|
)
|
|
return searchCMD.Wait()
|
|
}
|
|
|
|
// fetchAndProcessMessages fetches and processes messages based on the search results.
|
|
func (e *Email) fetchAndProcessMessages(client *imapclient.Client, searchData *imap.SearchData, inboxID int) error {
|
|
seqSet := imap.SeqSet{}
|
|
seqSet.AddRange(searchData.Min, searchData.Max)
|
|
|
|
// Fetch only envelope.
|
|
fetchOptions := &imap.FetchOptions{
|
|
Envelope: true,
|
|
}
|
|
|
|
fetchCmd := client.Fetch(seqSet, fetchOptions)
|
|
|
|
for {
|
|
msg := fetchCmd.Next()
|
|
if msg == nil {
|
|
break
|
|
}
|
|
|
|
for fetchItem := msg.Next(); fetchItem != nil; fetchItem = msg.Next() {
|
|
if item, ok := fetchItem.(imapclient.FetchItemDataEnvelope); ok {
|
|
if err := e.processEnvelope(client, item.Envelope, msg.SeqNum, inboxID); err != nil {
|
|
e.lo.Error("error processing envelope", "error", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// processEnvelope processes an email envelope.
|
|
func (e *Email) processEnvelope(client *imapclient.Client, env *imap.Envelope, seqNum uint32, inboxID int) error {
|
|
if len(env.From) == 0 {
|
|
e.lo.Warn("no sender received for email", "message_id", env.MessageID)
|
|
return nil
|
|
}
|
|
|
|
exists, err := e.messageStore.MessageExists(env.MessageID)
|
|
if err != nil {
|
|
e.lo.Error("error checking if message exists", "message_id", env.MessageID)
|
|
return fmt.Errorf("checking if message exists in DB: %w", err)
|
|
}
|
|
|
|
if exists {
|
|
e.lo.Debug("message already exists", "message_id", env.MessageID)
|
|
return nil
|
|
}
|
|
|
|
e.lo.Debug("message does not exist", "message_id", env.MessageID)
|
|
|
|
// Make contact.
|
|
firstName, lastName := getContactName(env.From[0])
|
|
var contact = umodels.User{
|
|
InboxID: inboxID,
|
|
FirstName: firstName,
|
|
LastName: lastName,
|
|
SourceChannel: null.NewString(e.Channel(), true),
|
|
SourceChannelID: null.NewString(env.From[0].Addr(), true),
|
|
Email: null.NewString(env.From[0].Addr(), true),
|
|
Type: user.UserTypeContact,
|
|
}
|
|
|
|
// Set CC addresses in meta.
|
|
var ccAddr = make([]string, len(env.Cc))
|
|
for _, cc := range env.Cc {
|
|
if cc.Addr() != "" {
|
|
ccAddr = append(ccAddr, cc.Addr())
|
|
}
|
|
}
|
|
meta, err := json.Marshal(map[string]interface{}{
|
|
"cc": ccAddr,
|
|
})
|
|
if err != nil {
|
|
e.lo.Error("error marshalling meta", "error", err)
|
|
return fmt.Errorf("marshalling meta: %w", err)
|
|
}
|
|
incomingMsg := models.IncomingMessage{
|
|
Message: models.Message{
|
|
Channel: e.Channel(),
|
|
SenderType: conversation.SenderTypeContact,
|
|
Type: conversation.MessageIncoming,
|
|
InboxID: inboxID,
|
|
Status: conversation.MessageStatusReceived,
|
|
Subject: env.Subject,
|
|
SourceID: null.StringFrom(env.MessageID),
|
|
Meta: string(meta),
|
|
},
|
|
Contact: contact,
|
|
InboxID: inboxID,
|
|
}
|
|
|
|
fetchOptions := &imap.FetchOptions{
|
|
BodySection: []*imap.FetchItemBodySection{{}},
|
|
}
|
|
seqSet := imap.SeqSet{}
|
|
seqSet.AddNum(seqNum)
|
|
|
|
fullFetchCmd := client.Fetch(seqSet, fetchOptions)
|
|
fullMsg := fullFetchCmd.Next()
|
|
if fullMsg == nil {
|
|
return nil
|
|
}
|
|
|
|
for fullFetchItem := fullMsg.Next(); fullFetchItem != nil; fullFetchItem = fullMsg.Next() {
|
|
if fullItem, ok := fullFetchItem.(imapclient.FetchItemDataBodySection); ok {
|
|
e.lo.Debug("fetching full message body", "message_id", env.MessageID)
|
|
return e.processFullMessage(fullItem, incomingMsg)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// processFullMessage processes the full email message.
|
|
func (e *Email) processFullMessage(item imapclient.FetchItemDataBodySection, incomingMsg models.IncomingMessage) error {
|
|
envelope, err := enmime.ReadEnvelope(item.Literal)
|
|
if err != nil {
|
|
e.lo.Error("error parsing email envelope", "error", err, "envelope_errors", envelope.Errors)
|
|
return fmt.Errorf("parsing email envelope: %w", err)
|
|
}
|
|
|
|
if len(envelope.HTML) > 0 {
|
|
incomingMsg.Message.Content = envelope.HTML
|
|
incomingMsg.Message.ContentType = conversation.ContentTypeHTML
|
|
} else if len(envelope.Text) > 0 {
|
|
incomingMsg.Message.Content = envelope.Text
|
|
incomingMsg.Message.ContentType = conversation.ContentTypeText
|
|
}
|
|
|
|
// Remove the angle brackets from the In-Reply-To and References headers.
|
|
inReplyTo := strings.ReplaceAll(strings.ReplaceAll(envelope.GetHeader("In-Reply-To"), "<", ""), ">", "")
|
|
references := strings.Fields(envelope.GetHeader("References"))
|
|
for i, ref := range references {
|
|
references[i] = strings.Trim(strings.TrimSpace(ref), " <>")
|
|
}
|
|
|
|
incomingMsg.Message.InReplyTo = inReplyTo
|
|
incomingMsg.Message.References = references
|
|
|
|
for _, att := range envelope.Attachments {
|
|
incomingMsg.Message.Attachments = append(incomingMsg.Message.Attachments, attachment.Attachment{
|
|
Name: att.FileName,
|
|
Content: att.Content,
|
|
ContentType: att.ContentType,
|
|
ContentID: att.ContentID,
|
|
Size: len(att.Content),
|
|
Disposition: attachment.DispositionAttachment,
|
|
})
|
|
}
|
|
for _, inline := range envelope.Inlines {
|
|
incomingMsg.Message.Attachments = append(incomingMsg.Message.Attachments, attachment.Attachment{
|
|
Name: inline.FileName,
|
|
Content: inline.Content,
|
|
ContentType: inline.ContentType,
|
|
ContentID: inline.ContentID,
|
|
Size: len(inline.Content),
|
|
Disposition: attachment.DispositionInline,
|
|
})
|
|
}
|
|
e.lo.Debug("enqueuing incoming email message for inserting in DB", "message_id", incomingMsg.Message.SourceID.String, "attachments", len(envelope.Attachments), "inlines", len(envelope.Inlines))
|
|
if err := e.messageStore.EnqueueIncoming(incomingMsg); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// getContactName extracts the contact's first and last name from the IMAP address.
|
|
func getContactName(imapAddr imap.Address) (string, string) {
|
|
from := strings.TrimSpace(imapAddr.Name)
|
|
names := strings.Fields(from)
|
|
if len(names) == 0 {
|
|
return imapAddr.Host, ""
|
|
}
|
|
if len(names) == 1 {
|
|
return names[0], ""
|
|
}
|
|
return names[0], names[1]
|
|
}
|