emersion / go-imap

📥 An IMAP library for clients and servers
MIT License
2.02k stars 288 forks source link

Idle client v2 stops receiving new emails in some time. #602

Closed serhiynovos closed 2 months ago

serhiynovos commented 3 months ago

I need a service to listening for incoming emails and send them to internal server. It's working but I noticed that in some time like 1 2 days it stops receiving a new emails. In console I don't see any errors. Is there are any way I can check idle client status and if it's failed I can restart client or even just fail client so container with it will be restarted automatically ?

package client

import (
    "bytes"
    "context"
    "fmt"
    "log"
    "sync"
    "text/template"

    "github.com/emersion/go-imap/v2"
    "github.com/emersion/go-imap/v2/imapclient"
    "github.com/mnako/letters"
    "mycom.imap.client/pkg/config"
    "mycom.imap.client/pkg/models"
    "mycom.imap.client/pkg/parsers"
    mycomserver "mycom.imap.client/pkg/mycom-server"
)

type IMAPClient struct {
    idleClient *imapclient.Client
    ctx        context.Context
    wg         *sync.WaitGroup
    idleCmd    *imapclient.IdleCommand
    config     config.Listener
}

func (client *IMAPClient) Start() {
    fmt.Printf("Start %s listener\n", client.config.Name)
    client.wg.Add(1)

    idleC, err := imapclient.DialTLS(client.config.Imap.Host, &imapclient.Options{
        UnilateralDataHandler: &imapclient.UnilateralDataHandler{
            Expunge: func(seqNum uint32) {
                log.Printf("message %v has been expunged", seqNum)
            },
            Mailbox: func(data *imapclient.UnilateralDataMailbox) {
                if data.NumMessages != nil {
                    client.fetch(*data.NumMessages)
                }
            },
        },
    })

    if err != nil {
        log.Fatalf("failed to dial IMAP server: %v", err)
    }

    client.idleClient = idleC

    if err := idleC.Login(client.config.Imap.Username, client.config.Imap.Password).Wait(); err != nil {
        log.Fatalf("failed to login: %v", err)
    }

    if _, err := idleC.Select("INBOX", nil).Wait(); err != nil {
        log.Fatalf("failed to select INBOX: %v", err)
    }

    // Start idling
    idleCmd, err := idleC.Idle()
    if err != nil {
        log.Fatalf("IDLE command failed: %v", err)
    }
    client.idleCmd = idleCmd

    <-client.ctx.Done()
    client.Close()
}

func (client *IMAPClient) fetch(number uint32) {
    fmt.Println("Fetch", number)
    seqSet := imap.SeqSetNum(number)
    fetchOptions := &imap.FetchOptions{
        UID:         true,
        BodySection: []*imap.FetchItemBodySection{{}},
    }

    // Create a new fetch client for each fetch operation
    fetchC, err := imapclient.DialTLS(client.config.Imap.Host, nil)
    if err != nil {
        log.Fatalf("failed to dial IMAP server: %v", err)
    }
    defer fetchC.Close() // Ensure the client is closed after the operation

    if err := fetchC.Login(client.config.Imap.Username, client.config.Imap.Password).Wait(); err != nil {
        log.Fatalf("failed to login: %v", err)
    }

    if _, err := fetchC.Select("INBOX", nil).Wait(); err != nil {
        log.Fatalf("failed to select INBOX: %v", err)
    }

    fetchCmd := fetchC.Fetch(seqSet, fetchOptions)
    defer fetchCmd.Close()

    for {
        msg := fetchCmd.Next()
        if msg == nil {
            break
        }

        for {
            item := msg.Next()
            if item == nil {
                break
            }

            switch item := item.(type) {
            case imapclient.FetchItemDataUID:
                log.Printf("UID: %v", item.UID)
            case imapclient.FetchItemDataBodySection:
                email, err := letters.ParseEmail(item.Literal)
                if err != nil {
                    log.Fatal(err)
                }

                from := email.Headers.From[0].Address
                subject := email.Headers.Subject
                message := email.Text

                fmt.Println("=========================================================================")

                fmt.Println("Sender ::", from)
                fmt.Println("Subject :: ", subject)
                fmt.Println("Email Text ::", message)

                canAccept := false

                if len(client.config.AcceptFrom) == 0 {
                    canAccept = true
                } else {
                    for _, acceptFrom := range client.config.AcceptFrom {
                        if from == acceptFrom {
                            canAccept = true
                            break
                        }
                    }
                }

                if !canAccept {
                    fmt.Println("Email is not in whitelist. Ignore this email ::", from)
                    return
                }

                // Load the template
                tmpl, err := template.ParseFiles("mmmessage_template.txt")
                if err != nil {
                    log.Fatalf("Error loading template: %v", err)
                }

                text, recipients := parsers.ParseIncomingEmailBody(message)

                if len(recipients) == 0 {
                    fmt.Println("No recipients found. Ignore")
                    return
                }

                sessionId, err := mycomserver.GetSessionID(client.config.Actions[0])

                if err != nil {
                    fmt.Println("Failed to get session id", err.Error())
                    return
                }

                // Define the data for the template
                data := models.MMMessageData{
                    SessionID:    sessionId,
                    EmailSubject: subject,
                    Application:  "EmailListener",
                    Priority:     "10",
                    EmailText:    text,
                    Recipients:   recipients,
                }

                // Execute the template with the data and save to a string variable
                var buf bytes.Buffer
                if err := tmpl.Execute(&buf, data); err != nil {
                    log.Fatalf("Error executing template: %v", err)
                }
                renderedTemplate := buf.String()

                fmt.Println("Received template", renderedTemplate)

                if err := mycomserver.SendMMMessage(client.config.Actions[0], renderedTemplate); err != nil {
                    fmt.Println("Failed sending MMMessage", err.Error())
                } else {
                    fmt.Println("MMMessage sucessfully sent")
                }
            }
        }
    }
}

func (client *IMAPClient) Close() {
    log.Println("Close Client")
    client.idleClient.Close()
    client.idleCmd.Close()
    client.wg.Done()
}

func NewIMAPClient(ctx context.Context, wg *sync.WaitGroup, config config.Listener) *IMAPClient {
    return &IMAPClient{
        ctx:    ctx,
        wg:     wg,
        config: config,
    }
}
emersion commented 3 months ago

Since ca0ddb75a697f13e66376b7f7be82cd13efdd507, IdleCommand.Wait can be called before Close. It should now be possible to use it to figure out when the connection is broken.

birudeghi commented 3 months ago

@emersion Should we also include timeout adjustability regardless, as they won't be able to act on that new information without it?

emersion commented 2 months ago

A connection can be closed by the server for many reasons, e.g. the server is restarted, or connectivity is lost for a moment.

serhiynovos commented 2 months ago

@emersion is there any way to handle it ?

emersion commented 2 months ago

In your code snippet, you can add something like

if err := idleCmd.Wait(); err != nil {
    log.Fatalf("IDLE command failed: %v", err)
}

maybe in a goroutine since you're already blocking with <-client.ctx.Done().