package main import ( "errors" "fmt" "sort" "strings" "time" "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/imapclient" "gorm.io/gorm" ) type Conversation struct { gorm.Model Subject string IsUnread bool ServerRecipientID uint ServerRecipient EmailAddress DisplayImageID uint DisplayImage DisplayImage Recipients []EmailAddress `gorm:"many2many:conversation_email_addresses;"` Messages []Message } func (app *App) NewConversation() (conv Conversation) { conv.Recipients = []EmailAddress{{}} return conv } func (app *App) GetConversationPreviewSubject(conv Conversation) string { if len(conv.Recipients) > 1 { return strings.Join(app.GetRecipientDisplay(conv.Recipients[1:]), ", ") } else { return "New Message" } } func (app *App) GetConversationPreviewText(conv Conversation) string { var msg Message err := app.DB. Where("conversation_id = ?", conv.ID). Order("created_at DESC"). Preload("MessageContents"). First(&msg).Error if err != nil { return "" } if len(msg.MessageContents) > 0 { s := string(msg.MessageContents[0].Content) runes := []rune(s) if len(runes) > 100 { s = string(runes[:100]) + "..." } return s } return "" } func (app *App) GetConversationPreviewTime(conv Conversation) string { var msg Message err := app.DB. Where("conversation_id = ?", conv.ID). Order("created_at DESC"). First(&msg).Error if err != nil { return "" } return relativeTime(msg.Date) } func relativeTime(t time.Time) string { now := time.Now().Local() t = t.Local() nowDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) tDay := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()) days := int(nowDay.Sub(tDay).Hours() / 24) switch { case days == 0: return t.Format("3:04 PM") case days == 1: return "yesterday" case days < 7: return fmt.Sprintf("%d days ago", days) case days < 28: weeks := days / 7 if weeks == 1 { return "1 week ago" } return fmt.Sprintf("%d weeks ago", weeks) case days < 365: months := days / 28 if months == 1 { return "1 month ago" } return fmt.Sprintf("%d months ago", months) default: years := days / 365 if years == 1 { return "1 year ago" } return fmt.Sprintf("%d years ago", years) } } func (app *App) GetAllConversations() ([]Conversation, error) { var conversations []Conversation err := app.DB. Preload("Recipients"). Preload("ServerRecipient"). Order("updated_at DESC"). Find(&conversations).Error return conversations, err } type MailMessageStandard struct { From string To string Subject string Date time.Time ServerMessageID string `gorm:"unique"` UID uint32 Seen bool Answered bool Deleted bool EmailID string ThreadID string } type Message struct { gorm.Model MailMessageStandard ConversationID uint Conversation Conversation SenderID uint Sender EmailAddress Hidden bool Internal bool Headers []MailHeader MessageContents []MessageContent } type MailHeader struct { gorm.Model MessageID uint Key string Value string } type MessageContent struct { gorm.Model MessageID uint MIMEType string Content []byte } type LastSynced struct { gorm.Model EmailAddressID uint EmailAddress EmailAddress UID uint32 } func (app *App) getLastSynced(email EmailAddress) (LastSynced, error) { var last LastSynced app.Log("Getting last synced for %s.", email.Value). Stdout(). StatusLine() defer app.Log("Last synced for %s is %d.", email.Value, last.UID). Stdout() err := app.DB.Where("email_address_id = ?", email.ID). First(&last). Error if errors.Is(err, gorm.ErrRecordNotFound) { return LastSynced{}, nil } return last, err } func (app *App) setLastSynced(email EmailAddress, uid uint32) error { var last LastSynced app.Log("Setting last synced for %s to %d.", email.Value, uid). Stdout() result := app.DB.Where(LastSynced{EmailAddressID: email.ID}). Assign(LastSynced{UID: uid}). FirstOrCreate(&last) return result.Error } func (app *App) fetchFilteredMessages(conn MailAccountConnection) ([]*imapclient.FetchMessageBuffer, error) { var emailAddr EmailAddress if err := app.DB.Where("value = ?", conn.Email).First(&emailAddr).Error; err != nil { return nil, err } lastSynced, err := app.getLastSynced(emailAddr) if err != nil { return nil, err } selectData, err := conn.ImapClient.Select(AppMailbox, nil).Wait() if err != nil { return nil, err } startUID := uint32(1) if lastSynced.UID > 0 { startUID = lastSynced.UID + 1 } endUID := uint32(0) if selectData.UIDNext > 1 { endUID = uint32(selectData.UIDNext) - 1 } if startUID > endUID { return []*imapclient.FetchMessageBuffer{}, nil } uidRange := []imap.UIDRange{{ Start: imap.UID(startUID), Stop: imap.UID(endUID), }} criteria := &imap.SearchCriteria{ Header: []imap.SearchCriteriaHeaderField{{ Key: "FE-Application", Value: "emessage", }}, UID: []imap.UIDSet{uidRange}, } searchData, err := conn.ImapClient.UIDSearch(criteria, nil).Wait() if err != nil { return nil, err } uids := searchData.AllUIDs() if len(uids) == 0 { return nil, nil } fetchOptions := &imap.FetchOptions{ UID: true, Envelope: true, Flags: true, InternalDate: true, } uidSet := imap.UIDSetNum(uids...) fetchCmd := conn.ImapClient.Fetch(uidSet, fetchOptions) defer fetchCmd.Close() var results []*imapclient.FetchMessageBuffer for { msg := fetchCmd.Next() if msg == nil { break } buf, err := msg.Collect() if err != nil { return nil, err } results = append(results, buf) } return results, nil } func (app *App) findOrCreateConversation(recipients []string, serverRecipient EmailAddress) (*Conversation, error) { emailIDs := make([]uint, len(recipients)) for i, r := range recipients { addr, err := app.GetOrCreateEmailAddress(r) if err != nil { return nil, err } emailIDs[i] = addr.ID } sort.Slice(emailIDs, func(i, j int) bool { return emailIDs[i] < emailIDs[j] }) sql := ` SELECT cea.conversation_id FROM conversation_email_addresses cea JOIN ( SELECT conversation_id FROM conversation_email_addresses GROUP BY conversation_id HAVING COUNT(DISTINCT email_address_id) = ? ) total ON total.conversation_id = cea.conversation_id WHERE cea.email_address_id IN ? GROUP BY cea.conversation_id HAVING COUNT(DISTINCT cea.email_address_id) = ? LIMIT 1 ` var convID uint err := app.DB.Raw(sql, len(emailIDs), emailIDs, len(emailIDs)).Scan(&convID).Error if err != nil { return nil, err } if convID != 0 { var conv Conversation if err := app.DB.Preload("Recipients").Preload("ServerRecipient").First(&conv, convID).Error; err != nil { return nil, err } return &conv, nil } emailRecipients := make([]EmailAddress, 0, len(emailIDs)) for _, id := range emailIDs { var addr EmailAddress if err := app.DB.First(&addr, id).Error; err != nil { return nil, err } emailRecipients = append(emailRecipients, addr) } containsServer := false for _, addr := range emailRecipients { if addr.ID == serverRecipient.ID { containsServer = true break } } if !containsServer { emailRecipients = append(emailRecipients, serverRecipient) } conv := Conversation{ ServerRecipientID: serverRecipient.ID, ServerRecipient: serverRecipient, Recipients: emailRecipients, Messages: []Message{}, } if err := app.DB.Create(&conv).Error; err != nil { return nil, err } if err := app.DB.Preload("Recipients").Preload("ServerRecipient").First(&conv, conv.ID).Error; err != nil { return nil, err } return &conv, nil } type PaginatedMessages struct { Messages []Message `json:"messages"` Total int64 `json:"total"` } func (app *App) GetMessagesFromConversation(conv Conversation, page, pageSize int) (*PaginatedMessages, error) { if page < 1 { page = 1 } if pageSize < 1 { pageSize = 20 } convID := conv.ID var total int64 if err := app.DB.Model(&Message{}). Where("conversation_id = ?", convID). Count(&total).Error; err != nil { return nil, err } offset := (page - 1) * pageSize var messages []Message err := app.DB. Where("conversation_id = ?", convID). Preload("Headers"). Preload("MessageContents"). Preload("Sender"). Order("date DESC, id ASC"). Offset(offset). Limit(pageSize). Find(&messages).Error if err != nil { return nil, err } return &PaginatedMessages{ Messages: messages, Total: total, }, nil }