From b77d75f05fb2059389c05f6c01484e0cd12e796e Mon Sep 17 00:00:00 2001 From: Bobby <30593201+luciferreeves@users.noreply.github.com> Date: Wed, 24 Dec 2025 13:50:07 +0530 Subject: feat: introduce email folder synchronization and management, refactor email services, and update UI styles --- repository/email.go | 284 -------------------------------------------------- repository/emails.go | 85 +++++++++++++++ repository/folders.go | 277 ++++++++---------------------------------------- 3 files changed, 127 insertions(+), 519 deletions(-) delete mode 100644 repository/email.go create mode 100644 repository/emails.go (limited to 'repository') diff --git a/repository/email.go b/repository/email.go deleted file mode 100644 index 9203e40..0000000 --- a/repository/email.go +++ /dev/null @@ -1,284 +0,0 @@ -package repository - -import ( - "fmt" - "lain/database" - "lain/models" - "lain/utils/crypto" - "lain/utils/email" - "lain/utils/storage" - "net/url" - "strings" - - "github.com/gofiber/fiber/v2" -) - -func GetEmails(userEmail, folderPath string, limit int, offset int) ([]fiber.Map, error) { - // Decode URL-encoded path (e.g., "inbox/lets%20encrypt" -> "inbox/lets encrypt") - decodedPath, _ := url.QueryUnescape(folderPath) - - var folder models.Folder - err := database.DB.Where("user_email = ? AND LOWER(imap_name) = ?", userEmail, strings.ToLower(decodedPath)).First(&folder).Error - if err != nil { - return nil, fmt.Errorf("folder not found: %w", err) - } - - var count int64 - database.DB.Model(&models.Email{}).Where("user_email = ? AND folder_id = ?", userEmail, folder.ID).Count(&count) - - // Always sync if no emails exist - if count == 0 { - if err := syncEmails(userEmail, folder.ID, folder.IMAPName); err != nil { - // Log the error but continue to show UI - fmt.Printf("Failed to sync emails for folder %s: %v\n", folder.IMAPName, err) - } - // Recount after sync - database.DB.Model(&models.Email{}).Where("user_email = ? AND folder_id = ?", userEmail, folder.ID).Count(&count) - } - - var messages []models.Email - err = database.DB.Where("user_email = ? AND folder_id = ?", userEmail, folder.ID). - Order("date DESC"). - Limit(limit). - Offset(offset). - Find(&messages).Error - - if err != nil { - return nil, fmt.Errorf("failed to fetch emails: %w", err) - } - - var emailMaps []fiber.Map - for _, message := range messages { - emailMaps = append(emailMaps, fiber.Map{ - "ID": message.ID, - "UID": message.UID, - "From": message.From, - "FromName": message.FromName, - "Subject": message.Subject, - "Date": message.Date, - "Snippet": message.Snippet, - "IsRead": message.IsRead, - "IsFlagged": message.IsFlagged, - "HasAttachment": message.HasAttachment, - }) - } - - return emailMaps, nil -} - -func GetEmail(userEmail string, emailID uint) (*models.Email, error) { - var message models.Email - err := database.DB.Preload("Folder").Where("user_email = ? AND id = ?", userEmail, emailID).First(&message).Error - if err != nil { - return nil, fmt.Errorf("email not found: %w", err) - } - - return &message, nil -} - -func GetAttachments(emailID uint) ([]models.Attachment, error) { - var attachments []models.Attachment - err := database.DB.Where("email_id = ?", emailID).Find(&attachments).Error - if err != nil { - return nil, fmt.Errorf("failed to fetch attachments: %w", err) - } - - return attachments, nil -} - -func MarkEmailAsRead(userEmail string, emailID uint) error { - var message models.Email - err := database.DB.Preload("Folder").Where("user_email = ? AND id = ?", userEmail, emailID).First(&message).Error - if err != nil { - return fmt.Errorf("email not found: %w", err) - } - - if message.IsRead { - return nil - } - - var prefs models.Preferences - if err := database.DB.Where("email = ?", userEmail).First(&prefs).Error; err != nil { - return err - } - - password, err := crypto.Decrypt(prefs.Authorization) - if err != nil { - return err - } - - client, err := email.ConnectIMAP(userEmail, password) - if err != nil { - return err - } - defer email.DisconnectIMAP(client) - - if err := email.MarkAsRead(client, message.Folder.IMAPName, message.UID); err != nil { - return err - } - - message.IsRead = true - if err := database.DB.Save(&message).Error; err != nil { - return fmt.Errorf("failed to update email: %w", err) - } - - return nil -} - -func ToggleEmailFlag(userEmail string, emailID uint) error { - var message models.Email - err := database.DB.Preload("Folder").Where("user_email = ? AND id = ?", userEmail, emailID).First(&message).Error - if err != nil { - return fmt.Errorf("email not found: %w", err) - } - - var prefs models.Preferences - if err := database.DB.Where("email = ?", userEmail).First(&prefs).Error; err != nil { - return err - } - - password, err := crypto.Decrypt(prefs.Authorization) - if err != nil { - return err - } - - client, err := email.ConnectIMAP(userEmail, password) - if err != nil { - return err - } - defer email.DisconnectIMAP(client) - - if err := email.ToggleFlag(client, message.Folder.IMAPName, message.UID, message.IsFlagged); err != nil { - return err - } - - message.IsFlagged = !message.IsFlagged - if err := database.DB.Save(&message).Error; err != nil { - return fmt.Errorf("failed to update email: %w", err) - } - - return nil -} - -func syncEmails(userEmail string, folderID uint, folderPath string) error { - var prefs models.Preferences - if err := database.DB.Where("email = ?", userEmail).First(&prefs).Error; err != nil { - return err - } - - password, err := crypto.Decrypt(prefs.Authorization) - if err != nil { - return err - } - - client, err := email.ConnectIMAP(userEmail, password) - if err != nil { - return err - } - defer email.DisconnectIMAP(client) - - messages, err := email.FetchMessages(client, folderPath, 50) - if err != nil { - return fmt.Errorf("failed to fetch messages: %w", err) - } - - for _, msg := range messages { - var existingMessage models.Email - result := database.DB.Where("user_email = ? AND folder_id = ? AND uid = ?", userEmail, folderID, msg.UID).First(&existingMessage) - - if result.Error == nil { - continue - } - - snippet := generateSnippet(msg.BodyText, msg.BodyHTML) - - message := models.Email{ - UserEmail: userEmail, - FolderID: folderID, - UID: msg.UID, - MessageID: msg.MessageID, - From: msg.From, - FromName: msg.FromName, - To: strings.Join(msg.To, ", "), - CC: strings.Join(msg.CC, ", "), - BCC: strings.Join(msg.BCC, ", "), - ReplyTo: strings.Join(msg.ReplyTo, ", "), - Subject: msg.Subject, - Date: msg.Date, - BodyText: msg.BodyText, - BodyHTML: msg.BodyHTML, - Snippet: snippet, - Size: int64(msg.Size), - InReplyTo: msg.InReplyTo, - IsRead: msg.IsRead, - IsFlagged: msg.IsFlagged, - IsAnswered: msg.IsAnswered, - IsDraft: msg.IsDraft, - HasAttachment: msg.HasAttachment, - } - - if err := database.DB.Create(&message).Error; err != nil { - continue - } - - for _, att := range msg.Attachments { - path, err := storage.UploadAttachment(userEmail, message.ID, att.Filename, att.Data, att.ContentType) - if err != nil { - continue - } - - attachment := models.Attachment{ - EmailID: message.ID, - Filename: att.Filename, - ContentType: att.ContentType, - Size: int64(len(att.Data)), - MinIOPath: path, - } - - database.DB.Create(&attachment) - } - } - - return nil -} - -func generateSnippet(bodyText, bodyHTML string) string { - text := bodyText - if text == "" && bodyHTML != "" { - text = stripHTML(bodyHTML) - } - - text = strings.TrimSpace(text) - if len(text) > 150 { - text = text[:150] + "..." - } - - return text -} - -func stripHTML(html string) string { - text := html - text = strings.ReplaceAll(text, "
", "\n") - text = strings.ReplaceAll(text, "
", "\n") - text = strings.ReplaceAll(text, "
", "\n") - text = strings.ReplaceAll(text, "

", "\n\n") - text = strings.ReplaceAll(text, "", "\n") - - inTag := false - var result strings.Builder - for _, char := range text { - if char == '<' { - inTag = true - continue - } - if char == '>' { - inTag = false - continue - } - if !inTag { - result.WriteRune(char) - } - } - - return strings.TrimSpace(result.String()) -} diff --git a/repository/emails.go b/repository/emails.go new file mode 100644 index 0000000..e39684c --- /dev/null +++ b/repository/emails.go @@ -0,0 +1,85 @@ +package repository + +import ( + "fmt" + "lain/database" + "lain/models" +) + +func GetEmailsByFolder(userEmail string, folderID uint, limit int, offset int) ([]models.Email, error) { + var messages []models.Email + err := database.DB.Where("user_email = ? AND folder_id = ?", userEmail, folderID). + Order("date DESC"). + Limit(limit). + Offset(offset). + Find(&messages).Error + + if err != nil { + return nil, fmt.Errorf("failed to fetch emails: %w", err) + } + + return messages, nil +} + +func GetEmailByID(userEmail string, emailID uint) (*models.Email, error) { + var message models.Email + err := database.DB.Preload("Folder").Where("user_email = ? AND id = ?", userEmail, emailID).First(&message).Error + if err != nil { + return nil, fmt.Errorf("email not found: %w", err) + } + + return &message, nil +} + +func EmailExists(userEmail string, folderID uint, uid uint32) (bool, error) { + var count int64 + err := database.DB.Model(&models.Email{}). + Where("user_email = ? AND folder_id = ? AND uid = ?", userEmail, folderID, uid). + Count(&count).Error + + if err != nil { + return false, err + } + + return count > 0, nil +} + +func CreateEmail(message *models.Email) (*models.Email, error) { + if err := database.DB.Create(message).Error; err != nil { + return nil, fmt.Errorf("failed to create email: %w", err) + } + return message, nil +} + +func UpdateEmail(message *models.Email) error { + if err := database.DB.Save(message).Error; err != nil { + return fmt.Errorf("failed to update email: %w", err) + } + return nil +} + +func GetAttachmentsByEmailID(emailID uint) ([]models.Attachment, error) { + var attachments []models.Attachment + err := database.DB.Where("email_id = ?", emailID).Find(&attachments).Error + if err != nil { + return nil, fmt.Errorf("failed to fetch attachments: %w", err) + } + + return attachments, nil +} + +func CreateAttachment(attachment *models.Attachment) error { + if err := database.DB.Create(attachment).Error; err != nil { + return fmt.Errorf("failed to create attachment: %w", err) + } + return nil +} + +func CountEmailsInFolder(userEmail string, folderID uint) (int64, error) { + var count int64 + err := database.DB.Model(&models.Email{}). + Where("user_email = ? AND folder_id = ?", userEmail, folderID). + Count(&count).Error + + return count, err +} diff --git a/repository/folders.go b/repository/folders.go index 20dcaa2..45c20c7 100644 --- a/repository/folders.go +++ b/repository/folders.go @@ -1,11 +1,11 @@ package repository import ( + "fmt" "lain/cache" "lain/database" "lain/models" "lain/types" - "lain/utils/crypto" "lain/utils/email" "net/url" "strings" @@ -13,7 +13,7 @@ import ( "github.com/gofiber/fiber/v2" ) -var folderIcons = map[string]types.FolderIconVariant{ +var FolderIcons = map[string]types.FolderIconVariant{ "default": { Open: "/static/icons/folder_open.png", Close: "/static/icons/folder.png", @@ -68,28 +68,54 @@ var folderIcons = map[string]types.FolderIconVariant{ }, } -func GetFolders(userEmail, activeFolder string) []fiber.Map { - if cached, ok := cache.GetFolders(userEmail); ok { - return updateActiveFolder(cached, activeFolder) +func GetAllFolders(userEmail string) ([]models.Folder, error) { + var folders []models.Folder + err := database.DB.Where("user_email = ?", userEmail).Find(&folders).Error + return folders, err +} + +func GetFolderByIMAPName(userEmail, imapName string) (*models.Folder, error) { + var folder models.Folder + err := database.DB.Where("user_email = ? AND LOWER(imap_name) = ?", userEmail, strings.ToLower(imapName)).First(&folder).Error + if err != nil { + return nil, err } + return &folder, nil +} - var count int64 - database.DB.Model(&models.Folder{}).Where("user_email = ?", userEmail).Count(&count) +func CreateFolder(folder *models.Folder) (*models.Folder, error) { + if err := database.DB.Create(folder).Error; err != nil { + return nil, fmt.Errorf("failed to create folder: %w", err) + } + return folder, nil +} - if count == 0 { - syncFolders(userEmail) +func UpdateFolder(folder *models.Folder) error { + if err := database.DB.Save(folder).Error; err != nil { + return fmt.Errorf("failed to update folder: %w", err) } + return nil +} + +func CountFolders(userEmail string) (int64, error) { + var count int64 + err := database.DB.Model(&models.Folder{}).Where("user_email = ?", userEmail).Count(&count).Error + return count, err +} - var allFolders []models.Folder - database.DB.Where("user_email = ?", userEmail).Find(&allFolders) +func BuildFolderTree(userEmail, activeFolder string) []fiber.Map { + if cached, ok := cache.GetFolders(userEmail); ok { + return email.UpdateActiveFolder(cached, activeFolder) + } - sortFolders(allFolders) + allFolders, _ := GetAllFolders(userEmail) + email.SortFolders(allFolders) folderMap := make(map[uint]*fiber.Map) var rootFolders []fiber.Map for _, folder := range allFolders { - displayName := getDisplayName(folder.IMAPName) + displayName := email.GetDisplayName(folder.IMAPName) folderData := fiber.Map{ "ID": folder.ID, @@ -119,25 +145,13 @@ func GetFolders(userEmail, activeFolder string) []fiber.Map { } cache.SetFolders(userEmail, rootFolders) - - return updateActiveFolder(rootFolders, activeFolder) -} - -func RefreshFolders(userEmail string) error { - cache.InvalidateFolders(userEmail) - err := syncFolders(userEmail) - if err != nil { - return err - } - return nil + return email.UpdateActiveFolder(rootFolders, activeFolder) } func GetFolderDisplayName(userEmail, folderPath string) string { decodedPath, _ := url.QueryUnescape(folderPath) - var folder models.Folder - err := database.DB.Where("user_email = ? AND LOWER(imap_name) = ?", userEmail, strings.ToLower(decodedPath)).First(&folder).Error - + folder, err := GetFolderByIMAPName(userEmail, decodedPath) if err != nil { if strings.ToLower(decodedPath) == "inbox" { return "Inbox" @@ -145,212 +159,5 @@ func GetFolderDisplayName(userEmail, folderPath string) string { return decodedPath } - return getDisplayName(folder.IMAPName) -} - -func getDisplayName(imapName string) string { - if strings.Contains(imapName, "/") { - parts := strings.Split(imapName, "/") - lastPart := parts[len(parts)-1] - if strings.ToLower(lastPart) == "inbox" { - return "Inbox" - } - return lastPart - } - - if strings.ToLower(imapName) == "inbox" { - return "Inbox" - } - - return imapName -} - -func updateActiveFolder(folders []fiber.Map, activeFolder string) []fiber.Map { - decodedActive, _ := url.QueryUnescape(activeFolder) - activeLower := strings.ToLower(decodedActive) - - foldersCopy := make([]fiber.Map, len(folders)) - for i, folder := range folders { - foldersCopy[i] = copyFolderMap(folder) - } - - var updateActive func([]fiber.Map) - updateActive = func(folderList []fiber.Map) { - for i := range folderList { - imapNameLower := strings.ToLower(folderList[i]["IMAPName"].(string)) - folderList[i]["Active"] = imapNameLower == activeLower - if subfolders, ok := folderList[i]["Subfolders"].([]fiber.Map); ok && len(subfolders) > 0 { - updateActive(subfolders) - } - } - } - - updateActive(foldersCopy) - return foldersCopy -} - -func copyFolderMap(folder fiber.Map) fiber.Map { - copy := fiber.Map{} - for k, v := range folder { - if k == "Subfolders" { - if subfolders, ok := v.([]fiber.Map); ok { - subfoldersCopy := make([]fiber.Map, len(subfolders)) - for i, sf := range subfolders { - subfoldersCopy[i] = copyFolderMap(sf) - } - copy[k] = subfoldersCopy - } - } else { - copy[k] = v - } - } - return copy -} - -func sortFolders(folders []models.Folder) { - for i := 0; i < len(folders)-1; i++ { - for j := 0; j < len(folders)-i-1; j++ { - if folders[j].SortOrder > folders[j+1].SortOrder { - folders[j], folders[j+1] = folders[j+1], folders[j] - } else if folders[j].SortOrder == folders[j+1].SortOrder { - if strings.ToLower(folders[j].IMAPName) > strings.ToLower(folders[j+1].IMAPName) { - folders[j], folders[j+1] = folders[j+1], folders[j] - } - } - } - } -} - -func getFolderType(folderName string) string { - nameLower := strings.ToLower(folderName) - - if strings.Contains(folderName, "/") { - parts := strings.Split(folderName, "/") - nameLower = strings.ToLower(parts[len(parts)-1]) - } - - for iconType := range folderIcons { - if iconType == "default" { - continue - } - if strings.Contains(nameLower, iconType) { - return iconType - } - } - - return "default" -} - -func syncFolders(userEmail string) error { - var prefs models.Preferences - if err := database.DB.Where("email = ?", userEmail).First(&prefs).Error; err != nil { - return err - } - - password, err := crypto.Decrypt(prefs.Authorization) - if err != nil { - return err - } - - client, err := email.ConnectIMAP(userEmail, password) - if err != nil { - return err - } - defer email.DisconnectIMAP(client) - - imapFolders, err := email.FetchFolders(client) - if err != nil { - return err - } - - foldersByName := make(map[string]uint) - - for i, imapFolder := range imapFolders { - if strings.HasPrefix(imapFolder.Name, "Virtual") || strings.Contains(imapFolder.Name, "/Virtual") { - continue - } - - var folder models.Folder - imapNameLower := strings.ToLower(imapFolder.Name) - result := database.DB.Where("user_email = ? AND LOWER(imap_name) = ?", userEmail, imapNameLower).First(&folder) - - sortOrder := getSortOrder(imapFolder.Name, i) - folderType := getFolderType(imapFolder.Name) - iconVariant := folderIcons[folderType] - - if result.Error != nil { - folder = models.Folder{ - UserEmail: userEmail, - Name: imapFolder.Name, - IMAPName: imapFolder.Name, - IconOpen: iconVariant.Open, - IconClose: iconVariant.Close, - SortOrder: sortOrder, - } - database.DB.Create(&folder) - foldersByName[imapNameLower] = folder.ID - } else { - folder.Name = imapFolder.Name - folder.SortOrder = sortOrder - folder.IconOpen = iconVariant.Open - folder.IconClose = iconVariant.Close - database.DB.Save(&folder) - foldersByName[imapNameLower] = folder.ID - } - } - - for _, imapFolder := range imapFolders { - if strings.HasPrefix(imapFolder.Name, "Virtual") || strings.Contains(imapFolder.Name, "/Virtual") { - continue - } - - if strings.Contains(imapFolder.Name, "/") { - parts := strings.Split(imapFolder.Name, "/") - if len(parts) > 1 { - parentName := strings.Join(parts[:len(parts)-1], "/") - parentNameLower := strings.ToLower(parentName) - if parentID, ok := foldersByName[parentNameLower]; ok { - var folder models.Folder - imapNameLower := strings.ToLower(imapFolder.Name) - if err := database.DB.Where("user_email = ? AND LOWER(imap_name) = ?", userEmail, imapNameLower).First(&folder).Error; err == nil { - folder.ParentID = &parentID - database.DB.Save(&folder) - } - } - } - } - } - - return nil -} - -func getSortOrder(folderName string, index int) int { - nameLower := strings.ToLower(folderName) - - if nameLower == "inbox" { - return 0 - } - if strings.Contains(nameLower, "draft") { - return 1 - } - if strings.Contains(nameLower, "sent") { - return 2 - } - if strings.Contains(nameLower, "archive") { - return 3 - } - if strings.Contains(nameLower, "trash") || strings.Contains(nameLower, "deleted") { - return 4 - } - if strings.Contains(nameLower, "spam") || strings.Contains(nameLower, "junk") { - return 5 - } - - if strings.Contains(folderName, "/") { - parts := strings.Split(folderName, "/") - baseOrder := getSortOrder(parts[0], index) - return baseOrder + 1000 + (index * 10) - } - - return 100 + index + return email.GetDisplayName(folder.IMAPName) } -- cgit v1.2.3