diff options
| -rw-r--r-- | controllers/mail.go | 13 | ||||
| -rw-r--r-- | data/icons.go | 58 | ||||
| -rw-r--r-- | jobs/emails.go | 97 | ||||
| -rw-r--r-- | jobs/folders.go | 93 | ||||
| -rw-r--r-- | repository/email.go | 284 | ||||
| -rw-r--r-- | repository/emails.go | 85 | ||||
| -rw-r--r-- | repository/folders.go | 277 | ||||
| -rw-r--r-- | services/emails.go | 58 | ||||
| -rw-r--r-- | services/folders.go | 22 | ||||
| -rw-r--r-- | static/css/main.css | 85 | ||||
| -rw-r--r-- | templates/partials/pane.django | 39 | ||||
| -rw-r--r-- | utils/email/folders.go | 132 | ||||
| -rw-r--r-- | utils/format/date.go | 99 | ||||
| -rw-r--r-- | utils/format/html.go | 73 |
14 files changed, 843 insertions, 572 deletions
diff --git a/controllers/mail.go b/controllers/mail.go index 36166ce..c4112d1 100644 --- a/controllers/mail.go +++ b/controllers/mail.go @@ -2,7 +2,7 @@ package controllers import ( "lain/models" - "lain/repository" + "lain/services" "lain/session" "lain/utils/meta" "lain/utils/shortcuts" @@ -16,25 +16,22 @@ func Mailbox(context *fiber.Ctx) error { folderPath = "inbox" } - email, err := session.GetSessionEmail(context) + userEmail, err := session.GetSessionEmail(context) if err != nil { return InternalServerError(context, err) } prefs := context.Locals("Preferences").(*models.Preferences) - folders := repository.GetFolders(email, folderPath) - displayName := repository.GetFolderDisplayName(email, folderPath) + folders := services.GetFolders(userEmail, folderPath) + displayName := services.GetFolderDisplayName(userEmail, folderPath) page := context.QueryInt("page", 1) if page < 1 { page = 1 } - limit := prefs.EmailsPerPage - offset := (page - 1) * limit - - emails, err := repository.GetEmails(email, folderPath, limit, offset) + emails, err := services.GetEmails(userEmail, folderPath, prefs, page) if err != nil { emails = []fiber.Map{} } diff --git a/data/icons.go b/data/icons.go new file mode 100644 index 0000000..53624f4 --- /dev/null +++ b/data/icons.go @@ -0,0 +1,58 @@ +package data + +import "lain/types" + +var FolderIcons = map[string]types.FolderIconVariant{ + "default": { + Open: "/static/icons/folder_open.png", + Close: "/static/icons/folder.png", + }, + "inbox": { + Open: "/static/icons/inbox_open.png", + Close: "/static/icons/inbox.png", + }, + "encrypt": { + Open: "/static/icons/encrypt_open.png", + Close: "/static/icons/encrypt.png", + }, + "dog": { + Open: "/static/icons/dog_open.png", + Close: "/static/icons/dog.png", + }, + "internal": { + Open: "/static/icons/internal_open.png", + Close: "/static/icons/internal.png", + }, + "draft": { + Open: "/static/icons/draft_open.png", + Close: "/static/icons/draft.png", + }, + "progress": { + Open: "/static/icons/draft_open.png", + Close: "/static/icons/draft.png", + }, + "sent": { + Open: "/static/icons/sent.png", + Close: "/static/icons/sent.png", + }, + "archive": { + Open: "/static/icons/archive_open.png", + Close: "/static/icons/archive.png", + }, + "trash": { + Open: "/static/icons/trash_open.png", + Close: "/static/icons/trash.png", + }, + "delete": { + Open: "/static/icons/trash_open.png", + Close: "/static/icons/trash.png", + }, + "spam": { + Open: "/static/icons/junk_open.png", + Close: "/static/icons/junk.png", + }, + "junk": { + Open: "/static/icons/junk_open.png", + Close: "/static/icons/junk.png", + }, +} diff --git a/jobs/emails.go b/jobs/emails.go new file mode 100644 index 0000000..350b454 --- /dev/null +++ b/jobs/emails.go @@ -0,0 +1,97 @@ +package jobs + +import ( + "fmt" + "lain/database" + "lain/models" + "lain/repository" + "lain/utils/crypto" + "lain/utils/email" + "lain/utils/format" + "lain/utils/storage" + "strings" +) + +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 { + exists, err := repository.EmailExists(userEmail, folderID, msg.UID) + if err != nil { + continue + } + if exists { + continue + } + + snippet := format.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, + } + + createdMessage, err := repository.CreateEmail(&message) + if err != nil { + continue + } + + for _, att := range msg.Attachments { + path, err := storage.UploadAttachment(userEmail, createdMessage.ID, att.Filename, att.Data, att.ContentType) + if err != nil { + continue + } + + attachment := models.Attachment{ + EmailID: createdMessage.ID, + Filename: att.Filename, + ContentType: att.ContentType, + Size: int64(len(att.Data)), + MinIOPath: path, + } + + repository.CreateAttachment(&attachment) + } + } + + return nil +} diff --git a/jobs/folders.go b/jobs/folders.go new file mode 100644 index 0000000..c09b265 --- /dev/null +++ b/jobs/folders.go @@ -0,0 +1,93 @@ +package jobs + +import ( + "lain/models" + "lain/repository" + "lain/types" + "lain/utils/crypto" + "lain/utils/email" + "strings" +) + +func SyncFolders(userEmail string, iconMap map[string]types.FolderIconVariant) error { + prefs, err := repository.GetPreferencesByEmail(userEmail) + if 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 email.IsVirtualFolder(imapFolder.Name) { + continue + } + + existingFolder, err := repository.GetFolderByIMAPName(userEmail, imapFolder.Name) + + sortOrder := email.GetSortOrder(imapFolder.Name, i) + folderType := email.GetFolderType(imapFolder.Name, iconMap) + iconVariant := iconMap[folderType] + + if err != nil { + folder := models.Folder{ + UserEmail: userEmail, + Name: imapFolder.Name, + IMAPName: imapFolder.Name, + IconOpen: iconVariant.Open, + IconClose: iconVariant.Close, + SortOrder: sortOrder, + } + created, err := repository.CreateFolder(&folder) + if err != nil { + continue + } + foldersByName[strings.ToLower(imapFolder.Name)] = created.ID + } else { + existingFolder.Name = imapFolder.Name + existingFolder.SortOrder = sortOrder + existingFolder.IconOpen = iconVariant.Open + existingFolder.IconClose = iconVariant.Close + repository.UpdateFolder(existingFolder) + foldersByName[strings.ToLower(imapFolder.Name)] = existingFolder.ID + } + } + + for _, imapFolder := range imapFolders { + if email.IsVirtualFolder(imapFolder.Name) { + 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 { + folder, err := repository.GetFolderByIMAPName(userEmail, imapFolder.Name) + if err == nil { + folder.ParentID = &parentID + repository.UpdateFolder(folder) + } + } + } + } + } + + return nil +} 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, "<br>", "\n") - text = strings.ReplaceAll(text, "<br/>", "\n") - text = strings.ReplaceAll(text, "<br />", "\n") - text = strings.ReplaceAll(text, "</p>", "\n\n") - text = strings.ReplaceAll(text, "</div>", "\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) } diff --git a/services/emails.go b/services/emails.go new file mode 100644 index 0000000..e007f87 --- /dev/null +++ b/services/emails.go @@ -0,0 +1,58 @@ +package services + +import ( + "lain/jobs" + "lain/models" + "lain/repository" + "lain/utils/format" + "net/url" + "strings" + + "github.com/gofiber/fiber/v2" +) + +func GetEmails(userEmail, folderPath string, prefs *models.Preferences, page int) ([]fiber.Map, error) { + decodedPath, _ := url.QueryUnescape(folderPath) + + folder, err := repository.GetFolderByIMAPName(userEmail, strings.ToLower(decodedPath)) + if err != nil { + return nil, err + } + + emailCount, _ := repository.CountEmailsInFolder(userEmail, folder.ID) + if emailCount == 0 { + jobs.SyncEmails(userEmail, folder.ID, folder.IMAPName) + } + + limit := prefs.EmailsPerPage + offset := (page - 1) * limit + + messages, err := repository.GetEmailsByFolder(userEmail, folder.ID, limit, offset) + if err != nil { + return []fiber.Map{}, err + } + + var emails []fiber.Map + for _, message := range messages { + fromName := message.FromName + if fromName == "" { + fromName = message.From + } + + emails = append(emails, fiber.Map{ + "ID": message.ID, + "UID": message.UID, + "From": format.DecodeHTML(message.From), + "FromName": format.DecodeHTML(fromName), + "Subject": format.DecodeHTML(message.Subject), + "Date": message.Date, + "DateFormatted": format.FormatEmailDate(message.Date, prefs.DateFormat, prefs.TimeFormat, prefs.PrettyDates, prefs.TimeZone), + "Snippet": format.DecodeHTML(message.Snippet), + "IsRead": message.IsRead, + "IsFlagged": message.IsFlagged, + "HasAttachment": message.HasAttachment, + }) + } + + return emails, nil +} diff --git a/services/folders.go b/services/folders.go new file mode 100644 index 0000000..9555172 --- /dev/null +++ b/services/folders.go @@ -0,0 +1,22 @@ +package services + +import ( + "lain/data" + "lain/jobs" + "lain/repository" + + "github.com/gofiber/fiber/v2" +) + +func GetFolders(userEmail, activeFolder string) []fiber.Map { + count, _ := repository.CountFolders(userEmail) + if count == 0 { + jobs.SyncFolders(userEmail, data.FolderIcons) + } + + return repository.BuildFolderTree(userEmail, activeFolder) +} + +func GetFolderDisplayName(userEmail, folderPath string) string { + return repository.GetFolderDisplayName(userEmail, folderPath) +} diff --git a/static/css/main.css b/static/css/main.css index 84a1d59..de658ba 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -583,19 +583,19 @@ input[type="date"]:focus { background: var(--bg-primary); border-right: 1px solid var(--border-color); overflow-y: auto; - padding: 4px; + padding: 0px; } .email-row { display: grid; - grid-template-columns: 18px 18px 1fr 70px; - gap: 6px; + grid-template-columns: 16px 1fr 64px; + gap: 8px; align-items: center; - padding: 8px; + padding: 8px 10px; background: var(--bg-secondary); - border: 1px solid var(--border-color); - margin-bottom: 3px; + border-bottom: 1px solid var(--border-color); cursor: pointer; + transition: background 0.1s; } .email-row:hover { @@ -603,57 +603,66 @@ input[type="date"]:focus { } .email-row.active { - border-left: 2px solid var(--accent-primary); + background: var(--bg-tertiary); + border-left: 3px solid var(--accent-primary); + padding-left: 7px; } .email-row.unread { - border-left: 2px solid var(--accent-tertiary); + border-left: 3px solid var(--accent-tertiary); + padding-left: 7px; } .email-row.unread .email-from, .email-row.unread .email-subject { font-weight: bold; + color: var(--text-primary); } -.email-checkbox input { - width: 14px; - height: 14px; +.email-flag { + width: 16px; + height: 16px; + font-size: 14px; + color: var(--text-muted); cursor: pointer; + user-select: none; + display: flex; + align-items: center; + justify-content: center; } -.email-flag { - width: 14px; - height: 14px; - opacity: 0.3; - cursor: pointer; +.email-flag:hover { + color: var(--accent-secondary); } .email-flag.flagged { - opacity: 1; + color: var(--accent-secondary); } .email-info { overflow: hidden; + min-width: 0; } .email-from { font-size: 11px; - margin-bottom: 2px; + color: var(--text-secondary); + margin-bottom: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .email-subject { - color: var(--accent-primary); - font-size: 11px; - margin-bottom: 2px; + color: var(--text-primary); + font-size: 12px; + margin-bottom: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.email-preview { +.email-snippet { color: var(--text-muted); font-size: 10px; white-space: nowrap; @@ -665,12 +674,19 @@ input[type="date"]:focus { display: flex; flex-direction: column; align-items: flex-end; - gap: 2px; + gap: 4px; + flex-shrink: 0; } .email-date { color: var(--text-muted); - font-size: 9px; + font-size: 10px; + white-space: nowrap; +} + +.attachment-icon { + font-size: 12px; + color: var(--text-muted); } .empty-state { @@ -682,6 +698,21 @@ input[type="date"]:focus { font-size: 12px; } +.no-subject, +.no-content { + font-style: italic; + color: var(--text-muted); + opacity: 0.7; +} + +.email-preview { + color: var(--text-muted); + font-size: 10px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .preview { background: var(--bg-primary); overflow-y: auto; @@ -696,12 +727,6 @@ input[type="date"]:focus { border-bottom: 1px solid var(--border-color); } -.email-subject { - color: var(--accent-primary); - font-size: 16px; - font-weight: bold; -} - .email-actions { display: flex; gap: 4px; diff --git a/templates/partials/pane.django b/templates/partials/pane.django index 20ddfcc..c81893d 100644 --- a/templates/partials/pane.django +++ b/templates/partials/pane.django @@ -1,28 +1,37 @@ <div class="pane"> {% if Emails %} {% for email in Emails %} - <div class="email-row {% if not email.IsRead %}unread{% endif %} {% if email.Active %}active{% endif %}" data-email-id="{{ email.ID }}"> - <div class="email-checkbox"> - <input type="checkbox" /> - </div> - - <div class="email-flag {% if email.IsFlagged %}flagged{% endif %}"> - {% comment %} - <img src="{% static 'icons/flag.svg' %}" alt="Flag" /> - (We will write the static filter later){% endcomment %} - </div> + <div class="email-row {% if not email.IsRead %}unread{% endif %}" data-email-id="{{ email.ID }}"> + <div class="email-flag {% if email.IsFlagged %}flagged{% endif %}" + title="{% if email.IsFlagged %} + Unflag + {% else %} + Flag + {% endif %}">⚑</div> <div class="email-info"> <div class="email-from">{{ email.FromName }}</div> - <div class="email-subject">{{ email.Subject }}</div> - <div class="email-preview">{{ email.Preview }}</div> + <div class="email-subject"> + {% if email.Subject %} + {{ email.Subject }} + {% else %} + <span class="no-subject">[No Subject]</span> + {% endif %} + </div> + <div class="email-snippet"> + {% if email.Snippet %} + {{ email.Snippet }} + {% else %} + <span class="no-content">[Empty Content]</span> + {% endif %} + </div> </div> <div class="email-meta"> - {% if email.HasAttachments %} - <span class="attachment-icon">📎</span> + {% if email.HasAttachment %} + <span class="attachment-icon" title="Has attachments">📎</span> {% endif %} - <span class="email-date">{{ email.Date }}</span> + <span class="email-date">{{ email.DateFormatted }}</span> </div> </div> {% endfor %} diff --git a/utils/email/folders.go b/utils/email/folders.go index 3acffae..1ae2bf4 100644 --- a/utils/email/folders.go +++ b/utils/email/folders.go @@ -1,11 +1,32 @@ package email import ( + "lain/models" "lain/types" + "net/url" + "strings" "github.com/emersion/go-imap" + "github.com/gofiber/fiber/v2" ) +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 FetchFolders(client *types.EmailClient) ([]types.IMAPFolder, error) { mailboxes := make(chan *imap.MailboxInfo, 10) done := make(chan error, 1) @@ -28,3 +49,114 @@ func FetchFolders(client *types.EmailClient) ([]types.IMAPFolder, error) { return folders, nil } + +func GetFolderType(folderName string, iconMap map[string]types.FolderIconVariant) string { + nameLower := strings.ToLower(folderName) + + if strings.Contains(folderName, "/") { + parts := strings.Split(folderName, "/") + nameLower = strings.ToLower(parts[len(parts)-1]) + } + + for iconType := range iconMap { + if iconType == "default" { + continue + } + if strings.Contains(nameLower, iconType) { + return iconType + } + } + + return "default" +} + +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 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 +} + +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 IsVirtualFolder(folderName string) bool { + return strings.HasPrefix(folderName, "Virtual") || strings.Contains(folderName, "/Virtual") +} + +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, f := range folders { + foldersCopy[i] = CopyFolderMap(f) + } + + 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 +} diff --git a/utils/format/date.go b/utils/format/date.go new file mode 100644 index 0000000..8ef95cb --- /dev/null +++ b/utils/format/date.go @@ -0,0 +1,99 @@ +package format + +import ( + "html" + "lain/types" + "time" +) + +func FormatEmailDate(date time.Time, dateFormat types.DateFormat, timeFormat types.TimeFormat, prettyDates bool, timezone string) string { + loc, err := time.LoadLocation(timezone) + if err != nil { + loc = time.UTC + } + + date = date.In(loc) + now := time.Now().In(loc) + + if prettyDates { + return formatPrettyDate(date, now, timeFormat) + } + + return formatFullDate(date, dateFormat, timeFormat) +} + +func formatPrettyDate(date, now time.Time, timeFormat types.TimeFormat) string { + diff := now.Sub(date) + + // Today - show time only + if date.Year() == now.Year() && date.YearDay() == now.YearDay() { + return formatTime(date, timeFormat) + } + + // Yesterday + yesterday := now.AddDate(0, 0, -1) + if date.Year() == yesterday.Year() && date.YearDay() == yesterday.YearDay() { + return "Yesterday" + } + + // This week - show day name and time + if diff.Hours() < 168 { // 7 days + dayName := date.Format("Mon") + timeStr := formatTime(date, timeFormat) + return dayName + " " + timeStr + } + + // This year - show month and day + if date.Year() == now.Year() { + return date.Format("Jan 2") + } + + // Older - show full date + return date.Format("Jan 2, 2006") +} + +func formatFullDate(date time.Time, dateFormat types.DateFormat, timeFormat types.TimeFormat) string { + dateStr := formatDate(date, dateFormat) + timeStr := formatTime(date, timeFormat) + return dateStr + " " + timeStr +} + +func formatDate(date time.Time, dateFormat types.DateFormat) string { + switch dateFormat { + case types.YearMonthDayDashed: + return date.Format("2006-01-02") + case types.YearMonthDaySlashed: + return date.Format("2006/01/02") + case types.YearMonthDayDotted: + return date.Format("2006.01.02") + case types.DayMonthYearDashed: + return date.Format("02-01-2006") + case types.DayMonthYearSlashed: + return date.Format("02/01/2006") + case types.DayMonthYearDotted: + return date.Format("02.01.2006") + case types.DayMonthYearDottedShort: + return date.Format("2.1.06") + default: + return date.Format("2006-01-02") + } +} + +func formatTime(date time.Time, timeFormat types.TimeFormat) string { + switch timeFormat { + case types.ShortHoursAndMinutes24Hours: + return date.Format("15:4") + case types.FullHoursAndMinutes24Hours: + return date.Format("15:04") + case types.ShortHoursAndMinutes12Hours: + return date.Format("3:4 PM") + case types.FullHoursAndMinutes12Hours: + return date.Format("03:04 PM") + default: + return date.Format("15:04") + } +} + +func DecodeHTML(text string) string { + return html.UnescapeString(text) +} diff --git a/utils/format/html.go b/utils/format/html.go new file mode 100644 index 0000000..36e2425 --- /dev/null +++ b/utils/format/html.go @@ -0,0 +1,73 @@ +package format + +import ( + "regexp" + "strings" +) + +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 + + styleRegex := regexp.MustCompile(`(?i)<style[^>]*>[\s\S]*?</style>`) + text = styleRegex.ReplaceAllString(text, "") + + scriptRegex := regexp.MustCompile(`(?i)<script[^>]*>[\s\S]*?</script>`) + text = scriptRegex.ReplaceAllString(text, "") + + headRegex := regexp.MustCompile(`(?i)<head[^>]*>[\s\S]*?</head>`) + text = headRegex.ReplaceAllString(text, "") + + text = strings.ReplaceAll(text, "<br>", "\n") + text = strings.ReplaceAll(text, "<br/>", "\n") + text = strings.ReplaceAll(text, "<br />", "\n") + text = strings.ReplaceAll(text, "</p>", "\n\n") + text = strings.ReplaceAll(text, "</div>", "\n") + text = strings.ReplaceAll(text, "</tr>", "\n") + text = strings.ReplaceAll(text, "</h1>", "\n") + text = strings.ReplaceAll(text, "</h2>", "\n") + text = strings.ReplaceAll(text, "</h3>", "\n") + text = strings.ReplaceAll(text, "</li>", "\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) + } + } + + cleanText := result.String() + + lines := strings.Split(cleanText, "\n") + var cleanLines []string + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + cleanLines = append(cleanLines, line) + } + } + + return strings.TrimSpace(strings.Join(cleanLines, " ")) +} |
