package mail import ( "fmt" "regexp" "strings" "time" mailModel "dove/models/mail" mailRepo "dove/repositories/mail" "dove/utils/email" "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) ccAddresses := strings.TrimSpace(request.CcAddresses) bccAddresses := strings.TrimSpace(request.BccAddresses) 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) } messageID := generateMessageID(senderMailbox.Address) rawMessage := email.Compose(email.ComposeRequest{ MessageID: messageID, FromAddress: senderMailbox.Address, FromName: senderMailbox.User.DisplayName, ToAddresses: toAddress, CcAddresses: ccAddresses, Subject: subject, HTMLBody: body, }) recipients := email.EnvelopeRecipients(toAddress, ccAddresses, bccAddresses) submitError := email.Submit(senderMailbox.Address, recipients, rawMessage) if submitError != nil { return shortcuts.ServiceError(shortcuts.Internal, EmailSendFailed) } storeSentCopy(senderMailbox, messageID, toAddress, ccAddresses, subject, body, rawMessage) if request.DraftID > 0 { deleteDraftWithFile(request.DraftID, senderMailbox.Address) } return nil } func storeSentCopy(senderMailbox *mailModel.Mailbox, messageID string, toAddresses string, ccAddresses string, subject string, body string, rawMessage []byte) { sentFolder := mailRepo.FindFolderBySlug(senderMailbox.ID, "sent") if sentFolder == nil { return } filename := generateFilename() + "-sent" sentEmail := &mailModel.Email{ MailboxID: senderMailbox.ID, FolderID: sentFolder.ID, MessageID: messageID, Filename: filename, FromAddress: senderMailbox.Address, FromName: senderMailbox.User.DisplayName, ToAddresses: toAddresses, CcAddresses: ccAddresses, Subject: subject, Snippet: truncateSnippet(body), Size: int64(len(rawMessage)), IsRead: true, } if createError := mailRepo.CreateEmail(sentEmail); createError != nil { logger.Warnf(LogPrefix, EmailStorageFailed, createError) return } if writeError := storage.WriteMailFile(senderMailbox.Address, "sent", filename, rawMessage); writeError != nil { logger.Warnf(LogPrefix, EmailStorageFailed, writeError) } } 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] }