aboutsummaryrefslogtreecommitdiff
path: root/services
diff options
context:
space:
mode:
authorBobby <[email protected]>2026-03-08 08:11:41 +0530
committerBobby <[email protected]>2026-03-08 08:11:41 +0530
commitb2a231280ce3265d20cdc5f317ae1bcc5eb59924 (patch)
tree90eb1a5f5409025db47097e2e083361f8fa52555 /services
parent662dd2069dc8590e8b54823a33726464cf10c4e7 (diff)
downloaddove-b2a231280ce3265d20cdc5f317ae1bcc5eb59924.tar.xz
dove-b2a231280ce3265d20cdc5f317ae1bcc5eb59924.zip
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.
Diffstat (limited to 'services')
-rw-r--r--services/mail/mailboxes.go8
-rw-r--r--services/mail/messages.go25
-rw-r--r--services/mail/webmail.go700
3 files changed, 729 insertions, 4 deletions
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]
+}