package app import ( "context" "database/sql" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "net/textproto" "os" "path/filepath" "strconv" "strings" "time" "github.com/go-chi/chi/v5" ) // mailMessagesPageSize is the max number of messages returned per page in mail listing. const mailMessagesPageSize = 30 const customFolderDefaultSortOrderBase = 100000 type AttachmentInput struct { Filename string `json:"filename"` ContentType string `json:"contentType"` ContentBase64 string `json:"contentBase64"` } type storedMessage struct { MailboxID string FolderID string RecipientAddr string MessageUID string MessageID string Subject string From string FromName string To []string CC []string BCC []string SentAt time.Time ReceivedAt time.Time Snippet string BodyText string BodyHTML string IsRead bool IsStarred bool RawPath string Authentication MailAuthentication } func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) { user := currentUser(r) rows, err := a.db.QueryContext(r.Context(), `SELECT mb.id,mb.user_id,mb.domain_id,mb.local_part,mb.address,mb.display_name,mb.quota_mb,mb.status,mb.created_at FROM mailboxes mb JOIN domains d ON d.id=mb.domain_id WHERE mb.user_id=? AND mb.status='active' AND d.status='active' ORDER BY mb.address`, user.ID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to load mailboxes") return } defer rows.Close() items := []Mailbox{} for rows.Next() { var m Mailbox var created string if err := rows.Scan(&m.ID, &m.UserID, &m.DomainID, &m.LocalPart, &m.Address, &m.DisplayName, &m.QuotaMB, &m.Status, &created); err != nil { respondError(w, http.StatusInternalServerError, "failed to scan mailboxes") return } m.UserEmail = user.Email m.CreatedAt = parseTime(created) items = append(items, m) } respondJSON(w, http.StatusOK, map[string]any{"items": items}) } func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) { mb, err := a.mailboxForCurrentUser(r) if err != nil { respondError(w, http.StatusNotFound, "mailbox not found") return } rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role, COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread, COUNT(m.id) AS total, f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq FROM folders f LEFT JOIN messages m ON m.folder_id=f.id WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq ORDER BY CASE WHEN lower(f.name)='inbox' THEN 1000 WHEN lower(f.name)='sent' THEN 5000 WHEN lower(f.name)='drafts' THEN 6000 WHEN lower(f.name)='archive' THEN 7000 WHEN lower(f.name)='spam' THEN 8000 WHEN lower(f.name)='trash' THEN 9000 ELSE f.sort_order END, f.created_at,f.name`, mb.ID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to load folders") return } defer rows.Close() items := []MailFolder{} for rows.Next() { var f MailFolder if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil { respondError(w, http.StatusInternalServerError, "failed to scan folders") return } items = append(items, f) } respondJSON(w, http.StatusOK, map[string]any{"items": items}) } func (a *App) handleReorderMailFolders(w http.ResponseWriter, r *http.Request) { mb, err := a.mailboxForCurrentUser(r) if err != nil { respondError(w, http.StatusNotFound, "mailbox not found") return } var req struct { FolderIDs []string `json:"folderIds"` Folders []struct { ID string `json:"id"` SortOrder int `json:"sortOrder"` } `json:"folders"` } if err := decodeJSON(r, &req); err != nil { badRequest(w, err) return } seen := map[string]bool{} if len(req.Folders) == 0 { for i, id := range req.FolderIDs { id = strings.TrimSpace(id) req.Folders = append(req.Folders, struct { ID string `json:"id"` SortOrder int `json:"sortOrder"` }{ID: id, SortOrder: customFolderDefaultSortOrderBase + i + 1}) } } for i := range req.Folders { req.Folders[i].ID = strings.TrimSpace(req.Folders[i].ID) if req.Folders[i].ID == "" || req.Folders[i].SortOrder <= 0 || seen[req.Folders[i].ID] { badRequest(w, errors.New("invalid folder order")) return } seen[req.Folders[i].ID] = true } if len(req.Folders) == 0 { badRequest(w, errors.New("folderIds is required")) return } rows, err := a.db.QueryContext(r.Context(), `SELECT id,name FROM folders WHERE mailbox_id=?`, mb.ID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to load folders") return } customIDs := map[string]bool{} for rows.Next() { var id, name string if err := rows.Scan(&id, &name); err != nil { rows.Close() respondError(w, http.StatusInternalServerError, "failed to scan folders") return } if !isSystemFolderName(name) { customIDs[id] = true } } if err := rows.Err(); err != nil { rows.Close() respondError(w, http.StatusInternalServerError, "failed to scan folders") return } rows.Close() if len(customIDs) != len(req.Folders) { badRequest(w, errors.New("invalid folder order")) return } for _, item := range req.Folders { if !customIDs[item.ID] { badRequest(w, errors.New("invalid folder order")) return } } tx, err := a.db.BeginTx(r.Context(), nil) if err != nil { respondError(w, http.StatusInternalServerError, "failed to reorder folders") return } defer tx.Rollback() for _, item := range req.Folders { res, err := tx.ExecContext(r.Context(), `UPDATE folders SET sort_order=? WHERE id=? AND mailbox_id=?`, item.SortOrder, item.ID, mb.ID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to reorder folders") return } if affected, err := res.RowsAffected(); err != nil || affected != 1 { badRequest(w, errors.New("invalid folder order")) return } } if err := tx.Commit(); err != nil { respondError(w, http.StatusInternalServerError, "failed to reorder folders") return } respondJSON(w, http.StatusOK, map[string]any{"ok": true}) } func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) { mb, err := a.mailboxForCurrentUser(r) if err != nil { respondError(w, http.StatusNotFound, "mailbox not found") return } var req struct { Name string `json:"name"` } if err := decodeJSON(r, &req); err != nil { badRequest(w, err) return } name, err := normalizeCustomFolderName(req.Name) if err != nil { badRequest(w, err) return } if isSystemFolderName(name) { badRequest(w, errors.New("system folder already exists")) return } folderID, err := a.ensureCustomFolder(r.Context(), mb.ID, name) if err != nil { respondError(w, http.StatusInternalServerError, "failed to create folder") return } folder, err := a.folderByID(r.Context(), folderID, mb.ID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to load folder") return } respondJSON(w, http.StatusCreated, folder) } func (a *App) handleDeleteMailFolder(w http.ResponseWriter, r *http.Request) { mb, err := a.mailboxForCurrentUser(r) if err != nil { respondError(w, http.StatusNotFound, "mailbox not found") return } folderID := strings.TrimSpace(chi.URLParam(r, "id")) if folderID == "" { badRequest(w, errors.New("folder id is required")) return } var folderName string if err := a.db.QueryRowContext(r.Context(), `SELECT name FROM folders WHERE id=? AND mailbox_id=?`, folderID, mb.ID).Scan(&folderName); err != nil { if errors.Is(err, sql.ErrNoRows) { respondError(w, http.StatusNotFound, "folder not found") return } respondError(w, http.StatusInternalServerError, "failed to load folder") return } if isSystemFolderName(folderName) { badRequest(w, errors.New("system folders cannot be deleted")) return } inboxID, err := a.ensureFolder(r.Context(), mb.ID, "Inbox") if err != nil { respondError(w, http.StatusInternalServerError, "failed to load inbox") return } now := a.now().UTC().Format(time.RFC3339Nano) tx, err := a.db.BeginTx(r.Context(), nil) if err != nil { respondError(w, http.StatusInternalServerError, "failed to delete folder") return } defer tx.Rollback() rows, err := tx.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? ORDER BY received_at,id`, mb.ID, folderID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to load folder messages") return } var messageIDs []string for rows.Next() { var id string if err := rows.Scan(&id); err != nil { rows.Close() respondError(w, http.StatusInternalServerError, "failed to scan folder messages") return } messageIDs = append(messageIDs, id) } if err := rows.Err(); err != nil { rows.Close() respondError(w, http.StatusInternalServerError, "failed to scan folder messages") return } rows.Close() for _, messageID := range messageIDs { meta, err := a.nextIMAPMetadata(r.Context(), tx, inboxID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to allocate message uid") return } if _, err := tx.ExecContext(r.Context(), `UPDATE messages SET folder_id=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, inboxID, meta.UID, meta.ModSeq, now, messageID); err != nil { respondError(w, http.StatusInternalServerError, "failed to move folder messages") return } } if _, err := tx.ExecContext(r.Context(), `DELETE FROM folders WHERE id=? AND mailbox_id=?`, folderID, mb.ID); err != nil { respondError(w, http.StatusInternalServerError, "failed to delete folder") return } if err := tx.Commit(); err != nil { respondError(w, http.StatusInternalServerError, "failed to delete folder") return } _, _ = a.bumpFolderModSeq(r.Context(), inboxID) respondJSON(w, http.StatusOK, map[string]any{"ok": true, "moved": len(messageIDs)}) } func (a *App) ensureCustomFolder(ctx context.Context, mailboxID, name string) (string, error) { return a.ensureFolder(ctx, mailboxID, name) } func (a *App) nextCustomFolderSortOrder(ctx context.Context, mailboxID string) (int, error) { var maxOrder int if err := a.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(sort_order),0) FROM folders WHERE mailbox_id=? AND lower(name) NOT IN ('inbox','sent','drafts','archive','spam','trash')`, mailboxID).Scan(&maxOrder); err != nil { return 0, err } if maxOrder < customFolderDefaultSortOrderBase { maxOrder = customFolderDefaultSortOrderBase } return maxOrder + 1, nil } func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) { mb, err := a.mailboxForCurrentUser(r) if err != nil { respondError(w, http.StatusNotFound, "mailbox not found") return } if labelID := strings.TrimSpace(r.URL.Query().Get("labelId")); labelID != "" { if !a.labelBelongsToMailbox(r.Context(), labelID, mb.ID) { respondError(w, http.StatusNotFound, "label not found") return } a.respondMailMessageList(w, r, `m.mailbox_id=? AND EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)`, []any{mb.ID, labelID}) return } folder := r.URL.Query().Get("folder") if folder == "" { folder = "Inbox" } if normalized, err := normalizeFolderNameForUser(folder); err != nil { badRequest(w, err) return } else { folder = normalized } folderID, err := a.ensureFolder(r.Context(), mb.ID, folder) if err != nil { respondError(w, http.StatusInternalServerError, "failed to load folder") return } a.respondMailMessageList(w, r, `m.mailbox_id=? AND m.folder_id=?`, []any{mb.ID, folderID}) } func (a *App) handleStarredMessages(w http.ResponseWriter, r *http.Request) { mb, err := a.mailboxForCurrentUser(r) if err != nil { respondError(w, http.StatusNotFound, "mailbox not found") return } a.respondMailMessageList(w, r, `m.mailbox_id=? AND m.is_starred=1`, []any{mb.ID}) } func (a *App) respondMailMessageList(w http.ResponseWriter, r *http.Request, where string, args []any) { q := strings.TrimSpace(r.URL.Query().Get("q")) offset, _ := strconv.Atoi(r.URL.Query().Get("cursor")) if offset < 0 { offset = 0 } limit := mailMessagesPageSize if q != "" { where += ` AND (m.subject LIKE ? OR m.from_addr LIKE ? OR m.from_name LIKE ? OR m.snippet LIKE ? OR m.body_text LIKE ?)` like := "%" + q + "%" args = append(args, like, like, like, like, like) } args = append(args, limit+1, offset) query := `SELECT m.id,m.mailbox_id,m.folder_id,COALESCE(f.name,''),m.message_uid,m.imap_uid,m.imap_modseq,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE ` + where + ` ORDER BY m.received_at DESC LIMIT ? OFFSET ?` rows, err := a.db.QueryContext(r.Context(), query, args...) if err != nil { respondError(w, http.StatusInternalServerError, "failed to load messages") return } defer rows.Close() items := []MailMessage{} for rows.Next() { msg, err := scanMessageSummary(rows) if err != nil { respondError(w, http.StatusInternalServerError, "failed to scan messages") return } items = append(items, msg) } next := "" if len(items) > limit { items = items[:limit] next = strconv.Itoa(offset + limit) } if err := a.attachLabelsToMessages(r.Context(), items); err != nil { respondError(w, http.StatusInternalServerError, "failed to load labels") return } respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next}) } func (a *App) handleMailLabels(w http.ResponseWriter, r *http.Request) { mb, err := a.mailboxForCurrentUser(r) if err != nil { respondError(w, http.StatusNotFound, "mailbox not found") return } labels, err := a.labelsForMailbox(r.Context(), mb.ID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to load labels") return } respondJSON(w, http.StatusOK, map[string]any{"items": labels}) } func (a *App) handleCreateMailLabel(w http.ResponseWriter, r *http.Request) { mb, err := a.mailboxForCurrentUser(r) if err != nil { respondError(w, http.StatusNotFound, "mailbox not found") return } var req struct { Name string `json:"name"` Color string `json:"color"` } if err := decodeJSON(r, &req); err != nil { badRequest(w, err) return } label, err := a.ensureLabel(r.Context(), mb.ID, req.Name, req.Color) if err != nil { badRequest(w, err) return } respondJSON(w, http.StatusCreated, label) } func (a *App) handleDeleteMailLabel(w http.ResponseWriter, r *http.Request) { mb, err := a.mailboxForCurrentUser(r) if err != nil { respondError(w, http.StatusNotFound, "mailbox not found") return } labelID := chi.URLParam(r, "id") if labelID == "" { badRequest(w, fmt.Errorf("label id is required")) return } ctx := r.Context() if !a.labelBelongsToMailbox(ctx, labelID, mb.ID) { respondError(w, http.StatusNotFound, "label not found") return } tx, err := a.db.BeginTx(ctx, nil) if err != nil { respondError(w, http.StatusInternalServerError, "failed to begin transaction") return } defer tx.Rollback() if _, err := tx.ExecContext(ctx, "DELETE FROM message_labels WHERE label_id = ?", labelID); err != nil { respondError(w, http.StatusInternalServerError, "failed to remove label associations") return } result, err := tx.ExecContext(ctx, `DELETE FROM mail_labels WHERE id=? AND mailbox_id=?`, labelID, mb.ID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to delete label") return } if n, _ := result.RowsAffected(); n == 0 { respondError(w, http.StatusNotFound, "label not found") return } if err := tx.Commit(); err != nil { respondError(w, http.StatusInternalServerError, "failed to commit transaction") return } labels, err := a.labelsForMailbox(ctx, mb.ID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to load labels") return } respondJSON(w, http.StatusOK, map[string]any{"labels": labels}) } func (a *App) handleAddMessageLabel(w http.ResponseWriter, r *http.Request) { msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), false) if err != nil { respondError(w, http.StatusNotFound, "message not found") return } var req struct { Name string `json:"name"` Color string `json:"color"` } if err := decodeJSON(r, &req); err != nil { badRequest(w, err) return } label, err := a.ensureLabel(r.Context(), msg.MailboxID, req.Name, req.Color) if err != nil { badRequest(w, err) return } _, err = a.db.ExecContext(r.Context(), `INSERT OR IGNORE INTO message_labels(message_id,label_id,created_at) VALUES(?,?,?)`, msg.ID, label.ID, a.now().UTC().Format(time.RFC3339Nano)) if err != nil { respondError(w, http.StatusInternalServerError, "failed to add label") return } labels, err := a.labelsForMessage(r.Context(), msg.ID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to load labels") return } respondJSON(w, http.StatusOK, map[string]any{"labels": labels}) } func (a *App) handleRemoveMessageLabel(w http.ResponseWriter, r *http.Request) { msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), false) if err != nil { respondError(w, http.StatusNotFound, "message not found") return } labelID := strings.TrimSpace(chi.URLParam(r, "labelID")) if !a.labelBelongsToMailbox(r.Context(), labelID, msg.MailboxID) { respondError(w, http.StatusNotFound, "label not found") return } if _, err := a.db.ExecContext(r.Context(), `DELETE FROM message_labels WHERE message_id=? AND label_id=?`, msg.ID, labelID); err != nil { respondError(w, http.StatusInternalServerError, "failed to remove label") return } labels, err := a.labelsForMessage(r.Context(), msg.ID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to load labels") return } respondJSON(w, http.StatusOK, map[string]any{"labels": labels}) } func (a *App) handleMailMessage(w http.ResponseWriter, r *http.Request) { msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), true) if err != nil { respondError(w, http.StatusNotFound, "message not found") return } if r.URL.Query().Get("markRead") != "0" && !msg.IsRead && userHasPermission(currentUser(r), PermissionMailOrganize) { read := true if err := a.updateMessageMaildirFlags(r.Context(), msg.ID, &read, nil); err != nil { a.log.Warn("failed to update maildir read flag", "message_id", msg.ID, "error", err) } if modSeq, err := a.updateMessageModSeq(r.Context(), msg.ID, msg.FolderID); err == nil && modSeq > 0 { _, _ = a.db.ExecContext(r.Context(), `UPDATE messages SET is_read=1, imap_modseq=?, updated_at=? WHERE id=?`, modSeq, a.now().UTC().Format(time.RFC3339Nano), msg.ID) } else { _, _ = a.db.ExecContext(r.Context(), `UPDATE messages SET is_read=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), msg.ID) } msg.IsRead = true } respondJSON(w, http.StatusOK, msg) } type mailComposeInput struct { MailboxID string `json:"mailboxId"` From string `json:"from"` FromName string `json:"fromName"` To []string `json:"to"` CC []string `json:"cc"` BCC []string `json:"bcc"` Subject string `json:"subject"` Text string `json:"text"` HTML string `json:"html"` Attachments []AttachmentInput `json:"attachments"` } type mailDraftInput struct { MailboxID string `json:"mailboxId"` To []string `json:"to"` CC []string `json:"cc"` BCC []string `json:"bcc"` Subject string `json:"subject"` Text string `json:"text"` HTML string `json:"html"` Attachments *[]AttachmentInput `json:"attachments"` } type scheduledSendPayload struct { MailboxID string `json:"mailboxId"` From string `json:"from"` FromName string `json:"fromName"` To []string `json:"to"` CC []string `json:"cc"` BCC []string `json:"bcc"` Subject string `json:"subject"` Text string `json:"text"` HTML string `json:"html"` Attachments []AttachmentInput `json:"attachments"` DraftID string `json:"draftId,omitempty"` } type ScheduledSend struct { ID string `json:"id"` MailboxID string `json:"mailboxId"` DraftID string `json:"draftId,omitempty"` Subject string `json:"subject"` To []string `json:"to"` Snippet string `json:"snippet"` SendAt time.Time `json:"sendAt"` Status string `json:"status"` Error string `json:"error,omitempty"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` SentAt *time.Time `json:"sentAt,omitempty"` } func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) { var req mailComposeInput if err := decodeJSON(r, &req); err != nil { badRequest(w, err) return } mb, err := a.mailboxForCurrentUserWithID(r, req.MailboxID) if err != nil { respondError(w, http.StatusNotFound, "mailbox not found") return } msg, err := a.sendMailNow(r.Context(), currentUser(r), mb, req) if err != nil { if errors.Is(err, errNoRecipients) { badRequest(w, err) return } if errors.Is(err, errInvalidMIME) { badRequest(w, err) return } if errors.Is(err, errAttachmentTooLarge) { badRequest(w, err) return } if errors.Is(err, errSMTPRateLimited) { respondError(w, http.StatusTooManyRequests, err.Error()) return } if errors.Is(err, errSenderNotAuthorized) { respondError(w, http.StatusForbidden, err.Error()) return } if errors.Is(err, errMailboxQuotaExceeded) { respondError(w, http.StatusInsufficientStorage, err.Error()) return } respondError(w, http.StatusInternalServerError, err.Error()) return } respondJSON(w, http.StatusCreated, msg) } var errNoRecipients = errors.New("at least one recipient is required") var errInvalidMIME = errors.New("invalid mime message") var errAttachmentTooLarge = errors.New("attachment size exceeds permission limit") var errSMTPRateLimited = errors.New("smtp send rate limit exceeded") var errSenderNotAuthorized = errors.New("sender address is not authorized") var errMailboxQuotaExceeded = errors.New("mailbox quota exceeded") func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mailComposeInput) (*MailMessage, error) { if err := validateAttachmentLimit(req.Attachments, userLimits(user)); err != nil { return nil, err } req.To, req.CC, req.BCC = dedupeEmails(req.To), dedupeEmails(req.CC), dedupeEmails(req.BCC) allRecipients := append(append([]string{}, req.To...), append(req.CC, req.BCC...)...) if len(allRecipients) == 0 { return nil, errNoRecipients } if strings.TrimSpace(req.Subject) == "" { req.Subject = "(no subject)" } req.HTML = a.policy.Sanitize(req.HTML) if strings.TrimSpace(req.Text) == "" { req.Text = stripTags(req.HTML) } if strings.TrimSpace(req.HTML) == "" { req.HTML = "
" + htmlEscape(req.Text) + "
" } now := a.now().UTC() fromAddress, fromName, err := a.authorizedSender(ctx, mb, req.From) if err != nil { return nil, err } if strings.TrimSpace(req.FromName) != "" && normalizeEmail(req.From) == normalizeEmail(mb.Address) { fromName = strings.TrimSpace(req.FromName) } messageID := fmt.Sprintf("<%s@%s>", newID("msg"), strings.Split(mb.Address, "@")[1]) mimeBytes, err := BuildMIME(MIMEMessage{ From: fromAddress, FromName: fromName, To: req.To, CC: req.CC, BCC: req.BCC, Subject: req.Subject, Text: req.Text, HTML: req.HTML, MessageID: messageID, Date: now, Attachments: req.Attachments, }) if err != nil { return nil, fmt.Errorf("%w: %v", errInvalidMIME, err) } if err := a.recordSMTPRate(ctx, user, mb); err != nil { return nil, err } sentFolderID, err := a.ensureFolder(ctx, mb.ID, "Sent") if err != nil { return nil, fmt.Errorf("failed to load sent folder: %w", err) } base := storedMessage{MailboxID: mb.ID, FolderID: sentFolderID, MessageUID: newID("uid"), MessageID: messageID, Subject: req.Subject, From: fromAddress, FromName: fromName, To: req.To, CC: req.CC, BCC: req.BCC, SentAt: now, ReceivedAt: now, Snippet: snippetFrom(req.Text, req.HTML), BodyText: req.Text, BodyHTML: req.HTML, IsRead: true} sentID, err := a.insertMessage(ctx, base, req.Attachments) if err != nil { return nil, fmt.Errorf("failed to store sent message: %w", err) } if err := a.writeRawMessageToMaildir(ctx, sentID, mimeBytes, false); err != nil { a.deleteMessage(ctx, sentID) return nil, fmt.Errorf("failed to store sent message in maildir: %w", err) } a.recordSendAudit(ctx, sendAuditAccepted, sendQueueStatusQueued, sendAuditInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, Source: sendSourceWebmail, MailFrom: fromAddress, HeaderFrom: fromAddress, Recipients: allRecipients}) if _, err := a.enqueueSend(ctx, sendQueueInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, MessageID: messageID, Source: sendSourceWebmail, MailFrom: fromAddress, HeaderFrom: fromAddress, Recipients: allRecipients, MIMEBytes: mimeBytes, Now: now}); err != nil { a.deleteMessage(ctx, sentID) return nil, fmt.Errorf("failed to enqueue delivery: %w", err) } // Development/local-domain delivery: known local recipients go to their Inbox. // When catch-all is enabled, unknown local recipients are stored as unregistered // messages visible only in the admin "全部邮件" view. localRecipients := append(req.To, req.CC...) localRecipients = append(localRecipients, req.BCC...) for _, rcpt := range localRecipients { rcptMailbox, err := a.mailboxByAddress(ctx, rcpt) if err != nil { if !a.cfg.CatchAllEnabled || !a.isLocalDomainAddress(ctx, rcpt) { continue } copyMsg := base copyMsg.MailboxID = "" copyMsg.FolderID = "" copyMsg.RecipientAddr = normalizeEmail(rcpt) copyMsg.MessageUID = newID("uid") copyMsg.IsRead = false if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil { _ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments) } continue } if rcptMailbox.Status != "active" { if a.cfg.CatchAllEnabled && a.isLocalDomainAddress(ctx, rcpt) { copyMsg := base copyMsg.MailboxID = "" copyMsg.FolderID = "" copyMsg.RecipientAddr = normalizeEmail(rcpt) copyMsg.MessageUID = newID("uid") copyMsg.IsRead = false if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil { _ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments) } } continue } inboxID, err := a.ensureFolder(ctx, rcptMailbox.ID, "Inbox") if err != nil { continue } copyMsg := base copyMsg.MailboxID = rcptMailbox.ID copyMsg.FolderID = inboxID copyMsg.MessageUID = newID("uid") copyMsg.IsRead = false if inboxMsgID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil { _ = a.writeStoredMessageToMaildir(ctx, inboxMsgID, copyMsg, req.Attachments) a.applyInboundControls(ctx, inboxMsgID, rcptMailbox.ID, copyMsg.From, copyMsg.Subject) } } msg, _ := a.messageByID(ctx, sentID, true) return msg, nil } func userLimits(user *User) PermissionLimits { if user == nil { return defaultPermissionLimits() } return user.Limits } func validateAttachmentLimit(attachments []AttachmentInput, limits PermissionLimits) error { limitBytes := attachmentLimitBytes(limits) if limitBytes == 0 { return nil } for _, att := range attachments { decodedLen, err := decodedBase64Len(att.ContentBase64) if err != nil { return fmt.Errorf("%w: %v", errInvalidMIME, err) } if decodedLen > limitBytes { return fmt.Errorf("%w: max %d MB", errAttachmentTooLarge, limits.MaxAttachmentMB) } } return nil } func decodedBase64Len(value string) (int64, error) { value = strings.TrimSpace(value) if value == "" { return 0, nil } data, err := base64.StdEncoding.DecodeString(value) if err != nil { return 0, err } return int64(len(data)), nil } var errIMAPRateLimited = errors.New("imap rate limit exceeded") var errPOP3RateLimited = errors.New("pop3 rate limit exceeded") func (a *App) checkAndRecordProtocolRate(ctx context.Context, user *User, mb *Mailbox, table string, dailyLimit, minuteLimit int) error { if dailyLimit == 0 && minuteLimit == 0 { return nil } now := a.now().UTC() tx, err := a.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() if dailyLimit > 0 { var count int if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+table+" WHERE user_id=? AND created_at>=?", user.ID, now.Add(-24*time.Hour).Format(time.RFC3339Nano)).Scan(&count); err != nil { return err } if count >= dailyLimit { return fmt.Errorf("daily limit %d", dailyLimit) } } if minuteLimit > 0 { var count int if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+table+" WHERE user_id=? AND created_at>=?", user.ID, now.Add(-time.Minute).Format(time.RFC3339Nano)).Scan(&count); err != nil { return err } if count >= minuteLimit { return fmt.Errorf("per-minute limit %d", minuteLimit) } } if _, err := tx.ExecContext(ctx, "INSERT INTO "+table+"(id,user_id,mailbox_id,created_at) VALUES(?,?,?,?)", newID("evt"), user.ID, mb.ID, now.Format(time.RFC3339Nano)); err != nil { return err } return tx.Commit() } func (a *App) handleAuthPolicy(w http.ResponseWriter, r *http.Request) { var req struct { Login string `json:"login"` Protocol string `json:"protocol"` Username string `json:"username"` IP string `json:"ip"` Remote string `json:"remote"` Success *bool `json:"success"` PolicyReject *bool `json:"policy_reject"` } if err := decodeJSON(r, &req); err != nil { respondJSON(w, http.StatusOK, map[string]int{"status": 0}) return } var user *User var mailbox *Mailbox login := normalizeEmail(req.Login) if login == "" { login = normalizeEmail(req.Username) } if login != "" { if mb, err := a.mailboxByAddress(r.Context(), login); err == nil { mailbox = mb user, _ = a.userByID(r.Context(), mb.UserID) } if user == nil { var passHash string user, passHash, _ = a.userByEmail(r.Context(), login) _ = passHash } } if user == nil || user.Disabled { respondJSON(w, http.StatusOK, map[string]any{"status": -1, "msg": "user not found or disabled"}) return } if user.Role == "admin" { respondJSON(w, http.StatusOK, map[string]int{"status": 0}) return } limits := user.Limits var err error switch strings.ToLower(req.Protocol) { case "imap": if limits.IMAPMinuteLimit > 0 { if mailbox == nil { err = errors.New("mailbox not found") } else { err = a.checkAndRecordProtocolRate(r.Context(), user, mailbox, "imap_events", 0, limits.IMAPMinuteLimit) } } case "pop3": if limits.POP3MinuteLimit > 0 { if mailbox == nil { err = errors.New("mailbox not found") } else { err = a.checkAndRecordProtocolRate(r.Context(), user, mailbox, "pop3_events", 0, limits.POP3MinuteLimit) } } } if err != nil { respondJSON(w, http.StatusOK, map[string]any{"status": -1, "msg": err.Error()}) } else { respondJSON(w, http.StatusOK, map[string]int{"status": 0}) } } func (a *App) recordSMTPRate(ctx context.Context, user *User, mb *Mailbox) error { if user == nil || mb == nil || user.Role == "admin" { return nil } limits := user.Limits if limits.SMTPDailyLimit == 0 && limits.SMTPMinuteLimit == 0 { return nil } now := a.now().UTC() tx, err := a.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() if limits.SMTPDailyLimit > 0 { var count int if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM smtp_send_events WHERE user_id=? AND created_at>=?`, user.ID, now.Add(-24*time.Hour).Format(time.RFC3339Nano)).Scan(&count); err != nil { return err } if count >= limits.SMTPDailyLimit { return fmt.Errorf("%w: daily limit %d", errSMTPRateLimited, limits.SMTPDailyLimit) } } if limits.SMTPMinuteLimit > 0 { var count int if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM smtp_send_events WHERE user_id=? AND created_at>=?`, user.ID, now.Add(-time.Minute).Format(time.RFC3339Nano)).Scan(&count); err != nil { return err } if count >= limits.SMTPMinuteLimit { return fmt.Errorf("%w: per-minute limit %d", errSMTPRateLimited, limits.SMTPMinuteLimit) } } if _, err := tx.ExecContext(ctx, `INSERT INTO smtp_send_events(id,user_id,mailbox_id,created_at) VALUES(?,?,?,?)`, newID("smtp"), user.ID, mb.ID, now.Format(time.RFC3339Nano)); err != nil { return err } return tx.Commit() } func (a *App) handleSaveDraft(w http.ResponseWriter, r *http.Request) { var req mailDraftInput if err := decodeJSON(r, &req); err != nil { badRequest(w, err) return } mb, err := a.mailboxForCurrentUserWithID(r, req.MailboxID) if err != nil { respondError(w, http.StatusNotFound, "mailbox not found") return } if req.Attachments != nil { if err := validateAttachmentLimit(*req.Attachments, userLimits(currentUser(r))); err != nil { if errors.Is(err, errAttachmentTooLarge) || errors.Is(err, errInvalidMIME) { badRequest(w, err) return } respondError(w, http.StatusInternalServerError, err.Error()) return } } compose := mailComposeInput{MailboxID: req.MailboxID, To: req.To, CC: req.CC, BCC: req.BCC, Subject: req.Subject, Text: req.Text, HTML: req.HTML} compose.To, compose.CC, compose.BCC = dedupeEmails(compose.To), dedupeEmails(compose.CC), dedupeEmails(compose.BCC) subject := strings.TrimSpace(compose.Subject) if subject == "" { subject = "(无主题)" } compose.HTML = a.policy.Sanitize(compose.HTML) if strings.TrimSpace(compose.Text) == "" { compose.Text = stripTags(compose.HTML) } if strings.TrimSpace(compose.HTML) == "" && strings.TrimSpace(compose.Text) != "" { compose.HTML = "" + htmlEscape(compose.Text) + "
" } now := a.now().UTC() draftID := strings.TrimSpace(chi.URLParam(r, "id")) draftsFolderID, err := a.ensureFolder(r.Context(), mb.ID, "Drafts") if err != nil { respondError(w, http.StatusInternalServerError, "failed to load drafts folder") return } if draftID == "" { messageID := fmt.Sprintf("<%s@%s>", newID("draft"), strings.Split(mb.Address, "@")[1]) attachments := []AttachmentInput{} if req.Attachments != nil { attachments = *req.Attachments } stored := storedMessage{MailboxID: mb.ID, FolderID: draftsFolderID, MessageUID: newID("uid"), MessageID: messageID, Subject: subject, From: mb.Address, FromName: mb.DisplayName, To: compose.To, CC: compose.CC, BCC: compose.BCC, SentAt: now, ReceivedAt: now, Snippet: snippetFrom(compose.Text, compose.HTML), BodyText: compose.Text, BodyHTML: compose.HTML, IsRead: true} draftID, err = a.insertMessage(r.Context(), stored, attachments) if err != nil { respondError(w, http.StatusInternalServerError, "failed to save draft") return } if err := a.writeStoredMessageToMaildir(r.Context(), draftID, stored, attachments); err != nil { a.deleteMessage(r.Context(), draftID) respondError(w, http.StatusInternalServerError, "failed to save draft") return } msg, _ := a.messageByID(r.Context(), draftID, true) respondJSON(w, http.StatusCreated, msg) return } existing, err := a.loadMessageForRequest(r, draftID, false) if err != nil || !strings.EqualFold(existing.Folder, "Drafts") || existing.MailboxID != mb.ID { respondError(w, http.StatusNotFound, "draft not found") return } size := int64(len(compose.Text) + len(compose.HTML)) hasAttachments := existing.HasAttachments if req.Attachments != nil { a.deleteMessageFiles(r.Context(), draftID) if _, err := a.db.ExecContext(r.Context(), `DELETE FROM attachments WHERE message_id=?`, draftID); err != nil { respondError(w, http.StatusInternalServerError, "failed to replace draft attachments") return } hasAttachments = len(*req.Attachments) > 0 for _, att := range *req.Attachments { if decoded, err := base64.StdEncoding.DecodeString(att.ContentBase64); err == nil { size += int64(len(decoded)) } } } else { var attachmentBytes int64 _ = a.db.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(size_bytes),0) FROM attachments WHERE message_id=?`, draftID).Scan(&attachmentBytes) size += attachmentBytes } modSeq, err := a.updateMessageModSeq(r.Context(), draftID, existing.FolderID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to update draft") return } _, err = a.db.ExecContext(r.Context(), `UPDATE messages SET subject=?,to_addrs=?,cc_addrs=?,bcc_addrs=?,sent_at=?,received_at=?,snippet=?,body_text=?,body_html=?,is_read=1,has_attachments=?,size_bytes=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`, subject, jsonEncode(compose.To), jsonEncode(compose.CC), jsonEncode(compose.BCC), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), snippetFrom(compose.Text, compose.HTML), compose.Text, compose.HTML, boolInt(hasAttachments), size, modSeq, modSeq, now.Format(time.RFC3339Nano), draftID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to update draft") return } if req.Attachments != nil { for _, att := range *req.Attachments { if err := a.storeAttachment(r.Context(), draftID, att); err != nil { respondError(w, http.StatusInternalServerError, "failed to store draft attachment") return } } } if err := a.rewriteMessageMaildir(r.Context(), draftID); err != nil { respondError(w, http.StatusInternalServerError, "failed to update draft") return } msg, _ := a.messageByID(r.Context(), draftID, true) respondJSON(w, http.StatusOK, msg) } func (a *App) handleDeleteDraft(w http.ResponseWriter, r *http.Request) { msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), false) if err != nil || !strings.EqualFold(msg.Folder, "Drafts") { respondError(w, http.StatusNotFound, "draft not found") return } a.deleteMessageMaildirFile(r.Context(), msg.ID) a.deleteMessageFiles(r.Context(), msg.ID) if _, err := a.db.ExecContext(r.Context(), `DELETE FROM messages WHERE id=?`, msg.ID); err != nil { respondError(w, http.StatusInternalServerError, "failed to delete draft") return } _, _ = a.bumpFolderModSeq(r.Context(), msg.FolderID) respondJSON(w, http.StatusOK, map[string]any{"ok": true}) } func (a *App) handleScheduledSends(w http.ResponseWriter, r *http.Request) { user := currentUser(r) mb, err := a.mailboxForCurrentUser(r) if err != nil { respondError(w, http.StatusNotFound, "mailbox not found") return } rows, err := a.db.QueryContext(r.Context(), `SELECT id,mailbox_id,draft_id,payload_json,send_at,status,error,created_at,updated_at,sent_at FROM scheduled_sends WHERE user_id=? AND mailbox_id=? AND status IN ('pending','sending','failed') ORDER BY send_at ASC, created_at DESC`, user.ID, mb.ID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to load scheduled sends") return } defer rows.Close() items := []ScheduledSend{} for rows.Next() { var item ScheduledSend var draftID, errorText, sentAt sql.NullString var payloadJSON, sendAt, createdAt, updatedAt string if err := rows.Scan(&item.ID, &item.MailboxID, &draftID, &payloadJSON, &sendAt, &item.Status, &errorText, &createdAt, &updatedAt, &sentAt); err != nil { respondError(w, http.StatusInternalServerError, "failed to scan scheduled sends") return } if draftID.Valid { item.DraftID = draftID.String } if errorText.Valid { item.Error = errorText.String } item.SendAt = parseTime(sendAt) item.CreatedAt = parseTime(createdAt) item.UpdatedAt = parseTime(updatedAt) item.SentAt = nullableTime(sentAt) applyScheduledSendPreview(&item, payloadJSON) items = append(items, item) } if err := rows.Err(); err != nil { respondError(w, http.StatusInternalServerError, "failed to load scheduled sends") return } respondJSON(w, http.StatusOK, map[string]any{"items": items}) } func (a *App) handleSendQueue(w http.ResponseWriter, r *http.Request) { user := currentUser(r) mb, err := a.mailboxForCurrentUser(r) if err != nil { respondError(w, http.StatusNotFound, "mailbox not found") return } status := strings.TrimSpace(r.URL.Query().Get("status")) cursorCreatedAt, cursorID, offsetCursor, err := parseSendQueueCursor(r.URL.Query().Get("cursor")) if err != nil { badRequest(w, err) return } limit := 30 args := []any{user.ID, mb.ID} where := `mb.user_id=? AND sq.mailbox_id=?` if status != "" { if !validSendQueueStatus(status) { badRequest(w, errors.New("invalid send queue status")) return } where += ` AND sq.status=?` args = append(args, status) } if messageID := strings.TrimSpace(r.URL.Query().Get("messageId")); messageID != "" { where += ` AND (sq.message_id=? OR sq.sent_message_id=? OR m.message_id=?)` args = append(args, messageID, messageID, messageID) } if recipient := normalizeEmail(r.URL.Query().Get("recipient")); recipient != "" { where += ` AND sq.recipients_json LIKE ?` args = append(args, "%"+recipient+"%") } if from := strings.TrimSpace(r.URL.Query().Get("from")); from != "" { t, err := parseTimeQuery(from) if err != nil { badRequest(w, errors.New("invalid from time")) return } where += ` AND sq.created_at>=?` args = append(args, t.UTC().Format(time.RFC3339Nano)) } if to := strings.TrimSpace(r.URL.Query().Get("to")); to != "" { t, err := parseTimeQuery(to) if err != nil { badRequest(w, errors.New("invalid to time")) return } where += ` AND sq.created_at<=?` args = append(args, t.UTC().Format(time.RFC3339Nano)) } if cursorCreatedAt != "" && cursorID != "" { where += ` AND (sq.created_at < ? OR (sq.created_at = ? AND sq.id < ?))` args = append(args, cursorCreatedAt, cursorCreatedAt, cursorID) } args = append(args, limit+1) query := `SELECT sq.id,sq.mailbox_id,sq.sent_message_id,sq.message_id,COALESCE(m.subject,''),sq.source,sq.mail_from,sq.header_from,sq.recipients_json,sq.status,sq.attempt_count,sq.max_attempts,sq.next_attempt_at,sq.last_error,sq.created_at,sq.updated_at,sq.delivered_at FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id LEFT JOIN messages m ON m.id=sq.sent_message_id WHERE ` + where + ` ORDER BY sq.created_at DESC, sq.id DESC LIMIT ?` if offsetCursor > 0 { args = append(args, offsetCursor) query += ` OFFSET ?` } rows, err := a.db.QueryContext(r.Context(), query, args...) if err != nil { respondError(w, http.StatusInternalServerError, "failed to load send queue") return } defer rows.Close() items := []SendQueueEntry{} for rows.Next() { item, err := scanSendQueueEntry(rows) if err != nil { respondError(w, http.StatusInternalServerError, "failed to scan send queue") return } items = append(items, item) } if err := rows.Err(); err != nil { respondError(w, http.StatusInternalServerError, "failed to load send queue") return } next := "" if len(items) > limit { items = items[:limit] last := items[len(items)-1] next = encodeSendQueueCursor(last.CreatedAt, last.ID) } respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next}) } func (a *App) handleSendQueueAudit(w http.ResponseWriter, r *http.Request) { user := currentUser(r) id := strings.TrimSpace(chi.URLParam(r, "id")) if !a.sendQueueBelongsToUser(r.Context(), id, user.ID) { respondError(w, http.StatusNotFound, "send queue item not found") return } rows, err := a.db.QueryContext(r.Context(), `SELECT id,queue_id,mailbox_id,sent_message_id,source,event,status,mail_from,header_from,recipients_json,error,created_at FROM send_audit_events WHERE queue_id=? ORDER BY created_at ASC, id ASC`, id) if err != nil { respondError(w, http.StatusInternalServerError, "failed to load send audit") return } defer rows.Close() items := []SendAuditEvent{} for rows.Next() { var item SendAuditEvent var recipientsJSON, createdAt string if err := rows.Scan(&item.ID, &item.QueueID, &item.MailboxID, &item.SentMessageID, &item.Source, &item.Event, &item.Status, &item.MailFrom, &item.HeaderFrom, &recipientsJSON, &item.Error, &createdAt); err != nil { respondError(w, http.StatusInternalServerError, "failed to scan send audit") return } item.Recipients = jsonDecodeSlice(recipientsJSON) item.CreatedAt = parseTime(createdAt) items = append(items, item) } if err := rows.Err(); err != nil { respondError(w, http.StatusInternalServerError, "failed to load send audit") return } respondJSON(w, http.StatusOK, map[string]any{"items": items}) } func (a *App) handleRetrySendQueue(w http.ResponseWriter, r *http.Request) { user := currentUser(r) id := strings.TrimSpace(chi.URLParam(r, "id")) item, err := a.loadSendQueueEntryForUser(r.Context(), id, user.ID) if err != nil { respondError(w, http.StatusNotFound, "send queue item not found") return } if item.Status != sendQueueStatusFailed { badRequest(w, errors.New("send queue item is not failed")) return } now := a.now().UTC().Format(time.RFC3339Nano) res, err := a.db.ExecContext(r.Context(), `UPDATE send_queue SET status=?,attempt_count=0,next_attempt_at=?,last_error='',updated_at=?,delivered_at=NULL WHERE id=? AND status=? AND EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=send_queue.mailbox_id AND mb.user_id=?)`, sendQueueStatusQueued, now, now, id, sendQueueStatusFailed, user.ID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to retry send queue item") return } if n, _ := res.RowsAffected(); n == 0 { respondError(w, http.StatusNotFound, "send queue item not found") return } a.deleteSendQueueDeliveredMarker(id) a.recordSendAudit(r.Context(), sendAuditRetry, sendQueueStatusQueued, sendAuditInput{ QueueID: item.ID, UserID: user.ID, MailboxID: item.MailboxID, SentMessageID: item.SentMessageID, Source: item.Source, MailFrom: item.MailFrom, HeaderFrom: item.HeaderFrom, Recipients: item.Recipients, }) updated, err := a.loadSendQueueEntryForUser(r.Context(), id, user.ID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to load send queue item") return } respondJSON(w, http.StatusOK, updated) } func (a *App) handleCancelSendQueue(w http.ResponseWriter, r *http.Request) { user := currentUser(r) id := strings.TrimSpace(chi.URLParam(r, "id")) item, err := a.loadSendQueueEntryForUser(r.Context(), id, user.ID) if err != nil { respondError(w, http.StatusNotFound, "send queue item not found") return } if item.Status != sendQueueStatusQueued && item.Status != sendQueueStatusFailed { badRequest(w, errors.New("send queue item cannot be canceled")) return } now := a.now().UTC().Format(time.RFC3339Nano) res, err := a.db.ExecContext(r.Context(), `UPDATE send_queue SET status=?,last_error='',updated_at=? WHERE id=? AND status IN (?,?) AND EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=send_queue.mailbox_id AND mb.user_id=?)`, sendQueueStatusCanceled, now, id, sendQueueStatusQueued, sendQueueStatusFailed, user.ID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to cancel send queue item") return } if n, _ := res.RowsAffected(); n == 0 { respondError(w, http.StatusNotFound, "send queue item not found") return } a.deleteSendQueueDeliveredMarker(id) a.recordSendAudit(r.Context(), sendAuditCanceled, sendQueueStatusCanceled, sendAuditInput{ QueueID: item.ID, UserID: user.ID, MailboxID: item.MailboxID, SentMessageID: item.SentMessageID, Source: item.Source, MailFrom: item.MailFrom, HeaderFrom: item.HeaderFrom, Recipients: item.Recipients, }) updated, err := a.loadSendQueueEntryForUser(r.Context(), id, user.ID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to load send queue item") return } respondJSON(w, http.StatusOK, updated) } type sendQueueEntryScanner interface{ Scan(dest ...any) error } func scanSendQueueEntry(row sendQueueEntryScanner) (SendQueueEntry, error) { var item SendQueueEntry var recipientsJSON, nextAttemptAt, createdAt, updatedAt string var deliveredAt sql.NullString err := row.Scan(&item.ID, &item.MailboxID, &item.SentMessageID, &item.MessageID, &item.Subject, &item.Source, &item.MailFrom, &item.HeaderFrom, &recipientsJSON, &item.Status, &item.AttemptCount, &item.MaxAttempts, &nextAttemptAt, &item.LastError, &createdAt, &updatedAt, &deliveredAt) if err != nil { return item, err } item.Recipients = jsonDecodeSlice(recipientsJSON) item.NextAttemptAt = parseTime(nextAttemptAt) item.CreatedAt = parseTime(createdAt) item.UpdatedAt = parseTime(updatedAt) item.DeliveredAt = nullableTime(deliveredAt) return item, nil } func (a *App) loadSendQueueEntryForUser(ctx context.Context, id, userID string) (SendQueueEntry, error) { row := a.db.QueryRowContext(ctx, `SELECT sq.id,sq.mailbox_id,sq.sent_message_id,sq.message_id,COALESCE(m.subject,''),sq.source,sq.mail_from,sq.header_from,sq.recipients_json,sq.status,sq.attempt_count,sq.max_attempts,sq.next_attempt_at,sq.last_error,sq.created_at,sq.updated_at,sq.delivered_at FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id LEFT JOIN messages m ON m.id=sq.sent_message_id WHERE sq.id=? AND mb.user_id=?`, id, userID) return scanSendQueueEntry(row) } type sendQueueCursor struct { CreatedAt string `json:"createdAt"` ID string `json:"id"` } func encodeSendQueueCursor(createdAt time.Time, id string) string { payload, _ := json.Marshal(sendQueueCursor{CreatedAt: createdAt.UTC().Format(time.RFC3339Nano), ID: id}) return base64.RawURLEncoding.EncodeToString(payload) } func parseSendQueueCursor(raw string) (createdAt string, id string, offset int, err error) { raw = strings.TrimSpace(raw) if raw == "" { return "", "", 0, nil } if n, convErr := strconv.Atoi(raw); convErr == nil { if n < 0 { return "", "", 0, errors.New("invalid cursor") } return "", "", n, nil } data, decodeErr := base64.RawURLEncoding.DecodeString(raw) if decodeErr != nil { return "", "", 0, errors.New("invalid cursor") } var cursor sendQueueCursor if err := json.Unmarshal(data, &cursor); err != nil { return "", "", 0, errors.New("invalid cursor") } t, err := parseTimeQuery(cursor.CreatedAt) if err != nil || strings.TrimSpace(cursor.ID) == "" { return "", "", 0, errors.New("invalid cursor") } return t.UTC().Format(time.RFC3339Nano), strings.TrimSpace(cursor.ID), 0, nil } func (a *App) sendQueueBelongsToUser(ctx context.Context, id, userID string) bool { var count int _ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id WHERE sq.id=? AND mb.user_id=?`, id, userID).Scan(&count) return count > 0 } func parseTimeQuery(raw string) (time.Time, error) { raw = strings.TrimSpace(raw) if raw == "" { return time.Time{}, errors.New("time is required") } if t, err := time.Parse(time.RFC3339Nano, raw); err == nil { return t, nil } if t, err := time.Parse("2006-01-02", raw); err == nil { return t, nil } return time.Time{}, errors.New("invalid time") } func validSendQueueStatus(status string) bool { switch status { case sendQueueStatusQueued, sendQueueStatusSending, sendQueueStatusDelivered, sendQueueStatusFailed, sendQueueStatusCanceled: return true default: return false } } func (a *App) handleScheduleSend(w http.ResponseWriter, r *http.Request) { var req struct { MailboxID string `json:"mailboxId"` To []string `json:"to"` CC []string `json:"cc"` BCC []string `json:"bcc"` Subject string `json:"subject"` Text string `json:"text"` HTML string `json:"html"` Attachments []AttachmentInput `json:"attachments"` DraftID string `json:"draftId"` SendAt string `json:"sendAt"` } if err := decodeJSON(r, &req); err != nil { badRequest(w, err) return } mb, err := a.mailboxForCurrentUserWithID(r, req.MailboxID) if err != nil { respondError(w, http.StatusNotFound, "mailbox not found") return } sendAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(req.SendAt)) if err != nil { badRequest(w, errors.New("sendAt is required")) return } if !sendAt.After(a.now().Add(30 * time.Second)) { badRequest(w, errors.New("sendAt must be in the future")) return } compose := mailComposeInput{MailboxID: req.MailboxID, To: req.To, CC: req.CC, BCC: req.BCC, Subject: req.Subject, Text: req.Text, HTML: req.HTML, Attachments: req.Attachments} if err := validateAttachmentLimit(compose.Attachments, userLimits(currentUser(r))); err != nil { if errors.Is(err, errAttachmentTooLarge) || errors.Is(err, errInvalidMIME) { badRequest(w, err) return } respondError(w, http.StatusInternalServerError, err.Error()) return } compose.To, compose.CC, compose.BCC = dedupeEmails(compose.To), dedupeEmails(compose.CC), dedupeEmails(compose.BCC) if len(append(append([]string{}, compose.To...), append(compose.CC, compose.BCC...)...)) == 0 { badRequest(w, errNoRecipients) return } compose.HTML = a.policy.Sanitize(compose.HTML) if strings.TrimSpace(compose.Text) == "" { compose.Text = stripTags(compose.HTML) } if strings.TrimSpace(compose.HTML) == "" { compose.HTML = "" + htmlEscape(compose.Text) + "
" } if strings.TrimSpace(compose.Subject) == "" { compose.Subject = "(no subject)" } draftID := strings.TrimSpace(req.DraftID) if draftID != "" { msg, err := a.loadMessageForRequest(r, draftID, false) if err != nil || !strings.EqualFold(msg.Folder, "Drafts") || msg.MailboxID != mb.ID { respondError(w, http.StatusNotFound, "draft not found") return } } now := a.now().UTC().Format(time.RFC3339Nano) payload := scheduledSendPayload{MailboxID: compose.MailboxID, From: compose.From, FromName: compose.FromName, To: compose.To, CC: compose.CC, BCC: compose.BCC, Subject: compose.Subject, Text: compose.Text, HTML: compose.HTML, Attachments: compose.Attachments, DraftID: draftID} item := ScheduledSend{ID: newID("sched"), MailboxID: mb.ID, DraftID: draftID, Subject: payload.Subject, To: payload.To, Snippet: snippetFrom(payload.Text, payload.HTML), SendAt: sendAt.UTC(), Status: "pending", CreatedAt: parseTime(now), UpdatedAt: parseTime(now)} if _, err := a.db.ExecContext(r.Context(), `INSERT INTO scheduled_sends(id,user_id,mailbox_id,draft_id,payload_json,send_at,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)`, item.ID, currentUser(r).ID, mb.ID, nullableString(draftID), jsonEncode(payload), item.SendAt.Format(time.RFC3339Nano), item.Status, now, now); err != nil { respondError(w, http.StatusInternalServerError, "failed to schedule send") return } respondJSON(w, http.StatusCreated, item) } func applyScheduledSendPreview(item *ScheduledSend, payloadJSON string) { var payload scheduledSendPayload if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil { item.Subject = "(no subject)" return } item.Subject = strings.TrimSpace(payload.Subject) if item.Subject == "" { item.Subject = "(no subject)" } item.To = payload.To item.Snippet = snippetFrom(payload.Text, payload.HTML) } func (a *App) handleCancelScheduledSend(w http.ResponseWriter, r *http.Request) { user := currentUser(r) id := strings.TrimSpace(chi.URLParam(r, "id")) var status string if err := a.db.QueryRowContext(r.Context(), `SELECT status FROM scheduled_sends WHERE id=? AND user_id=?`, id, user.ID).Scan(&status); err != nil { respondError(w, http.StatusNotFound, "scheduled send not found") return } if status != "pending" && status != "failed" { badRequest(w, errors.New("scheduled send is not pending")) return } if _, err := a.db.ExecContext(r.Context(), `UPDATE scheduled_sends SET status='cancelled',updated_at=? WHERE id=? AND user_id=?`, a.now().UTC().Format(time.RFC3339Nano), id, user.ID); err != nil { respondError(w, http.StatusInternalServerError, "failed to cancel scheduled send") return } respondJSON(w, http.StatusOK, map[string]any{"ok": true}) } func (a *App) scheduledSendWorker(ctx context.Context) { a.log.Info("scheduled send worker started") ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() for { if err := a.processDueScheduledSends(ctx); err != nil { a.log.Warn("scheduled send worker failed", "error", err) } select { case <-ctx.Done(): a.log.Info("scheduled send worker stopped") return case <-ticker.C: } } } func (a *App) smtpEventsCleanupWorker(ctx context.Context) { a.log.Info("smtp events cleanup worker started") ticker := time.NewTicker(10 * time.Minute) defer ticker.Stop() for { select { case <-ctx.Done(): a.log.Info("smtp events cleanup worker stopped") return case <-ticker.C: a.cleanupStaleEvents(ctx) } } } func (a *App) cleanupStaleEvents(ctx context.Context) { cutoff := a.now().UTC().Add(-24 * time.Hour).Format(time.RFC3339Nano) for _, table := range []string{"smtp_send_events", "imap_events", "pop3_events"} { result, err := a.db.ExecContext(ctx, "DELETE FROM "+table+" WHERE created_at", cutoff) if err != nil { a.log.Warn("event cleanup failed", "table", table, "error", err) } else if n, _ := result.RowsAffected(); n > 0 { a.log.Debug("event cleanup deleted rows", "table", table, "count", n) } } } func (a *App) processDueScheduledSends(ctx context.Context) error { rows, err := a.db.QueryContext(ctx, `SELECT id,mailbox_id,draft_id,payload_json FROM scheduled_sends WHERE status='pending' AND send_at<=? ORDER BY send_at LIMIT 20`, a.now().UTC().Format(time.RFC3339Nano)) if err != nil { return err } defer rows.Close() type dueItem struct { id, mailboxID, draftID, payloadJSON string } items := []dueItem{} for rows.Next() { var item dueItem var draftID sql.NullString if err := rows.Scan(&item.id, &item.mailboxID, &draftID, &item.payloadJSON); err != nil { return err } if draftID.Valid { item.draftID = draftID.String } items = append(items, item) } for _, item := range items { a.processScheduledSend(ctx, item.id, item.mailboxID, item.draftID, item.payloadJSON) } return rows.Err() } func (a *App) processScheduledSend(ctx context.Context, id, mailboxID, draftID, payloadJSON string) { now := a.now().UTC().Format(time.RFC3339Nano) res, err := a.db.ExecContext(ctx, `UPDATE scheduled_sends SET status='sending',updated_at=? WHERE id=? AND status='pending'`, now, id) if err != nil { a.log.Warn("failed to claim scheduled send", "id", id, "error", err) return } if affected, _ := res.RowsAffected(); affected == 0 { return } var payload scheduledSendPayload if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil { a.markScheduledSendFailed(ctx, id, "invalid scheduled payload") return } mb, err := a.mailboxByID(ctx, mailboxID) if err != nil || mb.Status != "active" { a.markScheduledSendFailed(ctx, id, "mailbox not found") return } user, err := a.userByID(ctx, mb.UserID) if err != nil || !userHasPermission(user, PermissionMailSchedule) || !userHasPermission(user, PermissionMailSend) { a.markScheduledSendFailed(ctx, id, "mail send permission revoked") return } compose := mailComposeInput{MailboxID: payload.MailboxID, To: payload.To, CC: payload.CC, BCC: payload.BCC, Subject: payload.Subject, Text: payload.Text, HTML: payload.HTML, Attachments: payload.Attachments} if _, err := a.sendMailNow(ctx, user, mb, compose); err != nil { a.markScheduledSendFailed(ctx, id, err.Error()) return } if draftID != "" { a.deleteMessage(ctx, draftID) } sentAt := a.now().UTC().Format(time.RFC3339Nano) if _, err := a.db.ExecContext(ctx, `UPDATE scheduled_sends SET status='sent',sent_at=?,updated_at=?,error='' WHERE id=?`, sentAt, sentAt, id); err != nil { a.log.Warn("failed to mark scheduled send sent", "id", id, "error", err) } } func (a *App) markScheduledSendFailed(ctx context.Context, id, message string) { if _, err := a.db.ExecContext(ctx, `UPDATE scheduled_sends SET status='failed',error=?,updated_at=? WHERE id=?`, message, a.now().UTC().Format(time.RFC3339Nano), id); err != nil { a.log.Warn("failed to mark scheduled send failed", "id", id, "error", err) } } func (a *App) isLocalDomainAddress(ctx context.Context, address string) bool { parts := strings.Split(normalizeEmail(address), "@") if len(parts) != 2 || parts[1] == "" { return false } var count int _ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM domains WHERE name=? AND status='active'`, parts[1]).Scan(&count) return count > 0 } func (a *App) handleMarkRead(w http.ResponseWriter, r *http.Request) { msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), false) if err != nil { respondError(w, http.StatusNotFound, "message not found") return } var req struct { Read *bool `json:"read"` } _ = decodeJSON(r, &req) read := true if req.Read != nil { read = *req.Read } if err := a.updateMessageMaildirFlags(r.Context(), msg.ID, &read, nil); err != nil { respondError(w, http.StatusInternalServerError, "failed to update message") return } modSeq, err := a.updateMessageModSeq(r.Context(), msg.ID, msg.FolderID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to update message") return } _, err = a.db.ExecContext(r.Context(), `UPDATE messages SET is_read=?, imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END, updated_at=? WHERE id=?`, boolInt(read), modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), msg.ID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to update message") return } respondJSON(w, http.StatusOK, map[string]any{"ok": true, "read": read}) } func (a *App) handleStar(w http.ResponseWriter, r *http.Request) { msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), false) if err != nil { respondError(w, http.StatusNotFound, "message not found") return } var req struct { Starred *bool `json:"starred"` } _ = decodeJSON(r, &req) starred := !msg.IsStarred if req.Starred != nil { starred = *req.Starred } if err := a.updateMessageMaildirFlags(r.Context(), msg.ID, nil, &starred); err != nil { respondError(w, http.StatusInternalServerError, "failed to update message") return } modSeq, err := a.updateMessageModSeq(r.Context(), msg.ID, msg.FolderID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to update message") return } _, err = a.db.ExecContext(r.Context(), `UPDATE messages SET is_starred=?, imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END, updated_at=? WHERE id=?`, boolInt(starred), modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), msg.ID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to update message") return } respondJSON(w, http.StatusOK, map[string]any{"ok": true, "starred": starred}) } func (a *App) handleMove(w http.ResponseWriter, r *http.Request) { msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), false) if err != nil { respondError(w, http.StatusNotFound, "message not found") return } var req struct { Folder string `json:"folder"` } if err := decodeJSON(r, &req); err != nil { badRequest(w, err) return } folder, err := normalizeFolderNameForUser(req.Folder) if err != nil { badRequest(w, err) return } folderID, err := a.ensureFolder(r.Context(), msg.MailboxID, folder) if err != nil { respondError(w, http.StatusInternalServerError, "failed to load folder") return } if err := a.moveMessageMaildir(r.Context(), msg.ID, folderID); err != nil { respondError(w, http.StatusInternalServerError, "failed to move message") return } respondJSON(w, http.StatusOK, map[string]any{"ok": true}) } func (a *App) folderByID(ctx context.Context, folderID, mailboxID string) (*MailFolder, error) { row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role, COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread, COUNT(m.id) AS total, f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq FROM folders f LEFT JOIN messages m ON m.folder_id=f.id WHERE f.id=? AND f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq`, folderID, mailboxID) var f MailFolder if err := row.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil { return nil, err } return &f, nil } func normalizeCustomFolderName(raw string) (string, error) { name := strings.Join(strings.Fields(raw), " ") if name == "" { return "", errors.New("folder name is required") } if len([]rune(name)) > 48 { return "", errors.New("folder name is too long") } if strings.ContainsAny(name, `/\:`) || strings.Contains(name, "..") { return "", errors.New("folder name contains invalid characters") } for _, r := range name { if r < 32 || r == 127 { return "", errors.New("folder name contains invalid characters") } } return name, nil } func normalizeFolderNameForUser(raw string) (string, error) { name := strings.TrimSpace(raw) if isSystemFolderName(name) { switch strings.ToLower(name) { case "inbox": return "Inbox", nil case "sent": return "Sent", nil case "drafts": return "Drafts", nil case "archive": return "Archive", nil case "spam": return "Spam", nil case "trash": return "Trash", nil } } return normalizeCustomFolderName(raw) } func isSystemFolderName(name string) bool { switch strings.ToLower(strings.TrimSpace(name)) { case "inbox", "sent", "drafts", "archive", "spam", "trash": return true default: return false } } func (a *App) handleDeleteMessage(w http.ResponseWriter, r *http.Request) { msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), false) if err != nil { respondError(w, http.StatusNotFound, "message not found") return } if strings.EqualFold(msg.Folder, "Trash") { a.deleteMessageMaildirFile(r.Context(), msg.ID) a.deleteMessageFiles(r.Context(), msg.ID) _, err = a.db.ExecContext(r.Context(), `DELETE FROM messages WHERE id=?`, msg.ID) if err == nil { _, _ = a.bumpFolderModSeq(r.Context(), msg.FolderID) } } else { trashID, e := a.ensureFolder(r.Context(), msg.MailboxID, "Trash") if e != nil { err = e } else { if e := a.moveMessageMaildir(r.Context(), msg.ID, trashID); e != nil { err = e } } } if err != nil { respondError(w, http.StatusInternalServerError, "failed to delete message") return } respondJSON(w, http.StatusOK, map[string]any{"ok": true}) } func (a *App) handleAttachment(w http.ResponseWriter, r *http.Request) { user := currentUser(r) attID := chi.URLParam(r, "id") row := a.db.QueryRowContext(r.Context(), `SELECT a.filename,a.content_type,a.size_bytes,a.storage_path FROM attachments a JOIN messages m ON m.id=a.message_id JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE a.id=? AND mb.user_id=?`, attID, user.ID) var filename, contentType, path string var size int64 if err := row.Scan(&filename, &contentType, &size, &path); err != nil { respondError(w, http.StatusNotFound, "attachment not found") return } f, err := os.Open(path) if err != nil { respondError(w, http.StatusNotFound, "attachment file missing") return } defer f.Close() w.Header().Set("Content-Type", contentType) w.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(filename, `"`, "")+`"`) w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) _, _ = io.Copy(w, f) } func (a *App) handleAdminAttachment(w http.ResponseWriter, r *http.Request) { attID := chi.URLParam(r, "id") row := a.db.QueryRowContext(r.Context(), `SELECT filename,content_type,size_bytes,storage_path FROM attachments WHERE id=?`, attID) var filename, contentType, path string var size int64 if err := row.Scan(&filename, &contentType, &size, &path); err != nil { respondError(w, http.StatusNotFound, "attachment not found") return } f, err := os.Open(path) if err != nil { respondError(w, http.StatusNotFound, "attachment file missing") return } defer f.Close() w.Header().Set("Content-Type", contentType) w.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(filename, `"`, "")+`"`) w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) _, _ = io.Copy(w, f) } func (a *App) handleEvents(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") flusher, _ := w.(http.Flusher) fmt.Fprintf(w, "event: sync\ndata: {\"status\":\"connected\"}\n\n") if flusher != nil { flusher.Flush() } ticker := time.NewTicker(25 * time.Second) defer ticker.Stop() for { select { case <-r.Context().Done(): return case t := <-ticker.C: fmt.Fprintf(w, "event: heartbeat\ndata: {\"time\":\"%s\"}\n\n", t.UTC().Format(time.RFC3339)) if flusher != nil { flusher.Flush() } } } } func (a *App) mailboxForCurrentUser(r *http.Request) (*Mailbox, error) { return a.mailboxForCurrentUserWithID(r, r.URL.Query().Get("mailboxId")) } func (a *App) mailboxForCurrentUserWithID(r *http.Request, mailboxID string) (*Mailbox, error) { user := currentUser(r) if user == nil { return nil, errors.New("no user") } mailboxID = strings.TrimSpace(mailboxID) if mailboxID != "" { row := a.db.QueryRowContext(r.Context(), `SELECT id,user_id,domain_id,local_part,address,display_name,quota_mb,status,created_at FROM mailboxes WHERE id=? AND user_id=? AND status='active'`, mailboxID, user.ID) var m Mailbox var created string if err := row.Scan(&m.ID, &m.UserID, &m.DomainID, &m.LocalPart, &m.Address, &m.DisplayName, &m.QuotaMB, &m.Status, &created); err != nil { return nil, err } m.UserEmail = user.Email m.CreatedAt = parseTime(created) return &m, nil } return a.mailboxForUser(r.Context(), user.ID) } func (a *App) mailboxByAddress(ctx context.Context, address string) (*Mailbox, error) { row := a.db.QueryRowContext(ctx, `SELECT id,user_id,domain_id,local_part,address,display_name,quota_mb,status,created_at FROM mailboxes WHERE address=? AND status='active'`, normalizeEmail(address)) var m Mailbox var created string if err := row.Scan(&m.ID, &m.UserID, &m.DomainID, &m.LocalPart, &m.Address, &m.DisplayName, &m.QuotaMB, &m.Status, &created); err != nil { return nil, err } m.CreatedAt = parseTime(created) return &m, nil } func (a *App) loadMessageForRequest(r *http.Request, id string, includeBody bool) (*MailMessage, error) { user := currentUser(r) row := a.db.QueryRowContext(r.Context(), `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE m.id=? AND mb.user_id=?`, id, user.ID) var messageID string if err := row.Scan(&messageID); err != nil { return nil, err } return a.messageByID(r.Context(), messageID, includeBody) } func (a *App) messageByID(ctx context.Context, id string, includeBody bool) (*MailMessage, error) { row := a.db.QueryRowContext(ctx, `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.imap_uid,m.imap_modseq,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.body_text,m.body_html,m.is_read,m.is_starred,m.has_attachments,m.size_bytes,COALESCE(m.auth_results,''),COALESCE(m.auth_spf,'unknown'),COALESCE(m.auth_dkim,'unknown'),COALESCE(m.auth_dmarc,'unknown'),COALESCE(m.received_spf,'') FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE m.id=?`, id) msg, err := scanMessageFull(row, includeBody) if err != nil { return nil, err } if includeBody { atts, err := a.attachmentsForMessage(ctx, id) if err != nil { return nil, err } msg.Attachments = atts } labels, err := a.labelsForMessage(ctx, id) if err != nil { return nil, err } msg.Labels = labels if includeBody { _ = a.db.QueryRowContext(ctx, `SELECT id,status FROM send_queue WHERE sent_message_id=? ORDER BY created_at DESC,id DESC LIMIT 1`, id).Scan(&msg.SendQueueID, &msg.SendQueueStatus) } return &msg, nil } func (a *App) insertMessage(ctx context.Context, msg storedMessage, attachments []AttachmentInput) (string, error) { return a.insertMessageWithDB(ctx, a.db, msg, attachments) } type dbExecutor interface { ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) } type dbQueryer interface { QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row } func (a *App) insertMessageWithDB(ctx context.Context, db dbExecutor, msg storedMessage, attachments []AttachmentInput) (string, error) { id := newID("mail") now := a.now().UTC().Format(time.RFC3339Nano) hasAttachments := len(attachments) > 0 size := int64(len(msg.BodyText) + len(msg.BodyHTML)) for _, att := range attachments { if decoded, err := base64.StdEncoding.DecodeString(att.ContentBase64); err == nil { size += int64(len(decoded)) } } if err := a.ensureMailboxQuotaAvailable(ctx, db, msg.MailboxID, size); err != nil { return "", err } var mailboxID, folderID any if strings.TrimSpace(msg.MailboxID) != "" { mailboxID = msg.MailboxID } if strings.TrimSpace(msg.FolderID) != "" { folderID = msg.FolderID } imapUID, imapModSeq := int64(0), int64(1) if strings.TrimSpace(msg.FolderID) != "" { meta, err := a.nextIMAPMetadata(ctx, db, msg.FolderID) if err != nil { return "", err } imapUID, imapModSeq = meta.UID, meta.ModSeq } recipientAddr := normalizeEmail(msg.RecipientAddr) auth := normalizeMailAuthentication(msg.Authentication) _, err := db.ExecContext(ctx, `INSERT INTO messages(id,mailbox_id,folder_id,recipient_addr,message_uid,message_id,subject,from_addr,from_name,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,auth_results,auth_spf,auth_dkim,auth_dmarc,received_spf,raw_path,imap_uid,imap_modseq,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, mailboxID, folderID, recipientAddr, msg.MessageUID, msg.MessageID, msg.Subject, msg.From, msg.FromName, jsonEncode(msg.To), jsonEncode(msg.CC), jsonEncode(msg.BCC), msg.SentAt.Format(time.RFC3339Nano), msg.ReceivedAt.Format(time.RFC3339Nano), msg.Snippet, msg.BodyText, msg.BodyHTML, boolInt(msg.IsRead), boolInt(msg.IsStarred), boolInt(hasAttachments), size, auth.AuthenticationResults, auth.SPF, auth.DKIM, auth.DMARC, auth.ReceivedSPF, msg.RawPath, imapUID, imapModSeq, now, now) if err != nil { return "", err } for _, att := range attachments { if err := a.storeAttachmentWithDB(ctx, db, id, att); err != nil { a.deleteMessageFiles(ctx, id) return "", err } } return id, nil } func (a *App) ensureMailboxQuotaAvailable(ctx context.Context, db dbExecutor, mailboxID string, addBytes int64) error { mailboxID = strings.TrimSpace(mailboxID) if mailboxID == "" || addBytes <= 0 { return nil } rowDB, ok := db.(dbQueryer) if !ok { return nil } var quotaMB int64 if err := rowDB.QueryRowContext(ctx, `SELECT quota_mb FROM mailboxes WHERE id=? AND status='active'`, mailboxID).Scan("aMB); err != nil { return err } if quotaMB <= 0 { return nil } var used int64 if err := rowDB.QueryRowContext(ctx, `SELECT COALESCE(SUM(size_bytes),0) FROM messages WHERE mailbox_id=?`, mailboxID).Scan(&used); err != nil { return err } quotaBytes := quotaMB * 1024 * 1024 if used+addBytes > quotaBytes { return fmt.Errorf("%w: used %d bytes, adding %d bytes exceeds %d bytes", errMailboxQuotaExceeded, used, addBytes, quotaBytes) } return nil } func (a *App) storeAttachment(ctx context.Context, messageID string, input AttachmentInput) error { return a.storeAttachmentWithDB(ctx, a.db, messageID, input) } func (a *App) storeAttachmentWithDB(ctx context.Context, db dbExecutor, messageID string, input AttachmentInput) error { filename := filepath.Base(strings.TrimSpace(input.Filename)) if filename == "." || filename == "" { filename = "attachment.bin" } contentType := input.ContentType if contentType == "" { contentType = "application/octet-stream" } data, err := base64.StdEncoding.DecodeString(input.ContentBase64) if err != nil { return err } dir := filepath.Join(a.cfg.DataDir, "attachments", messageID) if err := os.MkdirAll(dir, 0o755); err != nil { return err } id := newID("att") path := filepath.Join(dir, id+"_"+filename) if err := os.WriteFile(path, data, 0o600); err != nil { return err } _, err = db.ExecContext(ctx, `INSERT INTO attachments(id,message_id,filename,content_type,size_bytes,storage_path,created_at) VALUES(?,?,?,?,?,?,?)`, id, messageID, filename, contentType, len(data), path, a.now().UTC().Format(time.RFC3339Nano)) return err } func (a *App) attachmentsForMessage(ctx context.Context, messageID string) ([]Attachment, error) { rows, err := a.db.QueryContext(ctx, `SELECT id,message_id,filename,content_type,size_bytes,created_at FROM attachments WHERE message_id=? ORDER BY filename`, messageID) if err != nil { return nil, err } defer rows.Close() items := []Attachment{} for rows.Next() { var item Attachment var created string if err := rows.Scan(&item.ID, &item.MessageID, &item.Filename, &item.ContentType, &item.SizeBytes, &created); err != nil { return nil, err } item.CreatedAt = parseTime(created) items = append(items, item) } return items, nil } func (a *App) labelsForMailbox(ctx context.Context, mailboxID string) ([]MailLabel, error) { rows, err := a.db.QueryContext(ctx, `SELECT l.id,l.mailbox_id,l.name,l.color,COUNT(ml.message_id) FROM mail_labels l LEFT JOIN message_labels ml ON ml.label_id=l.id WHERE l.mailbox_id=? GROUP BY l.id,l.mailbox_id,l.name,l.color ORDER BY lower(l.name)`, mailboxID) if err != nil { return nil, err } defer rows.Close() items := []MailLabel{} for rows.Next() { var item MailLabel if err := rows.Scan(&item.ID, &item.MailboxID, &item.Name, &item.Color, &item.MessageCount); err != nil { return nil, err } items = append(items, item) } return items, rows.Err() } func (a *App) labelsForMessage(ctx context.Context, messageID string) ([]MailLabel, error) { rows, err := a.db.QueryContext(ctx, `SELECT l.id,l.mailbox_id,l.name,l.color FROM mail_labels l JOIN message_labels ml ON ml.label_id=l.id WHERE ml.message_id=? ORDER BY lower(l.name)`, messageID) if err != nil { return nil, err } defer rows.Close() items := []MailLabel{} for rows.Next() { var item MailLabel if err := rows.Scan(&item.ID, &item.MailboxID, &item.Name, &item.Color); err != nil { return nil, err } items = append(items, item) } return items, rows.Err() } func (a *App) attachLabelsToMessages(ctx context.Context, items []MailMessage) error { if len(items) == 0 { return nil } ids := make([]string, 0, len(items)) index := make(map[string]int, len(items)) args := make([]any, 0, len(items)) for i := range items { ids = append(ids, "?") index[items[i].ID] = i args = append(args, items[i].ID) } rows, err := a.db.QueryContext(ctx, `SELECT ml.message_id,l.id,l.mailbox_id,l.name,l.color FROM message_labels ml JOIN mail_labels l ON l.id=ml.label_id WHERE ml.message_id IN (`+strings.Join(ids, ",")+`) ORDER BY lower(l.name)`, args...) if err != nil { return err } defer rows.Close() for rows.Next() { var messageID string var label MailLabel if err := rows.Scan(&messageID, &label.ID, &label.MailboxID, &label.Name, &label.Color); err != nil { return err } if itemIndex, ok := index[messageID]; ok { items[itemIndex].Labels = append(items[itemIndex].Labels, label) } } return rows.Err() } func (a *App) ensureLabel(ctx context.Context, mailboxID, name, color string) (MailLabel, error) { name = normalizeLabelName(name) if name == "" { return MailLabel{}, errors.New("label name is required") } color = normalizeLabelColor(color) now := a.now().UTC().Format(time.RFC3339Nano) var existing MailLabel row := a.db.QueryRowContext(ctx, `SELECT id,mailbox_id,name,color FROM mail_labels WHERE mailbox_id=? AND lower(name)=lower(?)`, mailboxID, name) if err := row.Scan(&existing.ID, &existing.MailboxID, &existing.Name, &existing.Color); err == nil { if color != "" && color != existing.Color { if _, err := a.db.ExecContext(ctx, `UPDATE mail_labels SET color=?, updated_at=? WHERE id=?`, color, now, existing.ID); err != nil { return MailLabel{}, err } existing.Color = color } return existing, nil } else if !errors.Is(err, sql.ErrNoRows) { return MailLabel{}, err } id := newID("lbl") if color == "" { color = "#64748b" } _, err := a.db.ExecContext(ctx, `INSERT INTO mail_labels(id,mailbox_id,name,color,created_at,updated_at) VALUES(?,?,?,?,?,?)`, id, mailboxID, name, color, now, now) if err != nil { return MailLabel{}, err } return MailLabel{ID: id, MailboxID: mailboxID, Name: name, Color: color}, nil } func (a *App) labelBelongsToMailbox(ctx context.Context, labelID, mailboxID string) bool { var count int _ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM mail_labels WHERE id=? AND mailbox_id=?`, labelID, mailboxID).Scan(&count) return count > 0 } func normalizeLabelName(name string) string { name = strings.Join(strings.Fields(strings.TrimSpace(name)), " ") if len([]rune(name)) > 32 { name = string([]rune(name)[:32]) } return name } func normalizeLabelColor(color string) string { color = strings.TrimSpace(color) if len(color) != 7 || !strings.HasPrefix(color, "#") { return "" } for _, r := range color[1:] { if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) { return "" } } return strings.ToLower(color) } func (a *App) deleteMessageFiles(ctx context.Context, messageID string) { rows, err := a.db.QueryContext(ctx, `SELECT storage_path FROM attachments WHERE message_id=?`, messageID) if err != nil { return } defer rows.Close() for rows.Next() { var p string if rows.Scan(&p) == nil { _ = os.Remove(p) } } _ = os.RemoveAll(filepath.Join(a.cfg.DataDir, "attachments", messageID)) } func (a *App) deleteMessage(ctx context.Context, messageID string) { var folderID sql.NullString _ = a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, messageID).Scan(&folderID) a.deleteMessageMaildirFile(ctx, messageID) a.deleteMessageFiles(ctx, messageID) _, _ = a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, messageID) if folderID.Valid && folderID.String != "" { _, _ = a.bumpFolderModSeq(ctx, folderID.String) } } type messageSummaryScanner interface{ Scan(dest ...any) error } func scanAdminMessageSummary(row messageSummaryScanner) (MailMessage, error) { var msg MailMessage var toJSON, ccJSON, bccJSON, sent, received string var read, starred, hasAtt int err := row.Scan(&msg.ID, &msg.MailboxID, &msg.MailboxAddress, &msg.OwnerEmail, &msg.RecipientAddr, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.IMAPUID, &msg.IMAPModSeq, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &read, &starred, &hasAtt, &msg.SizeBytes) if err != nil { return msg, err } msg.To, msg.CC, msg.BCC = jsonDecodeSlice(toJSON), jsonDecodeSlice(ccJSON), jsonDecodeSlice(bccJSON) msg.SentAt, msg.ReceivedAt = parseTime(sent), parseTime(received) msg.IsRead, msg.IsStarred, msg.HasAttachments = intBool(read), intBool(starred), intBool(hasAtt) return msg, nil } func scanMessageSummary(row messageSummaryScanner) (MailMessage, error) { var msg MailMessage var toJSON, ccJSON, bccJSON, sent, received string var read, starred, hasAtt int err := row.Scan(&msg.ID, &msg.MailboxID, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.IMAPUID, &msg.IMAPModSeq, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &read, &starred, &hasAtt, &msg.SizeBytes) if err != nil { return msg, err } msg.To, msg.CC, msg.BCC = jsonDecodeSlice(toJSON), jsonDecodeSlice(ccJSON), jsonDecodeSlice(bccJSON) msg.SentAt, msg.ReceivedAt = parseTime(sent), parseTime(received) msg.IsRead, msg.IsStarred, msg.HasAttachments = intBool(read), intBool(starred), intBool(hasAtt) return msg, nil } func scanMessageFull(row messageSummaryScanner, includeBody bool) (MailMessage, error) { var msg MailMessage var toJSON, ccJSON, bccJSON, sent, received string var auth MailAuthentication var read, starred, hasAtt int var bodyText, bodyHTML string err := row.Scan(&msg.ID, &msg.MailboxID, &msg.RecipientAddr, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.IMAPUID, &msg.IMAPModSeq, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &bodyText, &bodyHTML, &read, &starred, &hasAtt, &msg.SizeBytes, &auth.AuthenticationResults, &auth.SPF, &auth.DKIM, &auth.DMARC, &auth.ReceivedSPF) if err != nil { return msg, err } msg.To, msg.CC, msg.BCC = jsonDecodeSlice(toJSON), jsonDecodeSlice(ccJSON), jsonDecodeSlice(bccJSON) msg.SentAt, msg.ReceivedAt = parseTime(sent), parseTime(received) msg.IsRead, msg.IsStarred, msg.HasAttachments = intBool(read), intBool(starred), intBool(hasAtt) msg.Authentication = normalizeMailAuthentication(auth) if includeBody { msg.BodyText, msg.BodyHTML = bodyText, bodyHTML } return msg, nil } func parseMailAuthentication(header textproto.MIMEHeader) MailAuthentication { authResults := strings.Join(header.Values("Authentication-Results"), "\n") receivedSPF := strings.Join(header.Values("Received-SPF"), "\n") auth := MailAuthentication{ AuthenticationResults: strings.TrimSpace(authResults), ReceivedSPF: strings.TrimSpace(receivedSPF), SPF: "unknown", DKIM: "unknown", DMARC: "unknown", } for _, value := range header.Values("Authentication-Results") { for _, field := range strings.FieldsFunc(value, func(r rune) bool { return r == ';' || r == '\r' || r == '\n' }) { key, result, ok := authMethodResult(field) if !ok { continue } switch key { case "spf": auth.SPF = result case "dkim": auth.DKIM = result case "dmarc": auth.DMARC = result } } } if auth.SPF == "unknown" { for _, value := range header.Values("Received-SPF") { if result := firstAuthStatus(value); result != "unknown" { auth.SPF = result break } } } return normalizeMailAuthentication(auth) } func authMethodResult(value string) (string, string, bool) { value = strings.TrimSpace(value) if value == "" { return "", "", false } parts := strings.SplitN(value, "=", 2) if len(parts) != 2 { return "", "", false } method := strings.ToLower(strings.TrimSpace(parts[0])) if method != "spf" && method != "dkim" && method != "dmarc" { return "", "", false } return method, firstAuthStatus(parts[1]), true } func firstAuthStatus(value string) string { value = strings.TrimSpace(value) if value == "" { return "unknown" } value = strings.ToLower(strings.Fields(value)[0]) if idx := strings.IndexAny(value, "();,"); idx >= 0 { value = value[:idx] } switch value { case "pass", "fail", "softfail", "neutral", "temperror", "permerror", "none": return value default: return "unknown" } } func normalizeMailAuthentication(auth MailAuthentication) MailAuthentication { auth.AuthenticationResults = strings.TrimSpace(auth.AuthenticationResults) auth.ReceivedSPF = strings.TrimSpace(auth.ReceivedSPF) auth.SPF = normalizeAuthStatus(auth.SPF) auth.DKIM = normalizeAuthStatus(auth.DKIM) auth.DMARC = normalizeAuthStatus(auth.DMARC) return auth } func normalizeAuthStatus(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "pass", "fail", "softfail", "neutral", "temperror", "permerror", "none": return strings.ToLower(strings.TrimSpace(value)) default: return "unknown" } }