package main import ( "errors" "time" "sync" "context" "strconv" "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/imapclient" "github.com/emersion/go-sasl" "github.com/emersion/go-smtp" "github.com/zalando/go-keyring" "github.com/wailsapp/wails/v2/pkg/runtime" "gorm.io/gorm" ) var ImapSyncMutex sync.Mutex type MailAccountConnection struct { gorm.Model Email string `gorm:"uniqueIndex"` ImapServer string ImapPort int ImapUsername string SmtpServer string SmtpPort int SmtpUsername string Password string `gorm:"-:all"` ImapClient *imapclient.Client `gorm:"-:all"` SmtpClient *smtp.Client `gorm:"-:all"` SmtpSasl *sasl.Client `gorm:"-:all"` } var ( mailConnections = make(map[string]MailAccountConnection) AppMailbox = "APPLICATION.machinetelecom.emessage" SourceMailboxes = []string{"INBOX", "Sent", "Spam", "Junk"} ) func (app *App) GetMailConnections() ([]MailAccountConnection, error) { userContact := app.GetUserContact() connections := make([]MailAccountConnection, len(userContact.Emails)) for index, email := range userContact.Emails { if err := app.DB. Where("email = ?", email.Value). First(&connections[index]). Error; err != nil { connections[index].Email = email.Value connections[index].ImapUsername = email.Value connections[index].ImapPort = 993 connections[index].SmtpUsername = email.Value connections[index].SmtpPort = 465 continue } mailPassword, err := keyring.Get( app.GetMailPasswordKeyringService(), email.Value, ) if err != nil { continue } connections[index].Password = mailPassword if connections[index].Email != "" && connections[index].ImapServer != "" && connections[index].ImapPort != 0 && connections[index].ImapUsername != "" && connections[index].SmtpServer != "" && connections[index].SmtpPort != 0 && connections[index].SmtpUsername != "" && connections[index].Password != "" { mailConnections[email.Value] = connections[index] } } return connections, nil } func (app *App) GetActiveMailEmails() []string { emails := make([]string, 0) for email := range mailConnections { emails = append(emails, email) } return emails } func (app *App) SaveMailConnection(conn MailAccountConnection) error { keyring.Set( app.GetMailPasswordKeyringService(), conn.Email, conn.Password, ) return app.DB.Save(&conn).Error } func RemoveMailConnection(address string) { if conn, ok := mailConnections[address]; ok { if conn.ImapClient != nil { conn.ImapClient.Close() } if conn.SmtpClient != nil { conn.SmtpClient.Close() } delete(mailConnections, address) } } func (app *App) FullRefresh() { runtime.EventsEmit(app.ctx, "reload") app.EstablishMailConnections() app.Updater.Update() } func (app *App) EstablishMailConnections() { if app.idleCancel != nil { app.idleCancel() } ImapSyncMutex.Lock() defer ImapSyncMutex.Unlock() _, _ = app.GetMailConnections() for key, conn := range mailConnections { app.Log("Attempting to connect to %s...", conn.Email). Stdout(). StatusLine() var err error if conn.ImapClient != nil { conn.ImapClient.Close() } switch conn.ImapPort { case 143: conn.ImapClient, err = imapclient.DialStartTLS( conn.ImapServer+":"+strconv.Itoa(conn.ImapPort), nil, ) break case 993: conn.ImapClient, err = imapclient.DialTLS( conn.ImapServer+":"+strconv.Itoa(conn.ImapPort), nil, ) break default: conn.ImapClient, err = imapclient.DialTLS( conn.ImapServer+":"+strconv.Itoa(conn.ImapPort), nil, ) break } if err != nil { RemoveMailConnection(conn.Email) app.Log(err.Error()). Stdout() continue } if err := conn.ImapClient.Login(conn.ImapUsername, conn.Password).Wait(); err != nil { RemoveMailConnection(conn.Email) app.Log(err.Error()). Stdout() continue } // go app.keepIMAPAlive(key) go app.watchMailbox(key, conn) if conn.SmtpClient != nil { conn.SmtpClient.Close() } switch conn.SmtpPort { case 25: conn.SmtpClient, err = smtp.Dial(conn.SmtpServer + ":" + strconv.Itoa(conn.SmtpPort)) break case 587: conn.SmtpClient, err = smtp.DialStartTLS( conn.SmtpServer+":"+strconv.Itoa(conn.SmtpPort), nil, ) break case 465: conn.SmtpClient, err = smtp.DialTLS( conn.SmtpServer+":"+strconv.Itoa(conn.SmtpPort), nil, ) break default: conn.SmtpClient, err = smtp.DialTLS( conn.SmtpServer+":"+strconv.Itoa(conn.SmtpPort), nil, ) break } if err != nil { RemoveMailConnection(conn.Email) app.Log(err.Error()). Stdout() continue } auth := sasl.NewPlainClient("", conn.SmtpUsername, conn.Password) if err := conn.SmtpClient.Auth(auth); err != nil { RemoveMailConnection(conn.Email) app.Log(err.Error()). Stdout() continue } conn.SmtpSasl = &auth mailConnections[key] = conn app.Log("Successfully connected %s!", conn.Email). Stdout(). StatusLine() } app.EnsureAppMailboxExists() go func () { if err := app.syncMessages(); err != nil { app.Log("Sync Error: %s", err.Error()). Stdout(). StatusLine() } }() } func CloseMailConnections() { for key, _ := range mailConnections { RemoveMailConnection(key) } } func (conn *MailAccountConnection) EnsureMailboxExists(mailboxName string) error { if conn.ImapClient == nil { return errors.New("IMAP client is nil") } _, err := conn.ImapClient.Status(mailboxName, &imap.StatusOptions{UIDValidity: true}).Wait() if err != nil { if err := conn.ImapClient.Create(mailboxName, nil).Wait(); err != nil { return err } } return nil } func (app *App) EnsureAppMailboxExists() { for email, conn := range mailConnections { if err := conn.EnsureMailboxExists(AppMailbox); err != nil { app.Log("Failed to ensure app mailbox for %s: %v", email, err). Stdout(). StatusLine() } } } type MailboxSyncState struct { gorm.Model EmailAddressID uint EmailAddress EmailAddress MailboxName string LastUID uint32 } func (app *App) organizeMailboxesForAccount(conn MailAccountConnection) error { for _, mailbox := range SourceMailboxes { if _, err := conn.ImapClient.Select(mailbox, nil).Wait(); err != nil { continue } criteria := &imap.SearchCriteria{ Header: []imap.SearchCriteriaHeaderField{{ Key: "FE-Application", Value: "emessage", }}, } searchData, err := conn.ImapClient.UIDSearch(criteria, nil).Wait() if err != nil { return err } uids := searchData.AllUIDs() if len(uids) == 0 { continue } uidSet := imap.UIDSetNum(uids...) if _, err := conn.ImapClient.Move(uidSet, AppMailbox).Wait(); err != nil { return err } } return nil } func (app *App) syncMailbox(email EmailAddress, conn MailAccountConnection) error { if err := app.organizeMailboxesForAccount(conn); err != nil { return err } messages, err := app.fetchFilteredMessages(conn) if err != nil { return err } if len(messages) == 0 { return nil } app.Log("Found %d messages...", len(messages)).Stdout().StatusLine() var maxUID uint32 for _, buf := range messages { msg, err := app.parseMessage(buf) if err != nil { continue } app.Log("Processing %s...", msg.ServerMessageID).Stdout() recipients := collectRecipients(&msg) conv, err := app.findOrCreateConversation(recipients, email) if err != nil { app.Log(err.Error()). Stdout() continue } msg.ConversationID = conv.ID sender, err := app.resolveSender(msg.From) if err != nil { app.Log(err.Error()). Stdout(). StatusLine() continue } msg.SenderID = sender.ID if err := app.DB.Create(&msg).Error; err != nil { app.Log(err.Error()). Stdout(). StatusLine() continue } if !msg.Seen { app.DB.Model(&conv).Update("IsUnread", true) } if msg.UID > maxUID { maxUID = msg.UID } } if maxUID > 0 { if err := app.setLastSynced(email, maxUID); err != nil { return err } } return nil } func (app *App) syncMessages() error { if !ImapSyncMutex.TryLock() { return nil } defer ImapSyncMutex.Unlock() app.Log("Syncing..."). Stdout(). StatusLine() for emailStr, conn := range mailConnections { var emailAddr EmailAddress if err := app.DB.Where("value = ?", emailStr).First(&emailAddr).Error; err != nil { app.Log("Email %s not found in DB: %v", emailStr, err). Stdout(). StatusLine() continue } if err := app.syncMailbox(emailAddr, conn); err != nil { app.Log("Mailbox Error: %s: %v", emailStr, err). Stdout(). StatusLine() continue } } app.Log("Syncing done."). Stdout(). StatusLine() go func() { budget := 10 for { amount, err := app.PopulateMissingMessageContents(budget) if err != nil || amount < budget { break } time.Sleep(20 * time.Millisecond) } }() return nil } func (app *App) keepIMAPAlive(conn string) { for { select { case <-time.After(5 * time.Minute): if connection, ok := mailConnections[conn]; ok { ImapSyncMutex.Lock() err := connection.ImapClient.Noop().Wait() ImapSyncMutex.Unlock() if err != nil { app.Log("IMAP Noop failed: %v", err).Stdout() go app.EstablishMailConnections() return } } } } } func (app *App) watchMailbox(email string, conn MailAccountConnection) { mailbox := SourceMailboxes[0] for { select { case <-app.idleCtx.Done(): app.Log("IDLE cancelled via context").Stdout() app.idleCtx, app.idleCancel = context.WithCancel(app.ctx) time.Sleep(4 * time.Second) continue default: } if !ImapSyncMutex.TryLock() { time.Sleep(2 * time.Second) continue } if _, err := conn.ImapClient.Select(mailbox, nil).Wait(); err != nil { app.Log("Select failed: %v", err).Stdout() ImapSyncMutex.Unlock() return } idleCmd, err := conn.ImapClient.Idle() if err != nil { app.Log("IDLE start failed: %v", err).Stdout() ImapSyncMutex.Unlock() return } waitChan := make(chan error, 1) go func() { waitChan <- idleCmd.Wait() }() app.Log("Waiting for new messages...").Stdout().StatusLine() select { case err := <-waitChan: if err != nil { app.Log("IDLE ended with error: %v", err).Stdout() } app.Log("IDLE stopped naturally.").Stdout() idleCmd.Close() case <-time.After(15 * time.Second): app.Log("IDLE timeout – restarting").Stdout() idleCmd.Close() case <-app.idleCtx.Done(): app.Log("IDLE forced stop").Stdout() idleCmd.Close() } ImapSyncMutex.Unlock() if err := app.syncMessages(); err != nil { app.Log("Sync error: %v", err).Stdout() } time.Sleep(2 * time.Second) } }