diff options
| author | Bobby <[email protected]> | 2026-03-08 08:11:41 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2026-03-08 08:11:41 +0530 |
| commit | b2a231280ce3265d20cdc5f317ae1bcc5eb59924 (patch) | |
| tree | 90eb1a5f5409025db47097e2e083361f8fa52555 | |
| parent | 662dd2069dc8590e8b54823a33726464cf10c4e7 (diff) | |
| download | dove-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.
44 files changed, 2417 insertions, 88 deletions
@@ -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 @@ -<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1> <div class="slide-up space-y-8"> <div class="grid grid-cols-3 gap-5"> <div class="glass rounded-xl p-5 glow-border"> 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 @@ -<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1> <div class="slide-up space-y-6"> <div class="glass rounded-xl glow-border"> <div class="flex items-center justify-between px-5 py-4 border-b border-white/[0.04]"> 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 @@ -<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1> <div class="slide-up flex items-start justify-center pt-12"> <div class="glass rounded-xl glow-border w-full max-w-lg"> <div class="px-5 py-4 border-b border-white/[0.04]"> 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 @@ -<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1> <div class="slide-up flex items-start justify-center pt-12"> <div class="glass rounded-xl glow-border w-full max-w-lg"> <div class="px-5 py-4 border-b border-white/[0.04]"> 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 @@ -<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1> <div class="slide-up space-y-6"> <div class="glass rounded-xl glow-border"> <div class="px-6 py-5 border-b border-white/[0.04]"> 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 @@ -<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1> <div class="slide-up flex items-start justify-center pt-12"> <div class="glass rounded-xl glow-border w-full max-w-lg"> <div class="px-5 py-4 border-b border-white/[0.04]"> @@ -6,7 +5,7 @@ </div> <div class="p-5"> {% url "domains.manage.create" as create_path %} - <form method="POST" action="{{ create_path }}" class="space-y-4"> + <form hx-post="{{ create_path }}" hx-swap="none" class="space-y-4"> <div> <label class="block text-xs font-medium text-zinc-400 mb-1.5 ml-1">Domain Name</label> <input type="text" name="name" required autocomplete="off" placeholder="myproject" class="input-field"> 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 @@ -<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1> <div class="slide-up flex items-start justify-center pt-12"> <div class="glass rounded-xl glow-border w-full max-w-lg"> <div class="px-5 py-4 border-b border-white/[0.04]"> @@ -6,7 +5,7 @@ </div> <div class="p-5"> {% url "domains.tlds.create" as create_path %} - <form method="POST" action="{{ create_path }}" class="space-y-4"> + <form hx-post="{{ create_path }}" hx-swap="none" class="space-y-4"> <div> <label class="block text-xs font-medium text-zinc-400 mb-1.5 ml-1">TLD Name</label> <input type="text" name="name" required autocomplete="off" placeholder="example" class="input-field"> 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 @@ -<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1> <div class="slide-up space-y-6"> <div class="glass rounded-xl glow-border"> <div class="flex items-center justify-between px-5 py-4 border-b border-white/[0.04]"> 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" %} <div class="flex-1 flex flex-col min-h-screen" id="panel"> - {% include "partials/header.django" %} - <main class="flex-1 p-8" id="content"> {% block dashboard %}{% endblock %} </main> 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 @@ -<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1> <div class="slide-up space-y-6 max-w-lg mx-auto pt-12"> <div class="glass rounded-xl glow-border"> <div class="px-5 py-4 border-b border-white/[0.04]"> 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 @@ -<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1> <div class="slide-up flex items-start justify-center pt-12"> <div class="glass rounded-xl glow-border w-full max-w-lg"> <div class="px-5 py-4 border-b border-white/[0.04]"> 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 @@ -<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1> <div class="slide-up space-y-6"> <div class="glass rounded-xl glow-border"> <div class="px-6 py-5 border-b border-white/[0.04]"> 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 @@ -<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1> -<div class="slide-up space-y-6"> - <div class="glass rounded-xl glow-border"> - <div class="flex items-center justify-between px-5 py-4 border-b border-white/[0.04]"> - <h2 class="text-sm font-medium text-zinc-200">Inbox</h2> - </div> - <div class="flex flex-col items-center justify-center py-16 text-center"> - <div class="flex items-center justify-center w-12 h-12 rounded-2xl bg-surface-800 mb-4"> - <svg class="w-6 h-6 text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> - <path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" /> - </svg> - </div> - <p class="text-sm text-zinc-400">No emails in this mailbox</p> - <p class="mt-1 text-xs text-zinc-600">Emails sent to {{ Address }} will appear here</p> - </div> - </div> -</div>
\ 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 @@ -<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1> <div class="slide-up space-y-6"> <div class="glass rounded-xl glow-border"> <div class="flex items-center justify-between px-5 py-4 border-b border-white/[0.04]"> @@ -14,8 +13,7 @@ {% for mailbox in items %} <div class="flex items-center justify-between px-5 py-3"> <div class="flex items-center gap-3"> - {% url "mail.mailbox" address=mailbox.Address as mailbox_path %} - <a href="{{ mailbox_path }}" hx-get="{{ mailbox_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="flex items-center gap-3 hover:opacity-80 transition-opacity duration-150"> + <a href="/mail/webmail/{{ mailbox.ID }}" hx-get="/mail/webmail/{{ mailbox.ID }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="flex items-center gap-3 hover:opacity-80 transition-opacity duration-150"> <div class="flex items-center justify-center w-8 h-8 rounded-lg bg-accent-500/10"> <svg class="w-4 h-4 text-accent-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 13.5h3.86a2.25 2.25 0 0 1 2.012 1.244l.256.512a2.25 2.25 0 0 0 2.013 1.244h2.21a2.25 2.25 0 0 0 2.013-1.244l.256-.512a2.25 2.25 0 0 1 2.013-1.244h3.859m-19.5.338V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 0 0-2.15-1.588H6.911a2.25 2.25 0 0 0-2.15 1.588L2.35 13.177a2.25 2.25 0 0 0-.1.661Z" /> 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 @@ -<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1> <div class="slide-up flex items-start justify-center pt-12"> <div class="glass rounded-xl glow-border w-full max-w-lg"> <div class="px-5 py-4 border-b border-white/[0.04]"> @@ -6,7 +5,7 @@ </div> <div class="p-5"> {% url "mail.mailboxes.create" as create_path %} - <form method="POST" action="{{ create_path }}" class="space-y-4"> + <form hx-post="{{ create_path }}" hx-swap="none" class="space-y-4"> <div> <label class="block text-xs font-medium text-zinc-400 mb-1.5 ml-1">Address</label> <div class="flex items-center gap-2"> 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 @@ -<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1> <div class="slide-up flex items-start justify-center pt-12"> <div class="glass rounded-xl glow-border w-full max-w-lg"> <div class="px-5 py-4 border-b border-white/[0.04]"> @@ -6,7 +5,7 @@ </div> <div class="p-5"> {% url "mail.users.create" as create_path %} - <form method="POST" action="{{ create_path }}" class="space-y-4"> + <form hx-post="{{ create_path }}" hx-swap="none" class="space-y-4"> <div> <label class="block text-xs font-medium text-zinc-400 mb-1.5 ml-1">Username</label> <input type="text" name="username" required autocomplete="off" class="input-field"> 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 @@ -<h1 id="page-title" class="text-sm font-medium text-zinc-100" hx-swap-oob="true">{{ PageTitle }}</h1> <div class="slide-up space-y-6"> <div class="glass rounded-xl glow-border"> <div class="flex items-center justify-between px-5 py-4 border-b border-white/[0.04]"> 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 @@ +<div class="-m-8 flex flex-col h-[calc(100vh)]" id="webmail" data-mailbox-id="{{ active_mailbox.ID }}" data-folder-id="{{ active_folder.ID }}" data-folder-slug="{% if is_starred_view %}starred{% else %}{{ active_folder.Slug }}{% endif %}"> + <div class="flex items-center gap-3 px-4 h-12 shrink-0 border-b border-white/[0.04] bg-surface-900/50"> + <div class="dropdown" data-dropdown> + <input type="hidden" value="{{ active_mailbox.ID }}" data-dropdown-value> + <button type="button" class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-surface-800 border border-white/[0.06] hover:border-white/[0.1] text-sm text-zinc-200 transition-all duration-150" data-dropdown-trigger> + <svg class="w-4 h-4 text-accent-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" /> + </svg> + <span class="truncate max-w-[200px]" data-dropdown-label>{{ active_mailbox.Address }}</span> + <svg class="w-3.5 h-3.5 text-zinc-500 shrink-0 transition-transform duration-150" data-dropdown-chevron fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" /> + </svg> + </button> + <div class="dropdown-menu" data-dropdown-menu> + <div class="p-2 border-b border-white/[0.04]"> + <input type="text" placeholder="Search mailboxes..." class="dropdown-search" data-dropdown-search> + </div> + <div class="dropdown-options" data-dropdown-options> + {% for mailbox in mailboxes %} + <a href="/mail/webmail/{{ mailbox.ID }}" hx-get="/mail/webmail/{{ mailbox.ID }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="dropdown-option block" data-dropdown-option data-value="{{ mailbox.ID }}" data-label="{{ mailbox.Address }}"> + <p class="text-sm text-zinc-200">{{ mailbox.Address }}</p> + <p class="text-xs text-zinc-500">{{ mailbox.User.DisplayName }}</p> + </a> + {% endfor %} + </div> + <div class="dropdown-empty hidden" data-dropdown-empty> + <p class="text-xs text-zinc-500 text-center py-3">No mailboxes found</p> + </div> + </div> + </div> + + <div class="flex-1 max-w-md"> + <div class="relative"> + <svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /> + </svg> + <input type="text" name="search" value="{{ search }}" placeholder="Search emails..." class="w-full pl-10 pr-4 py-1.5 rounded-lg bg-surface-800 border border-white/[0.06] text-sm text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-accent-500/50 transition-colors duration-150" hx-get="/mail/webmail/{{ active_mailbox.ID }}/folder/{{ active_folder.ID }}/emails" hx-target="#webmail-emails" hx-swap="innerHTML" hx-trigger="input changed delay:300ms" hx-include="[name='search']"> + </div> + </div> + + <div class="ml-auto flex items-center gap-2"> + <button hx-get="/mail/webmail/{{ active_mailbox.ID }}/compose" hx-target="#webmail-preview" hx-swap="innerHTML" class="webmail-btn webmail-btn-primary"> + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" /> + </svg> + Compose + </button> + </div> + </div> + + <div class="flex flex-1 min-h-0"> + <div class="w-52 shrink-0 border-r border-white/[0.04] overflow-hidden" id="webmail-folders"> + {% include "mail/webmail/folders.django" %} + </div> + + <div class="w-80 shrink-0 border-r border-white/[0.04] overflow-y-auto" id="webmail-emails"> + {% include "mail/webmail/emails.django" %} + </div> + + <div class="flex-1 min-w-0 min-h-0" id="webmail-preview"> + {% include "mail/webmail/empty.django" %} + </div> + </div> +</div> + +<template id="webmail-empty-template"> + {% include "mail/webmail/empty.django" %} +</template> + +<div id="webmail-modal" class="fixed inset-0 z-50 hidden"> + <div class="absolute inset-0 bg-black/60 backdrop-blur-sm" data-webmail-modal-backdrop></div> + <div class="flex items-center justify-center min-h-screen p-4"> + <div class="relative glass rounded-xl glow-border w-full max-w-sm p-6 space-y-4"> + <h3 id="webmail-modal-title" class="text-sm font-medium text-zinc-100"></h3> + <div id="webmail-modal-body"></div> + <div class="flex items-center justify-end gap-3 pt-2"> + <button data-webmail-modal-cancel class="px-4 py-2 text-xs text-zinc-400 hover:text-zinc-200 rounded-lg bg-surface-800 border border-white/[0.06] hover:border-white/[0.1] transition-all duration-150">Cancel</button> + <button id="webmail-modal-confirm" class="px-4 py-2 text-xs text-white rounded-lg bg-red-500/80 hover:bg-red-500 border border-red-400/20 transition-all duration-150">Confirm</button> + </div> + </div> + </div> +</div> + +<script> +(function() { + var webmail = document.getElementById("webmail"); + if (!webmail) return; + + function getMailboxID() { + return webmail.dataset.mailboxId; + } + + function getFolderSlug() { + return webmail.dataset.folderSlug; + } + + function refreshEmailList() { + var mailboxId = getMailboxID(); + var folderSlug = getFolderSlug(); + var url; + + if (folderSlug === "starred") { + url = "/mail/webmail/" + mailboxId + "/starred/emails"; + } else { + var folderId = webmail.dataset.folderId; + url = "/mail/webmail/" + mailboxId + "/folder/" + folderId + "/emails"; + } + + htmx.ajax("GET", url, { target: "#webmail-emails", swap: "innerHTML" }); + } + + function refreshFolderSidebar() { + var mailboxId = getMailboxID(); + var folderSlug = getFolderSlug(); + htmx.ajax("GET", "/mail/webmail/" + mailboxId + "/folders?folder=" + folderSlug, { target: "#webmail-folders", swap: "innerHTML" }); + } + + function clearPreview() { + var template = document.getElementById("webmail-empty-template"); + var preview = document.getElementById("webmail-preview"); + if (template && preview) { + preview.innerHTML = template.innerHTML; + } + } + + var starredSvgFilled = '<svg class="w-4 h-4 text-yellow-400 fill-yellow-400" viewBox="0 0 24 24" stroke-width="1.5"><path d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" /></svg>'; + var starredSvgEmpty = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" /></svg>'; + + document.body.addEventListener("emailStarred", function(event) { + var triggerElement = event.target; + if (triggerElement) { + var emailId = triggerElement.getAttribute("data-email-star") || triggerElement.getAttribute("data-preview-star"); + if (!emailId) { + triggerElement = triggerElement.closest("[data-email-star], [data-preview-star]"); + if (triggerElement) { + emailId = triggerElement.getAttribute("data-email-star") || triggerElement.getAttribute("data-preview-star"); + } + } + if (emailId) { + var isFilled = triggerElement.querySelector(".fill-yellow-400"); + var newSvg = isFilled ? starredSvgEmpty : starredSvgFilled; + + var listStar = document.querySelector("[data-email-star='" + emailId + "']"); + if (listStar) listStar.innerHTML = newSvg; + + var previewStar = document.querySelector("[data-preview-star='" + emailId + "']"); + if (previewStar) previewStar.innerHTML = newSvg; + } + } + refreshFolderSidebar(); + if (getFolderSlug() === "starred") { + refreshEmailList(); + } + }); + + document.body.addEventListener("emailDeleted", function() { + clearPreview(); + refreshEmailList(); + refreshFolderSidebar(); + }); + + document.body.addEventListener("emailMoved", function() { + clearPreview(); + refreshEmailList(); + refreshFolderSidebar(); + }); + + document.body.addEventListener("emailReadChanged", function() { + refreshEmailList(); + refreshFolderSidebar(); + }); + + var modal = document.getElementById("webmail-modal"); + var modalTitle = document.getElementById("webmail-modal-title"); + var modalBody = document.getElementById("webmail-modal-body"); + var modalConfirm = document.getElementById("webmail-modal-confirm"); + var modalBackdrop = modal.querySelector("[data-webmail-modal-backdrop]"); + var modalCancel = modal.querySelector("[data-webmail-modal-cancel]"); + + function closeModal() { + modal.classList.add("hidden"); + modalBody.innerHTML = ""; + } + + modalBackdrop.addEventListener("click", closeModal); + modalCancel.addEventListener("click", closeModal); + + window.webmailConfirm = function(options) { + modalTitle.textContent = options.title || "Are you sure?"; + modalBody.innerHTML = '<p class="text-xs text-zinc-400 leading-relaxed">' + (options.message || "") + '</p>'; + modalConfirm.textContent = options.confirmText || "Confirm"; + modalConfirm.className = "px-4 py-2 text-xs text-white rounded-lg transition-all duration-150 " + (options.danger ? "bg-red-500/80 hover:bg-red-500 border border-red-400/20" : "bg-accent-500/80 hover:bg-accent-500 border border-accent-400/20"); + modal.classList.remove("hidden"); + + var cloned = modalConfirm.cloneNode(true); + modalConfirm.parentNode.replaceChild(cloned, modalConfirm); + modalConfirm = cloned; + + modalConfirm.addEventListener("click", function() { + closeModal(); + if (options.onConfirm) options.onConfirm(); + }); + }; + + window.webmailPrompt = function(options) { + modalTitle.textContent = options.title || "Input"; + modalBody.innerHTML = '<input type="text" id="webmail-modal-input" placeholder="' + (options.placeholder || "") + '" class="input-field mt-2" value="' + (options.value || "") + '">'; + modalConfirm.textContent = options.confirmText || "OK"; + modalConfirm.className = "px-4 py-2 text-xs text-white rounded-lg bg-accent-500/80 hover:bg-accent-500 border border-accent-400/20 transition-all duration-150"; + modal.classList.remove("hidden"); + + setTimeout(function() { + var input = document.getElementById("webmail-modal-input"); + if (input) input.focus(); + }, 50); + + var cloned = modalConfirm.cloneNode(true); + modalConfirm.parentNode.replaceChild(cloned, modalConfirm); + modalConfirm = cloned; + + modalConfirm.addEventListener("click", function() { + var input = document.getElementById("webmail-modal-input"); + var value = input ? input.value.trim() : ""; + closeModal(); + if (value && options.onConfirm) options.onConfirm(value); + }); + }; +})(); +</script> 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 %} -<div class="flex items-center gap-3"> - <div class="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-surface-800 border border-white/[0.06]"> - <svg class="w-3.5 h-3.5 text-accent-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> - <path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" /> - </svg> - <span class="text-xs text-zinc-300">{{ Address }}</span> - </div> -</div> -{% 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 @@ +<div class="flex flex-col h-full"> + <div class="flex items-center justify-between px-6 py-2 border-b border-white/[0.04] shrink-0"> + <h3 class="text-sm font-medium text-zinc-200">{% if reply_to %}Reply{% else %}New Message{% endif %}</h3> + <button type="button" onclick="var t = document.getElementById('webmail-empty-template'); if (t) document.getElementById('webmail-preview').innerHTML = t.innerHTML;" class="webmail-btn-icon" title="Discard"> + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> + </svg> + </button> + </div> + + <form id="compose-form" class="flex flex-col flex-1 min-h-0"> + <input type="hidden" id="compose-draft-id" value="{{ draft_id }}"> + <input type="hidden" id="compose-from-mailbox-id" value="{{ active_mailbox.ID }}"> + <input type="hidden" id="compose-to-value" value="{% if reply_to %}{{ reply_to.FromAddress }}{% endif %}"> + <input type="hidden" id="compose-cc-value" value=""> + <input type="hidden" id="compose-bcc-value" value=""> + + <div class="border-b border-white/[0.04]"> + <div class="flex items-start px-6 py-1.5 border-b border-white/[0.04]"> + <label class="text-xs text-zinc-500 w-12 shrink-0 leading-[24px]">From</label> + <div class="dropdown flex-1 min-w-0" data-dropdown> + <input type="hidden" value="{{ active_mailbox.ID }}" data-dropdown-value> + <button type="button" class="flex items-center gap-1 text-left" data-dropdown-trigger> + <span class="webmail-tag" data-dropdown-label>{{ active_mailbox.User.DisplayName }} <{{ active_mailbox.Address }}></span> + <svg class="w-3 h-3 text-zinc-600 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" /> + </svg> + </button> + <div class="dropdown-menu min-w-[16rem]" data-dropdown-menu> + <div class="dropdown-options" data-dropdown-options> + {% for address in from_addresses %} + <button type="button" class="dropdown-option" data-dropdown-option data-value="{{ address.MailboxID }}" data-label="{{ address.DisplayName }} <{{ address.Address }}>" onclick="document.getElementById('compose-from-mailbox-id').value = '{{ address.MailboxID }}';"> + <p class="text-sm text-zinc-200">{{ address.DisplayName }}</p> + <p class="text-xs text-zinc-500">{{ address.Address }}</p> + </button> + {% endfor %} + </div> + </div> + </div> + </div> + + <div class="flex items-start px-6 py-1.5 border-b border-white/[0.04]"> + <label class="text-xs text-zinc-500 w-12 shrink-0 leading-[24px]">To</label> + <div class="webmail-tag-input flex-1 min-w-0" id="to-field"> + <div class="tags-container"> + <span id="to-tags"></span> + <input type="text" autocomplete="off" placeholder="Add recipient..." class="webmail-compose-field flex-1 min-w-[80px] leading-[24px]" data-recipient-input data-field="to"> + </div> + <div class="webmail-autocomplete" data-autocomplete></div> + </div> + </div> + + <div class="flex items-start px-6 py-1.5 border-b border-white/[0.04]"> + <label class="text-xs text-zinc-500 w-12 shrink-0 leading-[24px]">Cc</label> + <div class="webmail-tag-input flex-1 min-w-0" id="cc-field"> + <div class="tags-container"> + <span id="cc-tags"></span> + <input type="text" autocomplete="off" placeholder="Add Cc..." class="webmail-compose-field flex-1 min-w-[80px] leading-[24px]" data-recipient-input data-field="cc"> + </div> + <div class="webmail-autocomplete" data-autocomplete></div> + </div> + </div> + + <div class="flex items-start px-6 py-1.5 border-b border-white/[0.04]"> + <label class="text-xs text-zinc-500 w-12 shrink-0 leading-[24px]">Bcc</label> + <div class="webmail-tag-input flex-1 min-w-0" id="bcc-field"> + <div class="tags-container"> + <span id="bcc-tags"></span> + <input type="text" autocomplete="off" placeholder="Add Bcc..." class="webmail-compose-field flex-1 min-w-[80px] leading-[24px]" data-recipient-input data-field="bcc"> + </div> + <div class="webmail-autocomplete" data-autocomplete></div> + </div> + </div> + + <div class="flex items-start px-6 py-1.5"> + <label class="text-xs text-zinc-500 w-12 shrink-0 leading-[24px]">Subject</label> + <input type="text" name="subject" value="{% if reply_to %}Re: {{ reply_to.Subject }}{% endif %}" required autocomplete="off" placeholder="Subject" class="webmail-compose-field flex-1 min-w-0 leading-[24px]"> + </div> + </div> + + <div class="webmail-toolbar" id="compose-toolbar"> + <button type="button" data-command="bold" title="Bold"><b>B</b></button> + <button type="button" data-command="italic" title="Italic"><i>I</i></button> + <button type="button" data-command="underline" title="Underline"><u>U</u></button> + <div class="separator"></div> + <button type="button" data-command="insertUnorderedList" title="Bullet list"> + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0ZM3.75 12h.007v.008H3.75V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm-.375 5.25h.007v.008H3.75v-.008Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" /> + </svg> + </button> + <button type="button" data-command="insertOrderedList" title="Numbered list"> + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M8.242 5.992h12m-12 6.003h12m-12 5.999h12M4.117 7.495v-3.75H2.99m1.125 3.75H2.99m1.125 0H5.24m-1.92 2.577a1.125 1.125 0 1 1 1.591 1.59l-1.83 1.83h2.16M2.99 15.745h1.125a1.125 1.125 0 0 1 0 2.25H3.74m0-.002h.375a1.125 1.125 0 0 1 0 2.25H2.99" /> + </svg> + </button> + <div class="separator"></div> + <button type="button" data-command="formatBlock" data-value="blockquote" title="Quote"> + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" /> + </svg> + </button> + <button type="button" data-command="createLink" title="Link"> + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" /> + </svg> + </button> + </div> + + <div class="flex-1 min-h-0 overflow-y-auto"> + <div id="compose-editor" class="webmail-editor" contenteditable="true" data-placeholder="Write your message...">{% if reply_to %}<br><br><blockquote>On {{ reply_to.CreatedAt|date:"Jan 2, 2006 3:04 PM" }}, {{ reply_to.FromAddress }} wrote:<br><br>{{ reply_to.Snippet }}</blockquote>{% endif %}</div> + </div> + + <div class="flex items-center justify-between px-6 py-3 border-t border-white/[0.04] shrink-0"> + <div class="flex items-center gap-2"> + <button type="button" onclick="submitCompose('send')" class="webmail-btn webmail-btn-primary"> + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" /> + </svg> + Send + </button> + <button type="button" onclick="submitCompose('draft')" class="webmail-btn webmail-btn-ghost"> + Save Draft + </button> + </div> + </div> + </form> +</div> + +<script> +(function() { + var composeState = { to: [], cc: [], bcc: [] }; + var allRecipients = [ + {% for r in all_recipients %} + { address: "{{ r.Address }}", display_name: "{{ r.DisplayName }}" }{% if not forloop.Last %},{% endif %} + {% endfor %} + ]; + + {% if reply_to %} + composeState.to.push("{{ reply_to.FromAddress }}"); + {% endif %} + + function renderTags(field) { + var container = document.getElementById(field + "-tags"); + if (!container) return; + container.innerHTML = ""; + composeState[field].forEach(function(address, index) { + var tag = document.createElement("span"); + tag.className = "webmail-tag"; + tag.innerHTML = '<span class="truncate">' + address + '</span><span class="remove" data-field="' + field + '" data-index="' + index + '">×</span>'; + container.appendChild(tag); + }); + updateHiddenField(field); + } + + function updateHiddenField(field) { + var hiddenInput = document.getElementById("compose-" + field + "-value"); + if (hiddenInput) { + hiddenInput.value = composeState[field].join(", "); + } + } + + function addRecipient(field, address) { + address = address.trim(); + if (!address || composeState[field].indexOf(address) !== -1) return; + composeState[field].push(address); + renderTags(field); + } + + function removeRecipient(field, index) { + composeState[field].splice(index, 1); + renderTags(field); + } + + document.querySelectorAll("[data-recipient-input]").forEach(function(input) { + var field = input.dataset.field; + var autocomplete = input.closest(".webmail-tag-input").querySelector("[data-autocomplete]"); + + input.addEventListener("input", function() { + var query = input.value.toLowerCase().trim(); + if (query.length < 1) { autocomplete.classList.remove("visible"); return; } + + var matches = allRecipients.filter(function(r) { + var alreadyAdded = composeState[field].indexOf(r.address) !== -1; + return !alreadyAdded && (r.address.toLowerCase().indexOf(query) !== -1 || r.display_name.toLowerCase().indexOf(query) !== -1); + }).slice(0, 8); + + if (matches.length === 0) { autocomplete.classList.remove("visible"); return; } + + autocomplete.innerHTML = ""; + matches.forEach(function(match) { + var option = document.createElement("div"); + option.className = "webmail-autocomplete-option"; + option.innerHTML = '<p class="text-sm text-zinc-200">' + match.display_name + '</p><p class="text-xs text-zinc-500">' + match.address + '</p>'; + option.addEventListener("mousedown", function(e) { + e.preventDefault(); + addRecipient(field, match.address); + input.value = ""; + autocomplete.classList.remove("visible"); + }); + autocomplete.appendChild(option); + }); + autocomplete.classList.add("visible"); + }); + + input.addEventListener("keydown", function(e) { + if (e.key === "Enter" || e.key === "," || e.key === "Tab") { + e.preventDefault(); + var val = input.value.replace(",", "").trim(); + if (val) { addRecipient(field, val); input.value = ""; } + autocomplete.classList.remove("visible"); + } + if (e.key === "Backspace" && input.value === "" && composeState[field].length > 0) { + removeRecipient(field, composeState[field].length - 1); + } + }); + + input.addEventListener("blur", function() { + setTimeout(function() { autocomplete.classList.remove("visible"); }, 200); + var val = input.value.replace(",", "").trim(); + if (val) { addRecipient(field, val); input.value = ""; } + }); + }); + + document.addEventListener("click", function(e) { + if (e.target.classList.contains("remove") && e.target.dataset.field) { + removeRecipient(e.target.dataset.field, parseInt(e.target.dataset.index)); + } + }); + + renderTags("to"); + renderTags("cc"); + renderTags("bcc"); + + var toolbar = document.getElementById("compose-toolbar"); + var editor = document.getElementById("compose-editor"); + + toolbar.querySelectorAll("[data-command]").forEach(function(button) { + button.addEventListener("click", function() { + var command = button.dataset.command; + var value = button.dataset.value || null; + + if (command === "createLink") { + if (typeof webmailPrompt === "function") { + webmailPrompt({ + title: "Insert Link", + placeholder: "https://example.com", + confirmText: "Insert", + onConfirm: function(url) { + editor.focus(); + document.execCommand("createLink", false, url); + } + }); + } + return; + } + + document.execCommand(command, false, value); + editor.focus(); + updateToolbarState(); + }); + }); + + function updateToolbarState() { + toolbar.querySelectorAll("[data-command]").forEach(function(button) { + var command = button.dataset.command; + var isActive = false; + + if (command === "bold" || command === "italic" || command === "underline" || command === "insertUnorderedList" || command === "insertOrderedList") { + isActive = document.queryCommandState(command); + } + + if (isActive) { + button.classList.add("active"); + } else { + button.classList.remove("active"); + } + }); + } + + editor.addEventListener("keyup", updateToolbarState); + editor.addEventListener("mouseup", updateToolbarState); + editor.addEventListener("focus", updateToolbarState); + + window.submitCompose = function(action) { + var form = document.getElementById("compose-form"); + var url = action === "draft" ? "/mail/webmail/draft" : "/mail/webmail/send"; + var draftButton = form.querySelector("[onclick=\"submitCompose('draft')\"]"); + + htmx.ajax("POST", url, { + swap: "none", + values: { + from_mailbox_id: document.getElementById("compose-from-mailbox-id").value, + to_address: document.getElementById("compose-to-value").value, + cc_addresses: document.getElementById("compose-cc-value").value, + bcc_addresses: document.getElementById("compose-bcc-value").value, + subject: form.querySelector("[name='subject']").value, + body: editor.innerHTML, + draft_id: document.getElementById("compose-draft-id").value + } + }).then(function() { + if (action === "draft" && draftButton) { + var originalText = draftButton.textContent; + draftButton.textContent = "Saved!"; + setTimeout(function() { draftButton.textContent = originalText; }, 1500); + } + }); + }; +})(); +</script> 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 %} +<div class="divide-y divide-white/[0.04]"> + {% for email in emails %} + <div class="webmail-email w-full text-left px-4 py-3 hover:bg-white/[0.02] transition-colors duration-150 cursor-pointer {% if not email.IsRead %}bg-accent-500/[0.03]{% endif %}" data-email-id="{{ email.ID }}" hx-get="/mail/webmail/{{ email.MailboxID }}/email/{{ email.ID }}" hx-target="#webmail-preview" hx-swap="innerHTML" hx-trigger="click consume" data-email-row> + <div class="flex items-start gap-3"> + <button type="button" hx-put="/mail/webmail/email/{{ email.ID }}/star" hx-swap="none" class="webmail-star mt-0.5 shrink-0 text-zinc-600 hover:text-yellow-400 transition-colors duration-150" onclick="event.stopPropagation();" data-email-star="{{ email.ID }}"> + {% if email.IsStarred %} + <svg class="w-4 h-4 text-yellow-400 fill-yellow-400" viewBox="0 0 24 24" stroke-width="1.5"> + <path d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" /> + </svg> + {% else %} + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" /> + </svg> + {% endif %} + </button> + <div class="flex-1 min-w-0"> + <div class="flex items-center justify-between gap-2"> + <p class="text-sm truncate {% if not email.IsRead %}text-zinc-100 font-medium{% else %}text-zinc-400{% endif %}"> + {% if email.FromName %}{{ email.FromName }}{% else %}{{ email.FromAddress }}{% endif %} + </p> + <span class="text-[10px] text-zinc-600 shrink-0">{{ email.CreatedAt|date:"Jan 2" }}</span> + </div> + <p class="text-xs truncate mt-0.5 {% if not email.IsRead %}text-zinc-300{% else %}text-zinc-500{% endif %}">{{ email.Subject }}</p> + <p class="text-xs text-zinc-600 truncate mt-0.5">{{ email.Snippet }}</p> + </div> + {% if email.AttachmentCount > 0 %} + <svg class="w-3.5 h-3.5 text-zinc-600 shrink-0 mt-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13" /> + </svg> + {% endif %} + </div> + </div> + {% endfor %} +</div> +{% else %} +<div class="flex flex-col items-center justify-center h-full text-center p-8"> + <div class="flex items-center justify-center w-12 h-12 rounded-2xl bg-surface-800 mb-4"> + <svg class="w-6 h-6 text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" /> + </svg> + </div> + <p class="text-sm text-zinc-400">No emails</p> + <p class="text-xs text-zinc-600 mt-1">This folder is empty</p> +</div> +{% 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 @@ +<div class="flex flex-col items-center justify-center h-full text-center" id="webmail-empty-state"> + <div class="flex items-center justify-center w-16 h-16 rounded-2xl bg-surface-800 mb-4"> + <svg class="w-8 h-8 text-zinc-700" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" /> + </svg> + </div> + <p class="text-sm text-zinc-500">Select an email to read</p> + <p class="text-xs text-zinc-600 mt-1">Or compose a new message</p> +</div> 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 @@ +<div class="flex flex-col h-full"> + <div class="flex-1 overflow-y-auto p-2 space-y-0.5"> + <p class="px-3 py-1.5 text-[10px] font-semibold text-zinc-600 uppercase tracking-wider">Folders</p> + + {% for folder in folders %} + <a href="/mail/webmail/{{ active_mailbox.ID }}?folder={{ folder.Slug }}" hx-get="/mail/webmail/{{ active_mailbox.ID }}?folder={{ folder.Slug }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="webmail-folder flex items-center justify-between w-full px-3 py-1.5 rounded-lg text-sm transition-colors duration-150 group {% if active_folder.ID == folder.ID and not is_starred_view %}bg-accent-500/10 text-accent-400{% else %}text-zinc-400 hover:text-zinc-200 hover:bg-white/[0.04]{% endif %}" data-folder-id="{{ folder.ID }}" data-folder-slug="{{ folder.Slug }}"> + <span class="flex items-center gap-2.5 min-w-0"> + {% if folder.Slug == "inbox" %} + <svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 13.5h3.86a2.25 2.25 0 0 1 2.012 1.244l.256.512a2.25 2.25 0 0 0 2.013 1.244h2.21a2.25 2.25 0 0 0 2.013-1.244l.256-.512a2.25 2.25 0 0 1 2.013-1.244h3.859m-19.5.338V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 0 0-2.15-1.588H6.911a2.25 2.25 0 0 0-2.15 1.588L2.35 13.177a2.25 2.25 0 0 0-.1.661Z" /> + </svg> + {% elif folder.Slug == "sent" %} + <svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" /> + </svg> + {% elif folder.Slug == "drafts" %} + <svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" /> + </svg> + {% elif folder.Slug == "spam" %} + <svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" /> + </svg> + {% elif folder.Slug == "trash" %} + <svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /> + </svg> + {% else %} + <svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z" /> + </svg> + {% endif %} + <span class="truncate">{{ folder.Name }}</span> + </span> + <span class="flex items-center gap-1"> + {% if folder.UnreadCount > 0 %} + <span class="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-accent-500/20 text-accent-400">{{ folder.UnreadCount }}</span> + {% endif %} + {% if not folder.IsSystem %} + <button type="button" class="p-0.5 rounded text-zinc-600 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all duration-150" onclick="event.preventDefault(); event.stopPropagation(); webmailConfirm({ title: 'Delete Folder', message: 'Are you sure you want to delete this folder?', confirmText: 'Delete', danger: true, onConfirm: function() { htmx.ajax('DELETE', '/mail/webmail/{{ active_mailbox.ID }}/folder/{{ folder.ID }}', {swap: 'none'}); }});"> + <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> + </svg> + </button> + {% endif %} + </span> + </a> + {% endfor %} + + <a href="/mail/webmail/{{ active_mailbox.ID }}?folder=starred" hx-get="/mail/webmail/{{ active_mailbox.ID }}?folder=starred" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="webmail-folder flex items-center justify-between w-full px-3 py-1.5 rounded-lg text-sm transition-colors duration-150 {% if is_starred_view %}bg-accent-500/10 text-accent-400{% else %}text-zinc-400 hover:text-zinc-200 hover:bg-white/[0.04]{% endif %}" data-folder-slug="starred"> + <span class="flex items-center gap-2.5"> + <svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" /> + </svg> + Starred + </span> + {% if starred_count > 0 %} + <span class="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-yellow-400/20 text-yellow-400">{{ starred_count }}</span> + {% endif %} + </a> + </div> + + <div class="border-t border-white/[0.04] p-2"> + <form hx-post="/mail/webmail/{{ active_mailbox.ID }}/folders" hx-swap="none" class="flex items-center gap-1"> + <input type="text" name="name" placeholder="New folder..." class="flex-1 min-w-0 px-2 py-1 rounded bg-transparent border border-transparent text-xs text-zinc-400 placeholder-zinc-600 focus:outline-none focus:border-white/[0.06] transition-colors duration-150"> + <button type="submit" class="p-1 rounded text-zinc-600 hover:text-zinc-300 shrink-0 transition-colors duration-150"> + <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> + </svg> + </button> + </form> + </div> +</div> 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 @@ +<div class="flex flex-col h-full"> + <div class="flex items-center justify-between px-6 py-2 border-b border-white/[0.04] shrink-0"> + <div class="flex items-center gap-1"> + <button hx-put="/mail/webmail/email/{{ email.ID }}/star" hx-swap="none" class="webmail-btn-icon" title="{% if email.IsStarred %}Unstar{% else %}Star{% endif %}" data-preview-star="{{ email.ID }}"> + {% if email.IsStarred %} + <svg class="w-4 h-4 text-yellow-400 fill-yellow-400" viewBox="0 0 24 24" stroke-width="1.5"> + <path d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" /> + </svg> + {% else %} + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" /> + </svg> + {% endif %} + </button> + + {% if email.IsRead %} + <button hx-put="/mail/webmail/email/{{ email.ID }}/unread" hx-swap="none" class="webmail-btn-icon" title="Mark as unread"> + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" /> + </svg> + </button> + {% else %} + <button hx-put="/mail/webmail/email/{{ email.ID }}/read" hx-swap="none" class="webmail-btn-icon" title="Mark as read"> + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M21.75 9v.906a2.25 2.25 0 0 1-1.183 1.981l-6.478 3.488M2.25 9v.906a2.25 2.25 0 0 0 1.183 1.981l6.478 3.488m8.839 2.51-4.66-2.51m0 0-1.023-.55a2.25 2.25 0 0 0-2.134 0l-1.022.55m0 0-4.661 2.51m16.5 1.615a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V8.844a2.25 2.25 0 0 1 1.183-1.981l7.5-4.039a2.25 2.25 0 0 1 2.134 0l7.5 4.039a2.25 2.25 0 0 1 1.183 1.98V19.5Z" /> + </svg> + </button> + {% endif %} + + <button hx-get="/mail/webmail/{{ active_mailbox.ID }}/compose?reply_to={{ email.ID }}" hx-target="#webmail-preview" hx-swap="innerHTML" class="webmail-btn-icon" title="Reply"> + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M9 15 3 9m0 0 6-6M3 9h12a6 6 0 0 1 0 12h-3" /> + </svg> + </button> + </div> + + <div class="flex items-center gap-1"> + <div class="dropdown" data-dropdown> + <button type="button" class="webmail-btn-icon" data-dropdown-trigger title="Move to folder"> + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z" /> + </svg> + </button> + <div class="dropdown-menu min-w-[10rem] right-0 left-auto" data-dropdown-menu> + <div class="dropdown-options" data-dropdown-options> + {% for folder in folders %} + <button type="button" class="dropdown-option whitespace-nowrap" hx-put="/mail/webmail/email/{{ email.ID }}/move" hx-vals='{"folder_id": {{ folder.ID }}}' hx-swap="none" data-dropdown-option data-value="{{ folder.ID }}" data-label="{{ folder.Name }}"> + <p class="text-sm text-zinc-200">{{ folder.Name }}</p> + </button> + {% endfor %} + </div> + </div> + </div> + + <button type="button" onclick="webmailConfirm({ title: 'Delete Email', message: '{% if folder_slug == "trash" %}Permanently delete this email?{% else %}Move this email to trash?{% endif %}', confirmText: '{% if folder_slug == "trash" %}Delete Permanently{% else %}Delete{% endif %}', danger: true, onConfirm: function() { htmx.ajax('DELETE', '/mail/webmail/{{ active_mailbox.ID }}/email/{{ email.ID }}', {swap: 'none'}); }})" class="webmail-btn-icon danger" title="Delete"> + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" /> + </svg> + </button> + </div> + </div> + + <div class="flex-1 overflow-y-auto p-6"> + <div class="max-w-2xl"> + <h2 class="text-lg font-medium text-zinc-100">{{ email.Subject }}</h2> + + <div class="flex items-start gap-3 mt-4"> + <div class="flex items-center justify-center w-10 h-10 rounded-full bg-accent-500/10 shrink-0"> + <span class="text-sm font-medium text-accent-400">{% if email.FromName %}{{ email.FromName|first|upper }}{% else %}{{ email.FromAddress|first|upper }}{% endif %}</span> + </div> + <div class="flex-1 min-w-0"> + <div class="flex items-center justify-between"> + <div> + <p class="text-sm font-medium text-zinc-200">{% if email.FromName %}{{ email.FromName }}{% else %}{{ email.FromAddress }}{% endif %}</p> + <p class="text-xs text-zinc-500">{{ email.FromAddress }}</p> + </div> + <p class="text-xs text-zinc-600">{{ email.CreatedAt|date:"Jan 2, 2006 3:04 PM" }}</p> + </div> + <div class="mt-1 text-xs text-zinc-500"> + <span>To: {{ email.ToAddresses }}</span> + {% if email.CcAddresses %} + <span class="ml-2">Cc: {{ email.CcAddresses }}</span> + {% endif %} + {% if email.BccAddresses %} + <span class="ml-2">Bcc: {{ email.BccAddresses }}</span> + {% endif %} + </div> + </div> + </div> + + <div class="mt-6 border-t border-white/[0.04] pt-6"> + <div class="prose prose-invert prose-sm max-w-none text-zinc-300 leading-relaxed">{% if email_body %}{{ email_body|safe }}{% else %}{{ email.Snippet }}{% endif %}</div> + </div> + + {% if email.AttachmentCount > 0 %} + <div class="mt-6 border-t border-white/[0.04] pt-4"> + <p class="text-xs text-zinc-500 mb-2">{{ email.AttachmentCount }} attachment{% if email.AttachmentCount > 1 %}s{% endif %}</p> + </div> + {% endif %} + </div> + </div> +</div> 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 @@ </svg> Mailboxes </a> + <a href="{{ webmail_path }}" hx-get="{{ webmail_path }}" class="nav-link flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors duration-150"> + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M9 3.75H6.912a2.25 2.25 0 0 0-2.15 1.588L2.35 13.177a2.25 2.25 0 0 0-.1.661V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 0 0-2.15-1.588H15M2.25 13.5h3.86a2.25 2.25 0 0 1 2.012 1.244l.256.512a2.25 2.25 0 0 0 2.013 1.244h2.21a2.25 2.25 0 0 0 2.013-1.244l.256-.512a2.25 2.25 0 0 1 2.013-1.244h3.859M12 3v8.25m0 0-3-3m3 3 3-3" /> + </svg> + WebMail + </a> </div> </div> </nav> 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." +) |
