From b2a231280ce3265d20cdc5f317ae1bcc5eb59924 Mon Sep 17 00:00:00 2001 From: Bobby Date: Sun, 8 Mar 2026 08:11:41 +0530 Subject: Add webmail email management templates and storage utilities - Implemented email listing template with read/unread and star functionality. - Created empty state template for webmail when no emails are present. - Developed folder navigation template for managing email folders. - Added email preview template for displaying selected email details. - Introduced storage utilities for managing email files, including creation, reading, moving, and deletion. - Defined constants for storage paths and error messages related to file operations. --- .gitignore | 3 + controllers/mail/webmail.go | 187 +++++++ database/migration.go | 1 + models/mail/email.go | 3 + models/mail/folder.go | 13 + pages/mail/mailbox.go | 17 - pages/mail/webmail.go | 155 ++++++ repositories/mail/email.go | 116 +++++ repositories/mail/folder.go | 80 +++ router/mail.go | 20 +- services/mail/mailboxes.go | 8 +- services/mail/messages.go | 25 + services/mail/webmail.go | 700 ++++++++++++++++++++++++++ static/css/tailwind.css | 246 +++++++++ static/js/dropdown.js | 26 +- templates/dashboard/htmx/overview.htmx.django | 1 - templates/domains/htmx/domains.htmx.django | 1 - templates/domains/htmx/editdomain.htmx.django | 1 - templates/domains/htmx/edittld.htmx.django | 1 - templates/domains/htmx/index.htmx.django | 1 - templates/domains/htmx/newdomain.htmx.django | 3 +- templates/domains/htmx/newtld.htmx.django | 3 +- templates/domains/htmx/tlds.htmx.django | 1 - templates/layouts/dashboard.django | 2 - templates/mail/htmx/editmailbox.htmx.django | 1 - templates/mail/htmx/edituser.htmx.django | 1 - templates/mail/htmx/index.htmx.django | 1 - templates/mail/htmx/mailbox.htmx.django | 17 - templates/mail/htmx/mailboxes.htmx.django | 4 +- templates/mail/htmx/newmailbox.htmx.django | 3 +- templates/mail/htmx/newuser.htmx.django | 3 +- templates/mail/htmx/users.htmx.django | 1 - templates/mail/htmx/webmail.htmx.django | 229 +++++++++ templates/mail/mailbox.django | 16 - templates/mail/webmail.django | 5 + templates/mail/webmail/compose.django | 309 ++++++++++++ templates/mail/webmail/emails.django | 46 ++ templates/mail/webmail/empty.django | 9 + templates/mail/webmail/folders.django | 73 +++ templates/mail/webmail/preview.django | 102 ++++ templates/partials/sidebar.django | 7 + utils/storage/defaults.go | 7 + utils/storage/mail.go | 48 ++ utils/storage/messages.go | 9 + 44 files changed, 2417 insertions(+), 88 deletions(-) create mode 100644 controllers/mail/webmail.go create mode 100644 models/mail/folder.go delete mode 100644 pages/mail/mailbox.go create mode 100644 pages/mail/webmail.go create mode 100644 repositories/mail/folder.go create mode 100644 services/mail/webmail.go delete mode 100644 templates/mail/htmx/mailbox.htmx.django create mode 100644 templates/mail/htmx/webmail.htmx.django delete mode 100644 templates/mail/mailbox.django create mode 100644 templates/mail/webmail.django create mode 100644 templates/mail/webmail/compose.django create mode 100644 templates/mail/webmail/emails.django create mode 100644 templates/mail/webmail/empty.django create mode 100644 templates/mail/webmail/folders.django create mode 100644 templates/mail/webmail/preview.django create mode 100644 utils/storage/defaults.go create mode 100644 utils/storage/mail.go create mode 100644 utils/storage/messages.go diff --git a/.gitignore b/.gitignore index 5b5c6a2..ceb17b0 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ config.toml # Database files *.db + +# Data +data/ \ No newline at end of file diff --git a/controllers/mail/webmail.go b/controllers/mail/webmail.go new file mode 100644 index 0000000..4524375 --- /dev/null +++ b/controllers/mail/webmail.go @@ -0,0 +1,187 @@ +package mail + +import ( + "fmt" + "strconv" + + mailService "dove/services/mail" + "dove/utils/meta" + "dove/utils/shortcuts" + + "github.com/gofiber/fiber/v2" +) + +func triggerEvent(context *fiber.Ctx, eventName string) error { + context.Set("HX-Trigger", eventName) + return context.SendStatus(fiber.StatusNoContent) +} + +func SendEmail(context *fiber.Ctx) error { + body, parseError := meta.Body[mailService.SendEmailRequest](context) + if parseError != nil { + return shortcuts.BadRequestError(context, parseError) + } + + serviceError := mailService.SendEmail(body) + if serviceError != nil { + return shortcuts.HandleError(context, serviceError) + } + + redirectPath := fmt.Sprintf("/mail/webmail/%d?folder=sent", body.FromMailboxID) + return shortcuts.RedirectToPath(context, redirectPath) +} + +func SaveDraft(context *fiber.Ctx) error { + body, parseError := meta.Body[mailService.SendEmailRequest](context) + if parseError != nil { + return shortcuts.BadRequestError(context, parseError) + } + + draft, serviceError := mailService.SaveDraft(body) + if serviceError != nil { + return shortcuts.HandleError(context, serviceError) + } + + return context.JSON(fiber.Map{ + "draft_id": draft.ID, + "message": "Draft saved.", + }) +} + +func ToggleStar(context *fiber.Ctx) error { + emailID, parseError := strconv.ParseUint(meta.Request(context).Param("email_id"), 10, 64) + if parseError != nil { + return shortcuts.BadRequestError(context, parseError) + } + + serviceError := mailService.ToggleStar(uint(emailID)) + if serviceError != nil { + return shortcuts.HandleError(context, serviceError) + } + + return triggerEvent(context, "emailStarred") +} + +func MarkReadEmail(context *fiber.Ctx) error { + emailID, parseError := strconv.ParseUint(meta.Request(context).Param("email_id"), 10, 64) + if parseError != nil { + return shortcuts.BadRequestError(context, parseError) + } + + serviceError := mailService.MarkRead(uint(emailID)) + if serviceError != nil { + return shortcuts.HandleError(context, serviceError) + } + + return triggerEvent(context, "emailReadChanged") +} + +func MarkUnreadEmail(context *fiber.Ctx) error { + emailID, parseError := strconv.ParseUint(meta.Request(context).Param("email_id"), 10, 64) + if parseError != nil { + return shortcuts.BadRequestError(context, parseError) + } + + serviceError := mailService.MarkUnread(uint(emailID)) + if serviceError != nil { + return shortcuts.HandleError(context, serviceError) + } + + return triggerEvent(context, "emailReadChanged") +} + +func MoveEmailToFolder(context *fiber.Ctx) error { + emailID, parseError := strconv.ParseUint(meta.Request(context).Param("email_id"), 10, 64) + if parseError != nil { + return shortcuts.BadRequestError(context, parseError) + } + + body, bodyError := meta.Body[mailService.MoveEmailRequest](context) + if bodyError != nil { + return shortcuts.BadRequestError(context, bodyError) + } + + serviceError := mailService.MoveEmail(uint(emailID), body) + if serviceError != nil { + return shortcuts.HandleError(context, serviceError) + } + + return triggerEvent(context, "emailMoved") +} + +func TrashEmailAction(context *fiber.Ctx) error { + emailID, parseError := strconv.ParseUint(meta.Request(context).Param("email_id"), 10, 64) + if parseError != nil { + return shortcuts.BadRequestError(context, parseError) + } + + mailboxID, mailboxParseError := strconv.ParseUint(meta.Request(context).Param("mailbox_id"), 10, 64) + if mailboxParseError != nil { + return shortcuts.BadRequestError(context, mailboxParseError) + } + + serviceError := mailService.TrashEmail(uint(emailID), uint(mailboxID)) + if serviceError != nil { + return shortcuts.HandleError(context, serviceError) + } + + return triggerEvent(context, "emailDeleted") +} + +func BulkEmailAction(context *fiber.Ctx) error { + mailboxID, parseError := strconv.ParseUint(meta.Request(context).Param("mailbox_id"), 10, 64) + if parseError != nil { + return shortcuts.BadRequestError(context, parseError) + } + + body, bodyError := meta.Body[mailService.BulkActionRequest](context) + if bodyError != nil { + return shortcuts.BadRequestError(context, bodyError) + } + + serviceError := mailService.BulkAction(body, uint(mailboxID)) + if serviceError != nil { + return shortcuts.HandleError(context, serviceError) + } + + return triggerEvent(context, "emailMoved") +} + +func CreateWebMailFolder(context *fiber.Ctx) error { + mailboxID, parseError := strconv.ParseUint(meta.Request(context).Param("mailbox_id"), 10, 64) + if parseError != nil { + return shortcuts.BadRequestError(context, parseError) + } + + body, bodyError := meta.Body[mailService.CreateFolderRequest](context) + if bodyError != nil { + return shortcuts.BadRequestError(context, bodyError) + } + + body.MailboxID = uint(mailboxID) + + serviceError := mailService.CreateFolder(body) + if serviceError != nil { + return shortcuts.HandleError(context, serviceError) + } + + redirectPath := fmt.Sprintf("/mail/webmail/%d", mailboxID) + return shortcuts.RedirectToPath(context, redirectPath) +} + +func DeleteWebMailFolder(context *fiber.Ctx) error { + folderID, parseError := strconv.ParseUint(meta.Request(context).Param("folder_id"), 10, 64) + if parseError != nil { + return shortcuts.BadRequestError(context, parseError) + } + + mailboxID, _ := strconv.ParseUint(meta.Request(context).Param("mailbox_id"), 10, 64) + + serviceError := mailService.DeleteFolder(uint(folderID)) + if serviceError != nil { + return shortcuts.HandleError(context, serviceError) + } + + redirectPath := fmt.Sprintf("/mail/webmail/%d", mailboxID) + return shortcuts.RedirectToPath(context, redirectPath) +} diff --git a/database/migration.go b/database/migration.go index efd3aba..a99b57c 100644 --- a/database/migration.go +++ b/database/migration.go @@ -13,6 +13,7 @@ func migrate() { &mail.User{}, &mail.Mailbox{}, &mail.Alias{}, + &mail.Folder{}, &mail.Email{}, &mail.Tag{}, &mail.Attachment{}, diff --git a/models/mail/email.go b/models/mail/email.go index 4b8b55e..b0cc27e 100644 --- a/models/mail/email.go +++ b/models/mail/email.go @@ -6,6 +6,9 @@ type Email struct { gorm.Model MailboxID uint `gorm:"not null;index" json:"mailbox_id"` Mailbox Mailbox `gorm:"foreignKey:MailboxID" json:"mailbox"` + FolderID uint `gorm:"not null;index" json:"folder_id"` + Folder Folder `gorm:"foreignKey:FolderID" json:"folder"` + IsStarred bool `gorm:"default:false;index" json:"is_starred"` MessageID string `gorm:"index" json:"message_id"` Filename string `gorm:"uniqueIndex;not null" json:"filename"` FromAddress string `gorm:"not null" json:"from_address"` diff --git a/models/mail/folder.go b/models/mail/folder.go new file mode 100644 index 0000000..3c46bec --- /dev/null +++ b/models/mail/folder.go @@ -0,0 +1,13 @@ +package mail + +import "gorm.io/gorm" + +type Folder struct { + gorm.Model + Name string `gorm:"not null" json:"name"` + Slug string `gorm:"not null;index" json:"slug"` + MailboxID uint `gorm:"not null;index" json:"mailbox_id"` + Mailbox Mailbox `gorm:"foreignKey:MailboxID" json:"mailbox"` + IsSystem bool `gorm:"not null;default:false" json:"is_system"` + Position int `gorm:"not null;default:0" json:"position"` +} diff --git a/pages/mail/mailbox.go b/pages/mail/mailbox.go deleted file mode 100644 index 81f525b..0000000 --- a/pages/mail/mailbox.go +++ /dev/null @@ -1,17 +0,0 @@ -package mail - -import ( - mailService "dove/services/mail" - "dove/utils/meta" - "dove/utils/shortcuts" - - "github.com/gofiber/fiber/v2" -) - -func Mailbox(context *fiber.Ctx) error { - address := meta.Request(context).Param("address") - meta.SetPageTitle(context, address) - return shortcuts.Render(context, "mail/mailbox", mailService.MailboxView{ - Address: address, - }) -} diff --git a/pages/mail/webmail.go b/pages/mail/webmail.go new file mode 100644 index 0000000..8018072 --- /dev/null +++ b/pages/mail/webmail.go @@ -0,0 +1,155 @@ +package mail + +import ( + "strconv" + + mailService "dove/services/mail" + "dove/utils/meta" + "dove/utils/shortcuts" + + "github.com/gofiber/fiber/v2" +) + +func WebMail(context *fiber.Ctx) error { + meta.SetPageTitle(context, "WebMail") + + mailboxIDParam := meta.Request(context).Param("mailbox_id") + var mailboxID uint + if mailboxIDParam != "" { + parsed, _ := strconv.ParseUint(mailboxIDParam, 10, 64) + mailboxID = uint(parsed) + } + + folderSlug := context.Query("folder", "inbox") + search := context.Query("search") + pagination := meta.Paginate(context) + sorting := meta.Sort(context, []string{"created_at", "from_address", "subject"}, "created_at") + + data, serviceError := mailService.WebMailData(mailboxID, folderSlug, search, pagination, sorting) + if serviceError != nil { + return shortcuts.HandleError(context, serviceError) + } + + return shortcuts.Render(context, "mail/webmail", data) +} + +func WebMailEmails(context *fiber.Ctx) error { + folderIDParam := meta.Request(context).Param("folder_id") + folderID, parseError := strconv.ParseUint(folderIDParam, 10, 64) + if parseError != nil { + return shortcuts.BadRequestError(context, parseError) + } + + search := context.Query("search") + pagination := meta.Paginate(context) + sorting := meta.Sort(context, []string{"created_at", "from_address", "subject"}, "created_at") + + data := mailService.EmailListData(uint(folderID), search, pagination, sorting) + return context.Render("mail/webmail/emails", fiber.Map{ + "emails": data.Emails, + "total_emails": data.TotalEmails, + "search": data.Search, + }) +} + +func WebMailStarredEmails(context *fiber.Ctx) error { + mailboxIDParam := meta.Request(context).Param("mailbox_id") + mailboxID, parseError := strconv.ParseUint(mailboxIDParam, 10, 64) + if parseError != nil { + return shortcuts.BadRequestError(context, parseError) + } + + search := context.Query("search") + pagination := meta.Paginate(context) + sorting := meta.Sort(context, []string{"created_at", "from_address", "subject"}, "created_at") + + data := mailService.StarredEmailListData(uint(mailboxID), search, pagination, sorting) + return context.Render("mail/webmail/emails", fiber.Map{ + "emails": data.Emails, + "total_emails": data.TotalEmails, + "search": data.Search, + }) +} + +func WebMailPreview(context *fiber.Ctx) error { + emailIDParam := meta.Request(context).Param("email_id") + emailID, parseError := strconv.ParseUint(emailIDParam, 10, 64) + if parseError != nil { + return shortcuts.BadRequestError(context, parseError) + } + + mailboxIDParam := meta.Request(context).Param("mailbox_id") + mailboxID, _ := strconv.ParseUint(mailboxIDParam, 10, 64) + + wasUnread := mailService.IsEmailUnread(uint(emailID)) + + data, serviceError := mailService.EmailPreviewData(uint(emailID), uint(mailboxID)) + if serviceError != nil { + return shortcuts.HandleError(context, serviceError) + } + + if wasUnread { + context.Set("HX-Trigger", "emailReadChanged") + } + + return context.Render("mail/webmail/preview", fiber.Map{ + "email": data.Email, + "email_body": data.EmailBody, + "active_mailbox": data.ActiveMailbox, + "folders": data.Folders, + "folder_slug": data.Email.Folder.Slug, + }) +} + +func WebMailFolders(context *fiber.Ctx) error { + mailboxIDParam := meta.Request(context).Param("mailbox_id") + mailboxID, parseError := strconv.ParseUint(mailboxIDParam, 10, 64) + if parseError != nil { + return shortcuts.BadRequestError(context, parseError) + } + + folderSlug := context.Query("folder", "inbox") + + data, serviceError := mailService.FolderSidebarData(uint(mailboxID), folderSlug) + if serviceError != nil { + return shortcuts.HandleError(context, serviceError) + } + + return context.Render("mail/webmail/folders", fiber.Map{ + "folders": data.Folders, + "active_folder": data.ActiveFolder, + "active_mailbox": data.ActiveMailbox, + "is_starred_view": data.IsStarredView, + "starred_count": data.StarredCount, + }) +} + +func WebMailCompose(context *fiber.Ctx) error { + mailboxIDParam := meta.Request(context).Param("mailbox_id") + var mailboxID uint + if mailboxIDParam != "" { + parsed, _ := strconv.ParseUint(mailboxIDParam, 10, 64) + mailboxID = uint(parsed) + } + + replyToParam := context.Query("reply_to") + var replyToID uint + if replyToParam != "" { + parsed, _ := strconv.ParseUint(replyToParam, 10, 64) + replyToID = uint(parsed) + } + + data, serviceError := mailService.ComposeData(mailboxID, replyToID) + if serviceError != nil { + return shortcuts.HandleError(context, serviceError) + } + + return context.Render("mail/webmail/compose", fiber.Map{ + "mailboxes": data.Mailboxes, + "active_mailbox": data.ActiveMailbox, + "from_addresses": data.FromAddresses, + "all_recipients": data.AllRecipients, + "draft_id": data.DraftID, + "reply_to": data.ReplyTo, + }) +} diff --git a/repositories/mail/email.go b/repositories/mail/email.go index 3a80b92..87ed36a 100644 --- a/repositories/mail/email.go +++ b/repositories/mail/email.go @@ -48,3 +48,119 @@ func ListEmailsByMailbox(mailboxID uint, pagination meta.Pagination, sorting met return emails, total } + +func ListEmailsByFolder(folderID uint, pagination meta.Pagination, sorting meta.Sorting, search string) ([]mail.Email, int64) { + var emails []mail.Email + var total int64 + + query := database.DB.Model(&mail.Email{}).Where("folder_id = ?", folderID) + + if search != "" { + like := "%" + search + "%" + query = query.Where("from_address LIKE ? OR to_addresses LIKE ? OR subject LIKE ?", like, like, like) + } + + query.Count(&total) + pagination.Apply(sorting.Apply(query)).Preload("Tags").Find(&emails) + + return emails, total +} + +func ListStarredEmails(mailboxID uint, pagination meta.Pagination, sorting meta.Sorting, search string) ([]mail.Email, int64) { + var emails []mail.Email + var total int64 + + query := database.DB.Model(&mail.Email{}).Where("mailbox_id = ? AND is_starred = ?", mailboxID, true) + + if search != "" { + like := "%" + search + "%" + query = query.Where("from_address LIKE ? OR to_addresses LIKE ? OR subject LIKE ?", like, like, like) + } + + query.Count(&total) + pagination.Apply(sorting.Apply(query)).Preload("Tags").Find(&emails) + + return emails, total +} + +func FindEmailByID(emailID uint) *mail.Email { + var email mail.Email + result := database.DB.Preload("Tags").First(&email, emailID) + if result.Error != nil { + return nil + } + return &email +} + +func FindEmailByIDWithRelations(emailID uint) *mail.Email { + var email mail.Email + result := database.DB.Preload("Tags").Preload("Mailbox").Preload("Folder").First(&email, emailID) + if result.Error != nil { + return nil + } + return &email +} + +func UpdateEmail(email *mail.Email) error { + return database.DB.Save(email).Error +} + +func DeleteEmail(email *mail.Email) error { + return database.DB.Delete(email).Error +} + +func MarkEmailAsRead(emailID uint) error { + return database.DB.Model(&mail.Email{}).Where("id = ?", emailID).Update("is_read", true).Error +} + +func MarkEmailAsUnread(emailID uint) error { + return database.DB.Model(&mail.Email{}).Where("id = ?", emailID).Update("is_read", false).Error +} + +func ToggleEmailStar(emailID uint) error { + return database.DB.Model(&mail.Email{}).Where("id = ?", emailID).Update("is_starred", database.DB.Raw("NOT is_starred")).Error +} + +func MoveEmailToFolder(emailID uint, folderID uint) error { + return database.DB.Model(&mail.Email{}).Where("id = ?", emailID).Update("folder_id", folderID).Error +} + +func BulkMoveEmails(emailIDs []uint, folderID uint) error { + return database.DB.Model(&mail.Email{}).Where("id IN ?", emailIDs).Update("folder_id", folderID).Error +} + +func BulkMarkAsRead(emailIDs []uint) error { + return database.DB.Model(&mail.Email{}).Where("id IN ?", emailIDs).Update("is_read", true).Error +} + +func BulkMarkAsUnread(emailIDs []uint) error { + return database.DB.Model(&mail.Email{}).Where("id IN ?", emailIDs).Update("is_read", false).Error +} + +func BulkDeleteEmails(emailIDs []uint) error { + return database.DB.Where("id IN ?", emailIDs).Delete(&mail.Email{}).Error +} + +func CountUnreadByFolderID(folderID uint) int64 { + var count int64 + database.DB.Model(&mail.Email{}).Where("folder_id = ? AND is_read = ?", folderID, false).Count(&count) + return count +} + +func CountEmailsByFolderID(folderID uint) int64 { + var count int64 + database.DB.Model(&mail.Email{}).Where("folder_id = ?", folderID).Count(&count) + return count +} + +func CountStarredByMailboxID(mailboxID uint) int64 { + var count int64 + database.DB.Model(&mail.Email{}).Where("mailbox_id = ? AND is_starred = ?", mailboxID, true).Count(&count) + return count +} + +func AllMailboxes() []mail.Mailbox { + var mailboxes []mail.Mailbox + database.DB.Preload("User").Preload("Domain").Preload("Domain.TLD").Preload("Aliases").Order("address ASC").Find(&mailboxes) + return mailboxes +} diff --git a/repositories/mail/folder.go b/repositories/mail/folder.go new file mode 100644 index 0000000..6870e41 --- /dev/null +++ b/repositories/mail/folder.go @@ -0,0 +1,80 @@ +package mail + +import ( + "dove/database" + "dove/models/mail" +) + +var SystemFolders = []struct { + Name string + Slug string + Position int +}{ + {Name: "Inbox", Slug: "inbox", Position: 0}, + {Name: "Sent", Slug: "sent", Position: 1}, + {Name: "Drafts", Slug: "drafts", Position: 2}, + {Name: "Spam", Slug: "spam", Position: 3}, + {Name: "Trash", Slug: "trash", Position: 4}, +} + +func SeedFoldersForMailbox(mailboxID uint) error { + for _, systemFolder := range SystemFolders { + folder := &mail.Folder{ + Name: systemFolder.Name, + Slug: systemFolder.Slug, + MailboxID: mailboxID, + IsSystem: true, + Position: systemFolder.Position, + } + + if createError := database.DB.Create(folder).Error; createError != nil { + return createError + } + } + + return nil +} + +func FindFoldersByMailboxID(mailboxID uint) []mail.Folder { + var folders []mail.Folder + database.DB.Where("mailbox_id = ?", mailboxID).Order("position ASC, name ASC").Find(&folders) + return folders +} + +func FindFolderByID(folderID uint) *mail.Folder { + var folder mail.Folder + result := database.DB.First(&folder, folderID) + if result.Error != nil { + return nil + } + return &folder +} + +func FindFolderBySlug(mailboxID uint, slug string) *mail.Folder { + var folder mail.Folder + result := database.DB.Where("mailbox_id = ? AND slug = ?", mailboxID, slug).First(&folder) + if result.Error != nil { + return nil + } + return &folder +} + +func CreateFolder(folder *mail.Folder) error { + return database.DB.Create(folder).Error +} + +func DeleteFolder(folder *mail.Folder) error { + return database.DB.Delete(folder).Error +} + +func CountFoldersByMailboxID(mailboxID uint) int64 { + var count int64 + database.DB.Model(&mail.Folder{}).Where("mailbox_id = ? AND is_system = ?", mailboxID, false).Count(&count) + return count +} + +func MaxFolderPosition(mailboxID uint) int { + var maxPosition int + database.DB.Model(&mail.Folder{}).Where("mailbox_id = ?", mailboxID).Select("COALESCE(MAX(position), 0)").Scan(&maxPosition) + return maxPosition +} diff --git a/router/mail.go b/router/mail.go index 3833052..68838ef 100644 --- a/router/mail.go +++ b/router/mail.go @@ -16,7 +16,6 @@ func init() { urls.Path(urls.Post, "/mailboxes", auth.RequireAuthentication(mailController.CreateMailbox), "mailboxes.create") urls.Path(urls.Get, "/mailboxes/:id/edit", auth.RequireAuthentication(mailPage.EditMailbox), "mailboxes.edit") urls.Path(urls.Put, "/mailboxes/:id", auth.RequireAuthentication(mailController.UpdateMailbox), "mailboxes.update") - urls.Path(urls.Get, "/mailboxes/:address", auth.RequireAuthentication(mailPage.Mailbox), "mailbox") urls.Path(urls.Get, "/users", auth.RequireAuthentication(mailPage.Users), "users") urls.Path(urls.Get, "/users/new", auth.RequireAuthentication(mailPage.NewUser), "users.new") urls.Path(urls.Post, "/users", auth.RequireAuthentication(mailController.CreateUser), "users.create") @@ -26,4 +25,21 @@ func init() { urls.Path(urls.Delete, "/mailboxes/:id", auth.RequireAuthentication(mailController.DeleteMailbox), "mailboxes.delete") urls.Path(urls.Post, "/mailboxes/:id/aliases", auth.RequireAuthentication(mailController.CreateAlias), "aliases.create") urls.Path(urls.Delete, "/mailboxes/:id/aliases/:alias_id", auth.RequireAuthentication(mailController.DeleteAlias), "aliases.delete") -} \ No newline at end of file + urls.Path(urls.Get, "/webmail", auth.RequireAuthentication(mailPage.WebMail), "webmail") + urls.Path(urls.Get, "/webmail/:mailbox_id", auth.RequireAuthentication(mailPage.WebMail), "webmail.mailbox") + urls.Path(urls.Get, "/webmail/:mailbox_id/folders", auth.RequireAuthentication(mailPage.WebMailFolders), "webmail.folders") + urls.Path(urls.Get, "/webmail/:mailbox_id/folder/:folder_id/emails", auth.RequireAuthentication(mailPage.WebMailEmails), "webmail.folder.emails") + urls.Path(urls.Get, "/webmail/:mailbox_id/starred/emails", auth.RequireAuthentication(mailPage.WebMailStarredEmails), "webmail.starred.emails") + urls.Path(urls.Get, "/webmail/:mailbox_id/email/:email_id", auth.RequireAuthentication(mailPage.WebMailPreview), "webmail.email") + urls.Path(urls.Get, "/webmail/:mailbox_id/compose", auth.RequireAuthentication(mailPage.WebMailCompose), "webmail.compose") + urls.Path(urls.Post, "/webmail/send", auth.RequireAuthentication(mailController.SendEmail), "webmail.send") + urls.Path(urls.Post, "/webmail/draft", auth.RequireAuthentication(mailController.SaveDraft), "webmail.draft") + urls.Path(urls.Put, "/webmail/email/:email_id/star", auth.RequireAuthentication(mailController.ToggleStar), "webmail.email.star") + urls.Path(urls.Put, "/webmail/email/:email_id/read", auth.RequireAuthentication(mailController.MarkReadEmail), "webmail.email.read") + urls.Path(urls.Put, "/webmail/email/:email_id/unread", auth.RequireAuthentication(mailController.MarkUnreadEmail), "webmail.email.unread") + urls.Path(urls.Put, "/webmail/email/:email_id/move", auth.RequireAuthentication(mailController.MoveEmailToFolder), "webmail.email.move") + urls.Path(urls.Delete, "/webmail/:mailbox_id/email/:email_id", auth.RequireAuthentication(mailController.TrashEmailAction), "webmail.email.delete") + urls.Path(urls.Post, "/webmail/:mailbox_id/bulk", auth.RequireAuthentication(mailController.BulkEmailAction), "webmail.bulk") + urls.Path(urls.Post, "/webmail/:mailbox_id/folders", auth.RequireAuthentication(mailController.CreateWebMailFolder), "webmail.folder.create") + urls.Path(urls.Delete, "/webmail/:mailbox_id/folder/:folder_id", auth.RequireAuthentication(mailController.DeleteWebMailFolder), "webmail.folder.delete") +} diff --git a/services/mail/mailboxes.go b/services/mail/mailboxes.go index 811e6bc..d940907 100644 --- a/services/mail/mailboxes.go +++ b/services/mail/mailboxes.go @@ -34,10 +34,6 @@ type EditMailboxFormResponse struct { Domains []domainModel.Domain `json:"domains"` } -type MailboxView struct { - Address string -} - func ListMailboxes(pagination meta.Pagination, sorting meta.Sorting, search string) meta.PaginatedResponse { mailboxes, total := mailRepo.ListMailboxes(pagination, sorting, search) return pagination.Response(mailboxes, total) @@ -89,6 +85,10 @@ func CreateMailbox(request CreateMailboxRequest) *shortcuts.Error { return shortcuts.ServiceError(shortcuts.Internal, CreationFailed) } + if seedError := mailRepo.SeedFoldersForMailbox(mailbox.ID); seedError != nil { + return shortcuts.ServiceError(shortcuts.Internal, FolderSeedFailed) + } + return nil } diff --git a/services/mail/messages.go b/services/mail/messages.go index 118b630..530ea45 100644 --- a/services/mail/messages.go +++ b/services/mail/messages.go @@ -20,6 +20,31 @@ const ( UserNotFound = "The selected user does not exist." UserRequired = "A user must be selected for the mailbox." + EmailNotFound = "Email not found." + EmailMoveFailed = "Failed to move email." + EmailDeleteFailed = "Failed to delete email." + EmailSendFailed = "Failed to send email." + EmailDraftFailed = "Failed to save draft." + EmailUpdateFailed = "Failed to update email." + EmailStorageFailed = "Failed to store email file." + EmailFileReadFailed = "Failed to read email content." + + LogPrefix = "mail" + + FolderCreationFailed = "Failed to create folder." + FolderDeletionFailed = "Failed to delete folder." + FolderNameRequired = "Folder name is required." + FolderNotFound = "Folder not found." + FolderSeedFailed = "Failed to create default folders." + FolderIsSystem = "System folders cannot be deleted." + FolderHasEmails = "Cannot delete a folder that contains emails. Move or delete the emails first." + + NoMailboxesExist = "No mailboxes exist. Create a mailbox first." + RecipientRequired = "A recipient address is required." + RecipientNotFound = "The recipient mailbox does not exist." + SenderRequired = "A sender mailbox must be selected." + SubjectRequired = "Subject is required." + DisplayNameRequired = "Display name is required." UserAlreadyExists = "A user with this username already exists." UserCreationFailed = "Failed to create user." diff --git a/services/mail/webmail.go b/services/mail/webmail.go new file mode 100644 index 0000000..5dbef75 --- /dev/null +++ b/services/mail/webmail.go @@ -0,0 +1,700 @@ +package mail + +import ( + "fmt" + "regexp" + "strings" + "time" + + mailModel "dove/models/mail" + mailRepo "dove/repositories/mail" + "dove/utils/logger" + "dove/utils/meta" + "dove/utils/shortcuts" + "dove/utils/storage" +) + +type FolderWithCount struct { + mailModel.Folder + UnreadCount int64 `json:"unread_count"` +} + +type WebMailResponse struct { + Mailboxes []mailModel.Mailbox `json:"mailboxes"` + ActiveMailbox mailModel.Mailbox `json:"active_mailbox"` + Folders []FolderWithCount `json:"folders"` + ActiveFolder FolderWithCount `json:"active_folder"` + Emails []mailModel.Email `json:"emails"` + TotalEmails int64 `json:"total_emails"` + ActiveEmail *mailModel.Email `json:"active_email"` + IsStarredView bool `json:"is_starred_view"` + StarredCount int64 `json:"starred_count"` + Search string `json:"search"` +} + +type EmailListResponse struct { + Emails []mailModel.Email `json:"emails"` + TotalEmails int64 `json:"total_emails"` + Search string `json:"search"` +} + +type EmailPreviewResponse struct { + Email mailModel.Email `json:"email"` + EmailBody string `json:"email_body"` + ActiveMailbox mailModel.Mailbox `json:"active_mailbox"` + Folders []mailModel.Folder `json:"folders"` +} + +type ComposeAddress struct { + Address string `json:"address"` + DisplayName string `json:"display_name"` + MailboxID uint `json:"mailbox_id"` +} + +type ComposeResponse struct { + Mailboxes []mailModel.Mailbox `json:"mailboxes"` + ActiveMailbox mailModel.Mailbox `json:"active_mailbox"` + FromAddresses []ComposeAddress `json:"from_addresses"` + AllRecipients []ComposeAddress `json:"all_recipients"` + DraftID uint `json:"draft_id"` + ReplyTo *mailModel.Email `json:"reply_to"` +} + +type SendEmailRequest struct { + FromMailboxID uint `form:"from_mailbox_id"` + ToAddress string `form:"to_address"` + CcAddresses string `form:"cc_addresses"` + BccAddresses string `form:"bcc_addresses"` + Subject string `form:"subject"` + Body string `form:"body"` + DraftID uint `form:"draft_id"` +} + +type MoveEmailRequest struct { + FolderID uint `form:"folder_id"` +} + +type BulkActionRequest struct { + EmailIDs []uint `form:"email_ids"` + Action string `form:"action"` + FolderID uint `form:"folder_id"` +} + +type CreateFolderRequest struct { + Name string `form:"name"` + MailboxID uint +} + +func WebMailData(mailboxID uint, folderSlug string, search string, pagination meta.Pagination, sorting meta.Sorting) (*WebMailResponse, *shortcuts.Error) { + mailboxes := mailRepo.AllMailboxes() + if len(mailboxes) == 0 { + return nil, shortcuts.ServiceError(shortcuts.NotFound, NoMailboxesExist) + } + + var activeMailbox *mailModel.Mailbox + if mailboxID > 0 { + for index := range mailboxes { + if mailboxes[index].ID == mailboxID { + activeMailbox = &mailboxes[index] + break + } + } + } + + if activeMailbox == nil { + activeMailbox = &mailboxes[0] + } + + folders := mailRepo.FindFoldersByMailboxID(activeMailbox.ID) + foldersWithCounts := buildFolderCounts(folders) + + isStarredView := folderSlug == "starred" + + var activeFolder *FolderWithCount + if !isStarredView { + for index := range foldersWithCounts { + if foldersWithCounts[index].Slug == folderSlug { + activeFolder = &foldersWithCounts[index] + break + } + } + + if activeFolder == nil && len(foldersWithCounts) > 0 { + activeFolder = &foldersWithCounts[0] + } + } + + var emails []mailModel.Email + var totalEmails int64 + + if isStarredView { + emails, totalEmails = mailRepo.ListStarredEmails(activeMailbox.ID, pagination, sorting, search) + } else if activeFolder != nil { + emails, totalEmails = mailRepo.ListEmailsByFolder(activeFolder.ID, pagination, sorting, search) + } + + starredCount := mailRepo.CountStarredByMailboxID(activeMailbox.ID) + + response := &WebMailResponse{ + Mailboxes: mailboxes, + ActiveMailbox: *activeMailbox, + Folders: foldersWithCounts, + Emails: emails, + TotalEmails: totalEmails, + IsStarredView: isStarredView, + StarredCount: starredCount, + Search: search, + } + + if activeFolder != nil { + response.ActiveFolder = *activeFolder + } + + return response, nil +} + +func EmailListData(folderID uint, search string, pagination meta.Pagination, sorting meta.Sorting) EmailListResponse { + emails, total := mailRepo.ListEmailsByFolder(folderID, pagination, sorting, search) + return EmailListResponse{ + Emails: emails, + TotalEmails: total, + Search: search, + } +} + +func StarredEmailListData(mailboxID uint, search string, pagination meta.Pagination, sorting meta.Sorting) EmailListResponse { + emails, total := mailRepo.ListStarredEmails(mailboxID, pagination, sorting, search) + return EmailListResponse{ + Emails: emails, + TotalEmails: total, + Search: search, + } +} + +type FolderSidebarResponse struct { + Folders []FolderWithCount `json:"folders"` + ActiveFolder FolderWithCount `json:"active_folder"` + ActiveMailbox mailModel.Mailbox `json:"active_mailbox"` + IsStarredView bool `json:"is_starred_view"` + StarredCount int64 `json:"starred_count"` +} + +func FolderSidebarData(mailboxID uint, folderSlug string) (*FolderSidebarResponse, *shortcuts.Error) { + mailbox := mailRepo.FindMailboxByIDWithRelations(mailboxID) + if mailbox == nil { + return nil, shortcuts.ServiceError(shortcuts.NotFound, MailboxNotFound) + } + + folders := mailRepo.FindFoldersByMailboxID(mailboxID) + foldersWithCounts := buildFolderCounts(folders) + + isStarredView := folderSlug == "starred" + starredCount := mailRepo.CountStarredByMailboxID(mailboxID) + + response := &FolderSidebarResponse{ + Folders: foldersWithCounts, + ActiveMailbox: *mailbox, + IsStarredView: isStarredView, + StarredCount: starredCount, + } + + if !isStarredView { + for index := range foldersWithCounts { + if foldersWithCounts[index].Slug == folderSlug { + response.ActiveFolder = foldersWithCounts[index] + break + } + } + } + + return response, nil +} + +func IsEmailUnread(emailID uint) bool { + email := mailRepo.FindEmailByID(emailID) + return email != nil && !email.IsRead +} + +func EmailPreviewData(emailID uint, mailboxID uint) (*EmailPreviewResponse, *shortcuts.Error) { + email := mailRepo.FindEmailByIDWithRelations(emailID) + if email == nil { + return nil, shortcuts.ServiceError(shortcuts.NotFound, EmailNotFound) + } + + if !email.IsRead { + mailRepo.MarkEmailAsRead(emailID) + email.IsRead = true + } + + mailbox := mailRepo.FindMailboxByIDWithRelations(mailboxID) + if mailbox == nil { + return nil, shortcuts.ServiceError(shortcuts.NotFound, MailboxNotFound) + } + + folders := mailRepo.FindFoldersByMailboxID(mailboxID) + + emailBody := readEmailBody(email.Mailbox.Address, email.Folder.Slug, email.Filename) + + return &EmailPreviewResponse{ + Email: *email, + EmailBody: emailBody, + ActiveMailbox: *mailbox, + Folders: folders, + }, nil +} + +func ComposeData(mailboxID uint, replyToEmailID uint) (*ComposeResponse, *shortcuts.Error) { + mailboxes := mailRepo.AllMailboxes() + if len(mailboxes) == 0 { + return nil, shortcuts.ServiceError(shortcuts.NotFound, NoMailboxesExist) + } + + var activeMailbox *mailModel.Mailbox + for index := range mailboxes { + if mailboxes[index].ID == mailboxID { + activeMailbox = &mailboxes[index] + break + } + } + + if activeMailbox == nil { + activeMailbox = &mailboxes[0] + } + + fromAddresses := buildFromAddresses(activeMailbox, mailboxes) + allRecipients := buildAllRecipients(mailboxes) + + response := &ComposeResponse{ + Mailboxes: mailboxes, + ActiveMailbox: *activeMailbox, + FromAddresses: fromAddresses, + AllRecipients: allRecipients, + } + + if replyToEmailID > 0 { + replyEmail := mailRepo.FindEmailByID(replyToEmailID) + if replyEmail != nil { + response.ReplyTo = replyEmail + } + } + + return response, nil +} + +func buildFromAddresses(activeMailbox *mailModel.Mailbox, allMailboxes []mailModel.Mailbox) []ComposeAddress { + var fromAddresses []ComposeAddress + + for _, mailbox := range allMailboxes { + if mailbox.UserID != activeMailbox.UserID { + continue + } + + fromAddresses = append(fromAddresses, ComposeAddress{ + Address: mailbox.Address, + DisplayName: mailbox.User.DisplayName, + MailboxID: mailbox.ID, + }) + + for _, alias := range mailbox.Aliases { + fromAddresses = append(fromAddresses, ComposeAddress{ + Address: alias.SourceAddress, + DisplayName: mailbox.User.DisplayName, + MailboxID: mailbox.ID, + }) + } + } + + return fromAddresses +} + +func buildAllRecipients(allMailboxes []mailModel.Mailbox) []ComposeAddress { + var recipients []ComposeAddress + + for _, mailbox := range allMailboxes { + recipients = append(recipients, ComposeAddress{ + Address: mailbox.Address, + DisplayName: mailbox.User.DisplayName, + MailboxID: mailbox.ID, + }) + + for _, alias := range mailbox.Aliases { + recipients = append(recipients, ComposeAddress{ + Address: alias.SourceAddress, + DisplayName: mailbox.User.DisplayName, + MailboxID: mailbox.ID, + }) + } + } + + return recipients +} + +func SendEmail(request SendEmailRequest) *shortcuts.Error { + toAddress := strings.TrimSpace(request.ToAddress) + subject := strings.TrimSpace(request.Subject) + body := strings.TrimSpace(request.Body) + + switch { + case request.FromMailboxID == 0: + return shortcuts.ServiceError(shortcuts.BadRequest, SenderRequired) + case toAddress == "": + return shortcuts.ServiceError(shortcuts.BadRequest, RecipientRequired) + case subject == "": + return shortcuts.ServiceError(shortcuts.BadRequest, SubjectRequired) + } + + senderMailbox := mailRepo.FindMailboxByIDWithRelations(request.FromMailboxID) + if senderMailbox == nil { + return shortcuts.ServiceError(shortcuts.NotFound, MailboxNotFound) + } + + recipientMailbox := mailRepo.FindMailboxByAddress(toAddress) + + sentFolder := mailRepo.FindFolderBySlug(senderMailbox.ID, "sent") + if sentFolder == nil { + return shortcuts.ServiceError(shortcuts.Internal, FolderNotFound) + } + + messageID := generateMessageID(senderMailbox.Address) + filename := generateFilename() + + sentFilename := filename + "-sent" + sentEmail := &mailModel.Email{ + MailboxID: senderMailbox.ID, + FolderID: sentFolder.ID, + MessageID: messageID, + Filename: sentFilename, + FromAddress: senderMailbox.Address, + FromName: senderMailbox.User.DisplayName, + ToAddresses: toAddress, + CcAddresses: strings.TrimSpace(request.CcAddresses), + Subject: subject, + Snippet: truncateSnippet(body), + Size: int64(len(body)), + IsRead: true, + } + + if createError := mailRepo.CreateEmail(sentEmail); createError != nil { + return shortcuts.ServiceError(shortcuts.Internal, EmailSendFailed) + } + + if writeError := storage.WriteMailFile(senderMailbox.Address, "sent", sentFilename, []byte(body)); writeError != nil { + logger.Warnf(LogPrefix, EmailStorageFailed, writeError) + } + + if recipientMailbox != nil { + inboxFolder := mailRepo.FindFolderBySlug(recipientMailbox.ID, "inbox") + if inboxFolder != nil { + recvFilename := filename + "-recv" + receivedEmail := &mailModel.Email{ + MailboxID: recipientMailbox.ID, + FolderID: inboxFolder.ID, + MessageID: messageID, + Filename: recvFilename, + FromAddress: senderMailbox.Address, + FromName: senderMailbox.User.DisplayName, + ToAddresses: toAddress, + CcAddresses: strings.TrimSpace(request.CcAddresses), + Subject: subject, + Snippet: truncateSnippet(body), + Size: int64(len(body)), + IsRead: false, + } + + if createError := mailRepo.CreateEmail(receivedEmail); createError == nil { + if writeError := storage.WriteMailFile(recipientMailbox.Address, "inbox", recvFilename, []byte(body)); writeError != nil { + logger.Warnf(LogPrefix, EmailStorageFailed, writeError) + } + } + } + } + + if request.DraftID > 0 { + deleteDraftWithFile(request.DraftID, senderMailbox.Address) + } + + return nil +} + +func SaveDraft(request SendEmailRequest) (*mailModel.Email, *shortcuts.Error) { + if request.FromMailboxID == 0 { + return nil, shortcuts.ServiceError(shortcuts.BadRequest, SenderRequired) + } + + senderMailbox := mailRepo.FindMailboxByIDWithRelations(request.FromMailboxID) + if senderMailbox == nil { + return nil, shortcuts.ServiceError(shortcuts.NotFound, MailboxNotFound) + } + + draftsFolder := mailRepo.FindFolderBySlug(senderMailbox.ID, "drafts") + if draftsFolder == nil { + return nil, shortcuts.ServiceError(shortcuts.Internal, FolderNotFound) + } + + body := strings.TrimSpace(request.Body) + + if request.DraftID > 0 { + existingDraft := mailRepo.FindEmailByID(request.DraftID) + if existingDraft != nil { + existingDraft.ToAddresses = strings.TrimSpace(request.ToAddress) + existingDraft.CcAddresses = strings.TrimSpace(request.CcAddresses) + existingDraft.Subject = strings.TrimSpace(request.Subject) + existingDraft.Snippet = truncateSnippet(body) + existingDraft.Size = int64(len(body)) + + if updateError := mailRepo.UpdateEmail(existingDraft); updateError != nil { + return nil, shortcuts.ServiceError(shortcuts.Internal, EmailDraftFailed) + } + + if writeError := storage.WriteMailFile(senderMailbox.Address, "drafts", existingDraft.Filename, []byte(body)); writeError != nil { + logger.Warnf(LogPrefix, EmailStorageFailed, writeError) + } + + return existingDraft, nil + } + } + + draftFilename := generateFilename() + "-draft" + draft := &mailModel.Email{ + MailboxID: senderMailbox.ID, + FolderID: draftsFolder.ID, + MessageID: generateMessageID(senderMailbox.Address), + Filename: draftFilename, + FromAddress: senderMailbox.Address, + FromName: senderMailbox.User.DisplayName, + ToAddresses: strings.TrimSpace(request.ToAddress), + CcAddresses: strings.TrimSpace(request.CcAddresses), + Subject: strings.TrimSpace(request.Subject), + Snippet: truncateSnippet(body), + Size: int64(len(body)), + IsRead: true, + } + + if createError := mailRepo.CreateEmail(draft); createError != nil { + return nil, shortcuts.ServiceError(shortcuts.Internal, EmailDraftFailed) + } + + if writeError := storage.WriteMailFile(senderMailbox.Address, "drafts", draftFilename, []byte(body)); writeError != nil { + logger.Warnf(LogPrefix, EmailStorageFailed, writeError) + } + + return draft, nil +} + +func ToggleStar(emailID uint) *shortcuts.Error { + email := mailRepo.FindEmailByID(emailID) + if email == nil { + return shortcuts.ServiceError(shortcuts.NotFound, EmailNotFound) + } + + email.IsStarred = !email.IsStarred + if updateError := mailRepo.UpdateEmail(email); updateError != nil { + return shortcuts.ServiceError(shortcuts.Internal, EmailUpdateFailed) + } + + return nil +} + +func MarkRead(emailID uint) *shortcuts.Error { + if markError := mailRepo.MarkEmailAsRead(emailID); markError != nil { + return shortcuts.ServiceError(shortcuts.Internal, EmailUpdateFailed) + } + return nil +} + +func MarkUnread(emailID uint) *shortcuts.Error { + if markError := mailRepo.MarkEmailAsUnread(emailID); markError != nil { + return shortcuts.ServiceError(shortcuts.Internal, EmailUpdateFailed) + } + return nil +} + +func MoveEmail(emailID uint, request MoveEmailRequest) *shortcuts.Error { + email := mailRepo.FindEmailByIDWithRelations(emailID) + if email == nil { + return shortcuts.ServiceError(shortcuts.NotFound, EmailNotFound) + } + + targetFolder := mailRepo.FindFolderByID(request.FolderID) + if targetFolder == nil { + return shortcuts.ServiceError(shortcuts.NotFound, FolderNotFound) + } + + sourceFolderSlug := email.Folder.Slug + + if moveError := mailRepo.MoveEmailToFolder(emailID, request.FolderID); moveError != nil { + return shortcuts.ServiceError(shortcuts.Internal, EmailMoveFailed) + } + + if moveFileError := storage.MoveMailFile(email.Mailbox.Address, sourceFolderSlug, targetFolder.Slug, email.Filename); moveFileError != nil { + logger.Warnf(LogPrefix, EmailStorageFailed, moveFileError) + } + + return nil +} + +func TrashEmail(emailID uint, mailboxID uint) *shortcuts.Error { + email := mailRepo.FindEmailByIDWithRelations(emailID) + if email == nil { + return shortcuts.ServiceError(shortcuts.NotFound, EmailNotFound) + } + + trashFolder := mailRepo.FindFolderBySlug(mailboxID, "trash") + if trashFolder == nil { + return shortcuts.ServiceError(shortcuts.Internal, FolderNotFound) + } + + sourceFolderSlug := email.Folder.Slug + + if email.FolderID == trashFolder.ID { + if deleteError := mailRepo.DeleteEmail(email); deleteError != nil { + return shortcuts.ServiceError(shortcuts.Internal, EmailDeleteFailed) + } + storage.DeleteMailFile(email.Mailbox.Address, sourceFolderSlug, email.Filename) + return nil + } + + if moveError := mailRepo.MoveEmailToFolder(emailID, trashFolder.ID); moveError != nil { + return shortcuts.ServiceError(shortcuts.Internal, EmailMoveFailed) + } + + if moveFileError := storage.MoveMailFile(email.Mailbox.Address, sourceFolderSlug, "trash", email.Filename); moveFileError != nil { + logger.Warnf(LogPrefix, EmailStorageFailed, moveFileError) + } + + return nil +} + +func BulkAction(request BulkActionRequest, mailboxID uint) *shortcuts.Error { + if len(request.EmailIDs) == 0 { + return nil + } + + switch request.Action { + case "read": + mailRepo.BulkMarkAsRead(request.EmailIDs) + case "unread": + mailRepo.BulkMarkAsUnread(request.EmailIDs) + case "move": + if request.FolderID == 0 { + return shortcuts.ServiceError(shortcuts.BadRequest, FolderNotFound) + } + mailRepo.BulkMoveEmails(request.EmailIDs, request.FolderID) + case "trash": + trashFolder := mailRepo.FindFolderBySlug(mailboxID, "trash") + if trashFolder == nil { + return shortcuts.ServiceError(shortcuts.Internal, FolderNotFound) + } + mailRepo.BulkMoveEmails(request.EmailIDs, trashFolder.ID) + case "delete": + mailRepo.BulkDeleteEmails(request.EmailIDs) + } + + return nil +} + +func CreateFolder(request CreateFolderRequest) *shortcuts.Error { + folderName := strings.TrimSpace(request.Name) + if folderName == "" { + return shortcuts.ServiceError(shortcuts.BadRequest, FolderNameRequired) + } + + slug := strings.ToLower(strings.ReplaceAll(folderName, " ", "-")) + + existing := mailRepo.FindFolderBySlug(request.MailboxID, slug) + if existing != nil { + return shortcuts.ServiceError(shortcuts.Unprocessable, "A folder with this name already exists.") + } + + nextPosition := mailRepo.MaxFolderPosition(request.MailboxID) + 1 + + folder := &mailModel.Folder{ + Name: folderName, + Slug: slug, + MailboxID: request.MailboxID, + IsSystem: false, + Position: nextPosition, + } + + if createError := mailRepo.CreateFolder(folder); createError != nil { + return shortcuts.ServiceError(shortcuts.Internal, FolderCreationFailed) + } + + return nil +} + +func DeleteFolder(folderID uint) *shortcuts.Error { + folder := mailRepo.FindFolderByID(folderID) + if folder == nil { + return shortcuts.ServiceError(shortcuts.NotFound, FolderNotFound) + } + + if folder.IsSystem { + return shortcuts.ServiceError(shortcuts.Unprocessable, FolderIsSystem) + } + + emailCount := mailRepo.CountEmailsByFolderID(folderID) + if emailCount > 0 { + return shortcuts.ServiceError(shortcuts.Unprocessable, FolderHasEmails) + } + + if deleteError := mailRepo.DeleteFolder(folder); deleteError != nil { + return shortcuts.ServiceError(shortcuts.Internal, FolderDeletionFailed) + } + + return nil +} + +func buildFolderCounts(folders []mailModel.Folder) []FolderWithCount { + foldersWithCounts := make([]FolderWithCount, len(folders)) + for index, folder := range folders { + foldersWithCounts[index] = FolderWithCount{ + Folder: folder, + UnreadCount: mailRepo.CountUnreadByFolderID(folder.ID), + } + } + return foldersWithCounts +} + +func generateMessageID(senderAddress string) string { + parts := strings.Split(senderAddress, "@") + domainPart := "localhost" + if len(parts) > 1 { + domainPart = parts[1] + } + return fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), parts[0], domainPart) +} + +func generateFilename() string { + return fmt.Sprintf("%d", time.Now().UnixNano()) +} + +func readEmailBody(mailboxAddress string, folderSlug string, filename string) string { + content, readError := storage.ReadMailFile(mailboxAddress, folderSlug, filename) + if readError != nil { + logger.Warnf(LogPrefix, EmailFileReadFailed, readError) + return "" + } + return string(content) +} + +func deleteDraftWithFile(draftID uint, senderAddress string) { + draft := mailRepo.FindEmailByIDWithRelations(draftID) + if draft == nil { + return + } + storage.DeleteMailFile(senderAddress, "drafts", draft.Filename) + mailRepo.DeleteEmail(draft) +} + +var htmlTagPattern = regexp.MustCompile(`<[^>]*>`) + +func truncateSnippet(body string) string { + plainText := htmlTagPattern.ReplaceAllString(body, "") + plainText = strings.Join(strings.Fields(plainText), " ") + plainText = strings.TrimSpace(plainText) + if len(plainText) <= 200 { + return plainText + } + return plainText[:200] +} diff --git a/static/css/tailwind.css b/static/css/tailwind.css index 2f948dc..bc7f853 100644 --- a/static/css/tailwind.css +++ b/static/css/tailwind.css @@ -364,3 +364,249 @@ input:-webkit-autofill:focus { -webkit-box-shadow: 0 0 0px 1000px var(--color-surface-800) inset; transition: background-color 5000s ease-in-out 0s; } + +.webmail-btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + border-radius: 0.5rem; + font-size: 0.8125rem; + font-weight: 500; + white-space: nowrap; + transition: all 0.15s ease; + cursor: pointer; + height: 2rem; +} + +.webmail-btn-primary { + background: rgba(99, 102, 241, 0.8); + color: white; + border: 1px solid rgba(129, 140, 248, 0.2); +} + +.webmail-btn-primary:hover { + background: var(--color-accent-500); +} + +.webmail-btn-ghost { + background: transparent; + color: #a1a1aa; + border: 1px solid transparent; +} + +.webmail-btn-ghost:hover { + color: #e4e4e7; + background: rgba(255, 255, 255, 0.04); +} + +.webmail-btn-danger { + background: transparent; + color: #a1a1aa; + border: 1px solid transparent; +} + +.webmail-btn-danger:hover { + color: var(--color-red-400); + background: rgba(239, 68, 68, 0.08); +} + +.webmail-btn-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 0.375rem; + color: #71717a; + transition: all 0.15s ease; + cursor: pointer; +} + +.webmail-btn-icon:hover { + color: #e4e4e7; + background: rgba(255, 255, 255, 0.04); +} + +.webmail-btn-icon.danger:hover { + color: var(--color-red-400); + background: rgba(239, 68, 68, 0.08); +} + +.webmail-compose-field { + width: 100%; + background: transparent; + color: #e4e4e7; + font-size: 0.875rem; + outline: none; +} + +.webmail-compose-field::placeholder { + color: #52525b; +} + +.webmail-tag-input { + position: relative; +} + +.webmail-tag-input .tags-container { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + align-items: center; + flex: 1; + min-width: 0; +} + +.webmail-tag { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.125rem 0.5rem; + border-radius: 0.375rem; + background: rgba(99, 102, 241, 0.15); + color: var(--color-accent-400); + font-size: 0.75rem; + line-height: 20px; + white-space: nowrap; + max-width: 200px; +} + +.webmail-tag .remove { + display: inline-flex; + cursor: pointer; + color: rgba(129, 140, 248, 0.6); + margin-left: 0.125rem; +} + +.webmail-tag .remove:hover { + color: var(--color-red-400); +} + +.webmail-autocomplete { + display: none; + position: absolute; + top: 100%; + left: 0; + right: 0; + z-index: 60; + max-height: 12rem; + overflow-y: auto; + border-radius: 0.5rem; + border: 1px solid rgba(255, 255, 255, 0.08); + background: var(--color-surface-800); + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4); + margin-top: 0.25rem; +} + +.webmail-autocomplete.visible { + display: block; +} + +.webmail-autocomplete-option { + display: block; + width: 100%; + text-align: left; + padding: 0.5rem 0.75rem; + cursor: pointer; + transition: background 0.1s ease; +} + +.webmail-autocomplete-option:hover { + background: rgba(255, 255, 255, 0.04); +} + +.webmail-editor { + min-height: 200px; + padding: 1.5rem; + color: #d4d4d8; + font-size: 0.875rem; + line-height: 1.625; + outline: none; +} + +.webmail-editor:empty::before { + content: attr(data-placeholder); + color: #52525b; +} + +.webmail-editor p { + margin-bottom: 0.5rem; +} + +.webmail-editor b, +.webmail-editor strong { + font-weight: 600; +} + +.webmail-editor i, +.webmail-editor em { + font-style: italic; +} + +.webmail-editor u { + text-decoration: underline; +} + +.webmail-editor ul { + list-style: disc; + padding-left: 1.5rem; + margin-bottom: 0.5rem; +} + +.webmail-editor ol { + list-style: decimal; + padding-left: 1.5rem; + margin-bottom: 0.5rem; +} + +.webmail-editor blockquote { + border-left: 3px solid rgba(99, 102, 241, 0.3); + padding-left: 1rem; + margin-left: 0; + color: #a1a1aa; + margin-bottom: 0.5rem; +} + +.webmail-editor a { + color: var(--color-accent-400); + text-decoration: underline; +} + +.webmail-toolbar { + display: flex; + align-items: center; + gap: 0.125rem; + padding: 0.375rem 0.5rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); +} + +.webmail-toolbar button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: 0.25rem; + color: #71717a; + font-size: 0.8125rem; + cursor: pointer; + transition: all 0.1s ease; +} + +.webmail-toolbar button:hover { + color: #e4e4e7; + background: rgba(255, 255, 255, 0.06); +} + +.webmail-toolbar button.active { + color: var(--color-accent-400); + background: rgba(99, 102, 241, 0.15); +} + +.webmail-toolbar .separator { + width: 1px; + height: 1rem; + background: rgba(255, 255, 255, 0.06); + margin: 0 0.25rem; +} diff --git a/static/js/dropdown.js b/static/js/dropdown.js index 0bfcc88..5b1fb25 100644 --- a/static/js/dropdown.js +++ b/static/js/dropdown.js @@ -13,23 +13,29 @@ function initDropdowns() { trigger.addEventListener("click", function () { dropdown.classList.toggle("open"); - if (dropdown.classList.contains("open")) { + if (dropdown.classList.contains("open") && search) { search.value = ""; filterOptions(""); search.focus(); } }); - search.addEventListener("input", function () { - filterOptions(search.value.toLowerCase()); - }); + if (search) { + search.addEventListener("input", function () { + filterOptions(search.value.toLowerCase()); + }); + } options.forEach(function (option) { option.addEventListener("click", function () { - hiddenInput.value = option.dataset.value; - label.textContent = option.dataset.label; - label.classList.remove("text-zinc-500"); - label.classList.add("text-zinc-200"); + if (hiddenInput) { + hiddenInput.value = option.dataset.value; + } + if (label) { + label.textContent = option.dataset.label; + label.classList.remove("text-zinc-500"); + label.classList.add("text-zinc-200"); + } options.forEach(function (other) { other.classList.remove("selected"); @@ -47,7 +53,9 @@ function initDropdowns() { option.style.display = matches ? "" : "none"; if (matches) visibleCount++; }); - empty.classList.toggle("hidden", visibleCount > 0); + if (empty) { + empty.classList.toggle("hidden", visibleCount > 0); + } } document.addEventListener("click", function (event) { diff --git a/templates/dashboard/htmx/overview.htmx.django b/templates/dashboard/htmx/overview.htmx.django index c5a7b4d..8e1529e 100644 --- a/templates/dashboard/htmx/overview.htmx.django +++ b/templates/dashboard/htmx/overview.htmx.django @@ -1,4 +1,3 @@ -

{{ PageTitle }}

diff --git a/templates/domains/htmx/domains.htmx.django b/templates/domains/htmx/domains.htmx.django index 11843f6..4e09799 100644 --- a/templates/domains/htmx/domains.htmx.django +++ b/templates/domains/htmx/domains.htmx.django @@ -1,4 +1,3 @@ -

{{ PageTitle }}

diff --git a/templates/domains/htmx/editdomain.htmx.django b/templates/domains/htmx/editdomain.htmx.django index f26dbce..7969999 100644 --- a/templates/domains/htmx/editdomain.htmx.django +++ b/templates/domains/htmx/editdomain.htmx.django @@ -1,4 +1,3 @@ -

{{ PageTitle }}

diff --git a/templates/domains/htmx/edittld.htmx.django b/templates/domains/htmx/edittld.htmx.django index 773578b..70c816d 100644 --- a/templates/domains/htmx/edittld.htmx.django +++ b/templates/domains/htmx/edittld.htmx.django @@ -1,4 +1,3 @@ -

{{ PageTitle }}

diff --git a/templates/domains/htmx/index.htmx.django b/templates/domains/htmx/index.htmx.django index 8f38bd3..0efaf45 100644 --- a/templates/domains/htmx/index.htmx.django +++ b/templates/domains/htmx/index.htmx.django @@ -1,4 +1,3 @@ -

{{ PageTitle }}

diff --git a/templates/domains/htmx/newdomain.htmx.django b/templates/domains/htmx/newdomain.htmx.django index 992bbd9..fcce626 100644 --- a/templates/domains/htmx/newdomain.htmx.django +++ b/templates/domains/htmx/newdomain.htmx.django @@ -1,4 +1,3 @@ -

{{ PageTitle }}

@@ -6,7 +5,7 @@
{% url "domains.manage.create" as create_path %} -
+
diff --git a/templates/domains/htmx/newtld.htmx.django b/templates/domains/htmx/newtld.htmx.django index 9962435..74df6f6 100644 --- a/templates/domains/htmx/newtld.htmx.django +++ b/templates/domains/htmx/newtld.htmx.django @@ -1,4 +1,3 @@ -

{{ PageTitle }}

@@ -6,7 +5,7 @@
{% url "domains.tlds.create" as create_path %} - +
diff --git a/templates/domains/htmx/tlds.htmx.django b/templates/domains/htmx/tlds.htmx.django index 13705d8..7ec1e01 100644 --- a/templates/domains/htmx/tlds.htmx.django +++ b/templates/domains/htmx/tlds.htmx.django @@ -1,4 +1,3 @@ -

{{ PageTitle }}

diff --git a/templates/layouts/dashboard.django b/templates/layouts/dashboard.django index 26aad2e..7863804 100644 --- a/templates/layouts/dashboard.django +++ b/templates/layouts/dashboard.django @@ -5,8 +5,6 @@ {% include "partials/sidebar.django" %}
- {% include "partials/header.django" %} -
{% block dashboard %}{% endblock %}
diff --git a/templates/mail/htmx/editmailbox.htmx.django b/templates/mail/htmx/editmailbox.htmx.django index b25563f..56fd89a 100644 --- a/templates/mail/htmx/editmailbox.htmx.django +++ b/templates/mail/htmx/editmailbox.htmx.django @@ -1,4 +1,3 @@ -

{{ PageTitle }}

diff --git a/templates/mail/htmx/edituser.htmx.django b/templates/mail/htmx/edituser.htmx.django index 8b22afc..6a6c7cb 100644 --- a/templates/mail/htmx/edituser.htmx.django +++ b/templates/mail/htmx/edituser.htmx.django @@ -1,4 +1,3 @@ -

{{ PageTitle }}

diff --git a/templates/mail/htmx/index.htmx.django b/templates/mail/htmx/index.htmx.django index b5b4895..802d047 100644 --- a/templates/mail/htmx/index.htmx.django +++ b/templates/mail/htmx/index.htmx.django @@ -1,4 +1,3 @@ -

{{ PageTitle }}

diff --git a/templates/mail/htmx/mailbox.htmx.django b/templates/mail/htmx/mailbox.htmx.django deleted file mode 100644 index 831a07d..0000000 --- a/templates/mail/htmx/mailbox.htmx.django +++ /dev/null @@ -1,17 +0,0 @@ -

{{ PageTitle }}

-
-
-
-

Inbox

-
-
-
- - - -
-

No emails in this mailbox

-

Emails sent to {{ Address }} will appear here

-
-
-
\ No newline at end of file diff --git a/templates/mail/htmx/mailboxes.htmx.django b/templates/mail/htmx/mailboxes.htmx.django index c0200af..34c9fc5 100644 --- a/templates/mail/htmx/mailboxes.htmx.django +++ b/templates/mail/htmx/mailboxes.htmx.django @@ -1,4 +1,3 @@ -

{{ PageTitle }}

@@ -14,8 +13,7 @@ {% for mailbox in items %}
- {% url "mail.mailbox" address=mailbox.Address as mailbox_path %} - +
diff --git a/templates/mail/htmx/newmailbox.htmx.django b/templates/mail/htmx/newmailbox.htmx.django index b8a8ed7..6c0e925 100644 --- a/templates/mail/htmx/newmailbox.htmx.django +++ b/templates/mail/htmx/newmailbox.htmx.django @@ -1,4 +1,3 @@ -

{{ PageTitle }}

@@ -6,7 +5,7 @@
{% url "mail.mailboxes.create" as create_path %} - +
diff --git a/templates/mail/htmx/newuser.htmx.django b/templates/mail/htmx/newuser.htmx.django index c6d4d10..8507b89 100644 --- a/templates/mail/htmx/newuser.htmx.django +++ b/templates/mail/htmx/newuser.htmx.django @@ -1,4 +1,3 @@ -

{{ PageTitle }}

@@ -6,7 +5,7 @@
{% url "mail.users.create" as create_path %} - +
diff --git a/templates/mail/htmx/users.htmx.django b/templates/mail/htmx/users.htmx.django index 8929907..e2a9113 100644 --- a/templates/mail/htmx/users.htmx.django +++ b/templates/mail/htmx/users.htmx.django @@ -1,4 +1,3 @@ -

{{ PageTitle }}

diff --git a/templates/mail/htmx/webmail.htmx.django b/templates/mail/htmx/webmail.htmx.django new file mode 100644 index 0000000..1657b2a --- /dev/null +++ b/templates/mail/htmx/webmail.htmx.django @@ -0,0 +1,229 @@ +
+
+ + +
+
+ + + + +
+
+ +
+ +
+
+ +
+
+ {% include "mail/webmail/folders.django" %} +
+ +
+ {% include "mail/webmail/emails.django" %} +
+ +
+ {% include "mail/webmail/empty.django" %} +
+
+
+ + + + + + diff --git a/templates/mail/mailbox.django b/templates/mail/mailbox.django deleted file mode 100644 index ed03a72..0000000 --- a/templates/mail/mailbox.django +++ /dev/null @@ -1,16 +0,0 @@ -{% extends "layouts/dashboard.django" %} - -{% block header_actions %} -
-
- - - - {{ Address }} -
-
-{% endblock %} - -{% block dashboard %} -{% include "mail/htmx/mailbox.htmx.django" %} -{% endblock %} \ No newline at end of file diff --git a/templates/mail/webmail.django b/templates/mail/webmail.django new file mode 100644 index 0000000..e444c5c --- /dev/null +++ b/templates/mail/webmail.django @@ -0,0 +1,5 @@ +{% extends "layouts/dashboard.django" %} + +{% block dashboard %} +{% include "mail/htmx/webmail.htmx.django" %} +{% endblock %} diff --git a/templates/mail/webmail/compose.django b/templates/mail/webmail/compose.django new file mode 100644 index 0000000..5fefb7a --- /dev/null +++ b/templates/mail/webmail/compose.django @@ -0,0 +1,309 @@ +
+
+

{% if reply_to %}Reply{% else %}New Message{% endif %}

+ +
+ + + + + + + + +
+
+ + +
+ +
+ +
+
+ + +
+
+
+
+ +
+ +
+
+ + +
+
+
+
+ +
+ +
+
+ + +
+
+
+
+ +
+ + +
+
+ +
+ + + +
+ + +
+ + +
+ +
+
{% if reply_to %}

On {{ reply_to.CreatedAt|date:"Jan 2, 2006 3:04 PM" }}, {{ reply_to.FromAddress }} wrote:

{{ reply_to.Snippet }}
{% endif %}
+
+ +
+
+ + +
+
+ +
+ + diff --git a/templates/mail/webmail/emails.django b/templates/mail/webmail/emails.django new file mode 100644 index 0000000..a96a40f --- /dev/null +++ b/templates/mail/webmail/emails.django @@ -0,0 +1,46 @@ +{% if emails %} +
+ {% for email in emails %} + + {% endfor %} +
+{% else %} +
+
+ + + +
+

No emails

+

This folder is empty

+
+{% endif %} diff --git a/templates/mail/webmail/empty.django b/templates/mail/webmail/empty.django new file mode 100644 index 0000000..d1c1c20 --- /dev/null +++ b/templates/mail/webmail/empty.django @@ -0,0 +1,9 @@ +
+
+ + + +
+

Select an email to read

+

Or compose a new message

+
diff --git a/templates/mail/webmail/folders.django b/templates/mail/webmail/folders.django new file mode 100644 index 0000000..ee6f47c --- /dev/null +++ b/templates/mail/webmail/folders.django @@ -0,0 +1,73 @@ +
diff --git a/templates/mail/webmail/preview.django b/templates/mail/webmail/preview.django new file mode 100644 index 0000000..967cb0f --- /dev/null +++ b/templates/mail/webmail/preview.django @@ -0,0 +1,102 @@ +
+
+
+ + + {% if email.IsRead %} + + {% else %} + + {% endif %} + + +
+ +
+ + + +
+
+ +
+
+

{{ email.Subject }}

+ +
+
+ {% if email.FromName %}{{ email.FromName|first|upper }}{% else %}{{ email.FromAddress|first|upper }}{% endif %} +
+
+
+
+

{% if email.FromName %}{{ email.FromName }}{% else %}{{ email.FromAddress }}{% endif %}

+

{{ email.FromAddress }}

+
+

{{ email.CreatedAt|date:"Jan 2, 2006 3:04 PM" }}

+
+
+ To: {{ email.ToAddresses }} + {% if email.CcAddresses %} + Cc: {{ email.CcAddresses }} + {% endif %} + {% if email.BccAddresses %} + Bcc: {{ email.BccAddresses }} + {% endif %} +
+
+
+ +
+
{% if email_body %}{{ email_body|safe }}{% else %}{{ email.Snippet }}{% endif %}
+
+ + {% if email.AttachmentCount > 0 %} +
+

{{ email.AttachmentCount }} attachment{% if email.AttachmentCount > 1 %}s{% endif %}

+
+ {% endif %} +
+
+
diff --git a/templates/partials/sidebar.django b/templates/partials/sidebar.django index 1a6ab8c..01dd602 100644 --- a/templates/partials/sidebar.django +++ b/templates/partials/sidebar.django @@ -14,6 +14,7 @@ {% url "domains.tlds" as tlds_path %} {% url "domains.manage" as manage_path %} {% url "mail.index" as mail_path %} + {% url "mail.webmail" as webmail_path %} {% url "mail.users" as users_path %} {% url "mail.mailboxes" as mailboxes_path %} @@ -77,6 +78,12 @@ Mailboxes + + + + + WebMail +
diff --git a/utils/storage/defaults.go b/utils/storage/defaults.go new file mode 100644 index 0000000..910de6a --- /dev/null +++ b/utils/storage/defaults.go @@ -0,0 +1,7 @@ +package storage + +const ( + DataDirectory = "data" + MailDirectory = "mail" + EmlExtension = ".eml" +) diff --git a/utils/storage/mail.go b/utils/storage/mail.go new file mode 100644 index 0000000..8f60f4f --- /dev/null +++ b/utils/storage/mail.go @@ -0,0 +1,48 @@ +package storage + +import ( + "os" + "path/filepath" +) + +func MailFolderPath(mailboxAddress string, folderSlug string) string { + return filepath.Join(DataDirectory, MailDirectory, mailboxAddress, folderSlug) +} + +func MailFilePath(mailboxAddress string, folderSlug string, filename string) string { + return filepath.Join(MailFolderPath(mailboxAddress, folderSlug), filename+EmlExtension) +} + +func WriteMailFile(mailboxAddress string, folderSlug string, filename string, content []byte) error { + directoryPath := MailFolderPath(mailboxAddress, folderSlug) + + if mkdirError := os.MkdirAll(directoryPath, 0750); mkdirError != nil { + return mkdirError + } + + filePath := MailFilePath(mailboxAddress, folderSlug, filename) + return os.WriteFile(filePath, content, 0640) +} + +func ReadMailFile(mailboxAddress string, folderSlug string, filename string) ([]byte, error) { + filePath := MailFilePath(mailboxAddress, folderSlug, filename) + return os.ReadFile(filePath) +} + +func MoveMailFile(mailboxAddress string, sourceFolderSlug string, targetFolderSlug string, filename string) error { + targetDirectory := MailFolderPath(mailboxAddress, targetFolderSlug) + + if mkdirError := os.MkdirAll(targetDirectory, 0750); mkdirError != nil { + return mkdirError + } + + sourcePath := MailFilePath(mailboxAddress, sourceFolderSlug, filename) + targetPath := MailFilePath(mailboxAddress, targetFolderSlug, filename) + + return os.Rename(sourcePath, targetPath) +} + +func DeleteMailFile(mailboxAddress string, folderSlug string, filename string) error { + filePath := MailFilePath(mailboxAddress, folderSlug, filename) + return os.Remove(filePath) +} diff --git a/utils/storage/messages.go b/utils/storage/messages.go new file mode 100644 index 0000000..7d5e883 --- /dev/null +++ b/utils/storage/messages.go @@ -0,0 +1,9 @@ +package storage + +const ( + DirectoryCreateFailed = "Failed to create directory: %s." + FileWriteFailed = "Failed to write file: %s." + FileReadFailed = "Failed to read file: %s." + FileMoveFailed = "Failed to move file: %s." + FileDeleteFailed = "Failed to delete file: %s." +) -- cgit v1.2.3