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