summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--controllers/mail.go13
-rw-r--r--data/icons.go58
-rw-r--r--jobs/emails.go97
-rw-r--r--jobs/folders.go93
-rw-r--r--repository/email.go284
-rw-r--r--repository/emails.go85
-rw-r--r--repository/folders.go277
-rw-r--r--services/emails.go58
-rw-r--r--services/folders.go22
-rw-r--r--static/css/main.css85
-rw-r--r--templates/partials/pane.django39
-rw-r--r--utils/email/folders.go132
-rw-r--r--utils/format/date.go99
-rw-r--r--utils/format/html.go73
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, " "))
+}