feat: customize NodeSeek-style webmail
This commit is contained in:
@@ -24,6 +24,10 @@ const mailMessagesPageSize = 30
|
|||||||
|
|
||||||
const customFolderDefaultSortOrderBase = 100000
|
const customFolderDefaultSortOrderBase = 100000
|
||||||
|
|
||||||
|
func isAllMailboxID(mailboxID string) bool {
|
||||||
|
return strings.EqualFold(strings.TrimSpace(mailboxID), "all")
|
||||||
|
}
|
||||||
|
|
||||||
type AttachmentInput struct {
|
type AttachmentInput struct {
|
||||||
Filename string `json:"filename"`
|
Filename string `json:"filename"`
|
||||||
ContentType string `json:"contentType"`
|
ContentType string `json:"contentType"`
|
||||||
@@ -81,6 +85,10 @@ func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
|
||||||
|
a.handleAllMailFolders(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
mb, err := a.mailboxForCurrentUser(r)
|
mb, err := a.mailboxForCurrentUser(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||||
@@ -118,6 +126,43 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) handleAllMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := currentUser(r)
|
||||||
|
rows, err := a.db.QueryContext(r.Context(), `SELECT 'all-' || lower(f.name),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,
|
||||||
|
MIN(f.sort_order),MAX(f.uid_validity),MAX(f.uid_next),MAX(f.highest_modseq)
|
||||||
|
FROM folders f
|
||||||
|
JOIN mailboxes mb ON mb.id=f.mailbox_id
|
||||||
|
LEFT JOIN messages m ON m.folder_id=f.id
|
||||||
|
WHERE mb.user_id=? AND mb.status='active'
|
||||||
|
GROUP BY f.name,f.role
|
||||||
|
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 MIN(f.sort_order)
|
||||||
|
END, f.name`, user.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) {
|
func (a *App) handleReorderMailFolders(w http.ResponseWriter, r *http.Request) {
|
||||||
mb, err := a.mailboxForCurrentUser(r)
|
mb, err := a.mailboxForCurrentUser(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -345,6 +390,29 @@ func (a *App) nextCustomFolderSortOrder(ctx context.Context, mailboxID string) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
|
||||||
|
user := currentUser(r)
|
||||||
|
if labelID := strings.TrimSpace(r.URL.Query().Get("labelId")); labelID != "" {
|
||||||
|
if !a.labelBelongsToUser(r.Context(), labelID, user.ID) {
|
||||||
|
respondError(w, http.StatusNotFound, "label not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.respondMailMessageList(w, r, `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)`, []any{user.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
|
||||||
|
}
|
||||||
|
a.respondMailMessageList(w, r, `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND f.name=?`, []any{user.ID, folder})
|
||||||
|
return
|
||||||
|
}
|
||||||
mb, err := a.mailboxForCurrentUser(r)
|
mb, err := a.mailboxForCurrentUser(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||||
@@ -377,6 +445,11 @@ func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleStarredMessages(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleStarredMessages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
|
||||||
|
user := currentUser(r)
|
||||||
|
a.respondMailMessageList(w, r, `EXISTS (SELECT 1 FROM mailboxes mb WHERE mb.id=m.mailbox_id AND mb.user_id=? AND mb.status='active') AND m.is_starred=1`, []any{user.ID})
|
||||||
|
return
|
||||||
|
}
|
||||||
mb, err := a.mailboxForCurrentUser(r)
|
mb, err := a.mailboxForCurrentUser(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||||
@@ -394,9 +467,15 @@ func (a *App) respondMailMessageList(w http.ResponseWriter, r *http.Request, whe
|
|||||||
limit := mailMessagesPageSize
|
limit := mailMessagesPageSize
|
||||||
|
|
||||||
if q != "" {
|
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 ?)`
|
where += ` AND (m.subject LIKE ? OR m.from_addr LIKE ? OR m.from_name LIKE ? OR m.to_addrs LIKE ? OR m.cc_addrs LIKE ? OR m.recipient_addr LIKE ? OR m.snippet LIKE ? OR m.body_text LIKE ?)`
|
||||||
like := "%" + q + "%"
|
like := "%" + q + "%"
|
||||||
args = append(args, like, like, like, like, like)
|
args = append(args, like, like, like, like, like, like, like, like)
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
where, args, err = appendMailMessageSearchFilters(r, where, args)
|
||||||
|
if err != nil {
|
||||||
|
badRequest(w, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
args = append(args, limit+1, offset)
|
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
|
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
|
||||||
@@ -428,7 +507,131 @@ func (a *App) respondMailMessageList(w http.ResponseWriter, r *http.Request, whe
|
|||||||
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
|
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func appendMailMessageSearchFilters(r *http.Request, where string, args []any) (string, []any, error) {
|
||||||
|
if from := strings.TrimSpace(r.URL.Query().Get("from")); from != "" {
|
||||||
|
where += ` AND (m.from_addr LIKE ? OR m.from_name LIKE ?)`
|
||||||
|
like := "%" + from + "%"
|
||||||
|
args = append(args, like, like)
|
||||||
|
}
|
||||||
|
if to := strings.TrimSpace(r.URL.Query().Get("to")); to != "" {
|
||||||
|
where += ` AND (m.to_addrs LIKE ? OR m.cc_addrs LIKE ? OR m.bcc_addrs LIKE ? OR m.recipient_addr LIKE ?)`
|
||||||
|
like := "%" + to + "%"
|
||||||
|
args = append(args, like, like, like, like)
|
||||||
|
}
|
||||||
|
if subject := strings.TrimSpace(r.URL.Query().Get("subject")); subject != "" {
|
||||||
|
where += ` AND m.subject LIKE ?`
|
||||||
|
args = append(args, "%"+subject+"%")
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("attachmentMode"))) {
|
||||||
|
case "with":
|
||||||
|
where += ` AND m.has_attachments=1`
|
||||||
|
case "without":
|
||||||
|
where += ` AND m.has_attachments=0`
|
||||||
|
default:
|
||||||
|
if mailSearchFlag(r, "hasAttachments") {
|
||||||
|
where += ` AND m.has_attachments=1`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if minSize, ok, err := mailSearchSizeBytes(r.URL.Query().Get("minSizeKb"), "minSizeKb"); err != nil {
|
||||||
|
return where, args, err
|
||||||
|
} else if ok {
|
||||||
|
where += ` AND m.size_bytes>=?`
|
||||||
|
args = append(args, minSize)
|
||||||
|
}
|
||||||
|
if maxSize, ok, err := mailSearchSizeBytes(r.URL.Query().Get("maxSizeKb"), "maxSizeKb"); err != nil {
|
||||||
|
return where, args, err
|
||||||
|
} else if ok {
|
||||||
|
where += ` AND m.size_bytes<=?`
|
||||||
|
args = append(args, maxSize)
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("readStatus"))) {
|
||||||
|
case "read":
|
||||||
|
where += ` AND m.is_read=1`
|
||||||
|
case "unread":
|
||||||
|
where += ` AND m.is_read=0`
|
||||||
|
default:
|
||||||
|
if mailSearchFlag(r, "unread") {
|
||||||
|
where += ` AND m.is_read=0`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("flagStatus"))) {
|
||||||
|
case "starred":
|
||||||
|
where += ` AND m.is_starred=1`
|
||||||
|
case "unstarred":
|
||||||
|
where += ` AND m.is_starred=0`
|
||||||
|
default:
|
||||||
|
if mailSearchFlag(r, "starred") {
|
||||||
|
where += ` AND m.is_starred=1`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start, ok, err := mailSearchDateBoundary(r.URL.Query().Get("startDate"), false); err != nil {
|
||||||
|
return where, args, err
|
||||||
|
} else if ok {
|
||||||
|
where += ` AND m.received_at>=?`
|
||||||
|
args = append(args, start)
|
||||||
|
}
|
||||||
|
if end, ok, err := mailSearchDateBoundary(r.URL.Query().Get("endDate"), true); err != nil {
|
||||||
|
return where, args, err
|
||||||
|
} else if ok {
|
||||||
|
where += ` AND m.received_at<=?`
|
||||||
|
args = append(args, end)
|
||||||
|
}
|
||||||
|
return where, args, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mailSearchFlag(r *http.Request, key string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get(key))) {
|
||||||
|
case "1", "true", "yes", "on":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mailSearchDateBoundary(value string, endOfDay bool) (string, bool, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
if len(value) == len("2006-01-02") {
|
||||||
|
t, err := time.Parse("2006-01-02", value)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, fmt.Errorf("invalid date %q", value)
|
||||||
|
}
|
||||||
|
if endOfDay {
|
||||||
|
t = t.AddDate(0, 0, 1).Add(-time.Nanosecond)
|
||||||
|
}
|
||||||
|
return t.UTC().Format(time.RFC3339Nano), true, nil
|
||||||
|
}
|
||||||
|
t, err := time.Parse(time.RFC3339Nano, value)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, fmt.Errorf("invalid date %q", value)
|
||||||
|
}
|
||||||
|
return t.UTC().Format(time.RFC3339Nano), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mailSearchSizeBytes(value string, key string) (int64, bool, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return 0, false, nil
|
||||||
|
}
|
||||||
|
kb, err := strconv.ParseInt(value, 10, 64)
|
||||||
|
if err != nil || kb < 0 {
|
||||||
|
return 0, false, fmt.Errorf("invalid %s %q", key, value)
|
||||||
|
}
|
||||||
|
return kb * 1024, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) handleMailLabels(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleMailLabels(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
|
||||||
|
labels, err := a.labelsForUser(r.Context(), currentUser(r).ID)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to load labels")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, map[string]any{"items": labels})
|
||||||
|
return
|
||||||
|
}
|
||||||
mb, err := a.mailboxForCurrentUser(r)
|
mb, err := a.mailboxForCurrentUser(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||||
@@ -1121,15 +1324,23 @@ func (a *App) handleDeleteDraft(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (a *App) handleScheduledSends(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleScheduledSends(w http.ResponseWriter, r *http.Request) {
|
||||||
user := currentUser(r)
|
user := currentUser(r)
|
||||||
mb, err := a.mailboxForCurrentUser(r)
|
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
|
||||||
if err != nil {
|
args := []any{user.ID}
|
||||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
where := `user_id=?`
|
||||||
return
|
if !isAllMailboxID(mailboxID) {
|
||||||
|
mb, err := a.mailboxForCurrentUser(r)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
where += ` AND mailbox_id=?`
|
||||||
|
args = append(args, mb.ID)
|
||||||
}
|
}
|
||||||
|
args = append(args, "pending", "sending", "failed")
|
||||||
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
|
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
|
FROM scheduled_sends
|
||||||
WHERE user_id=? AND mailbox_id=? AND status IN ('pending','sending','failed')
|
WHERE `+where+` AND status IN (?,?,?)
|
||||||
ORDER BY send_at ASC, created_at DESC`, user.ID, mb.ID)
|
ORDER BY send_at ASC, created_at DESC`, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load scheduled sends")
|
respondError(w, http.StatusInternalServerError, "failed to load scheduled sends")
|
||||||
return
|
return
|
||||||
@@ -1166,20 +1377,28 @@ func (a *App) handleScheduledSends(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (a *App) handleSendQueue(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleSendQueue(w http.ResponseWriter, r *http.Request) {
|
||||||
user := currentUser(r)
|
user := currentUser(r)
|
||||||
mb, err := a.mailboxForCurrentUser(r)
|
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
status := strings.TrimSpace(r.URL.Query().Get("status"))
|
status := strings.TrimSpace(r.URL.Query().Get("status"))
|
||||||
|
if strings.EqualFold(status, "all") {
|
||||||
|
status = ""
|
||||||
|
}
|
||||||
cursorCreatedAt, cursorID, offsetCursor, err := parseSendQueueCursor(r.URL.Query().Get("cursor"))
|
cursorCreatedAt, cursorID, offsetCursor, err := parseSendQueueCursor(r.URL.Query().Get("cursor"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
limit := 30
|
limit := 30
|
||||||
args := []any{user.ID, mb.ID}
|
args := []any{user.ID}
|
||||||
where := `mb.user_id=? AND sq.mailbox_id=?`
|
where := `mb.user_id=?`
|
||||||
|
if !isAllMailboxID(mailboxID) {
|
||||||
|
mb, err := a.mailboxForCurrentUser(r)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
where += ` AND sq.mailbox_id=?`
|
||||||
|
args = append(args, mb.ID)
|
||||||
|
}
|
||||||
if status != "" {
|
if status != "" {
|
||||||
if !validSendQueueStatus(status) {
|
if !validSendQueueStatus(status) {
|
||||||
badRequest(w, errors.New("invalid send queue status"))
|
badRequest(w, errors.New("invalid send queue status"))
|
||||||
@@ -2169,6 +2388,29 @@ func (a *App) labelsForMailbox(ctx context.Context, mailboxID string) ([]MailLab
|
|||||||
return items, rows.Err()
|
return items, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) labelsForUser(ctx context.Context, userID 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
|
||||||
|
JOIN mailboxes mb ON mb.id=l.mailbox_id
|
||||||
|
LEFT JOIN message_labels ml ON ml.label_id=l.id
|
||||||
|
WHERE mb.user_id=? AND mb.status='active'
|
||||||
|
GROUP BY l.id,l.mailbox_id,l.name,l.color
|
||||||
|
ORDER BY lower(l.name)`, userID)
|
||||||
|
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) {
|
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
|
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
|
FROM mail_labels l JOIN message_labels ml ON ml.label_id=l.id
|
||||||
@@ -2259,6 +2501,12 @@ func (a *App) labelBelongsToMailbox(ctx context.Context, labelID, mailboxID stri
|
|||||||
return count > 0
|
return count > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) labelBelongsToUser(ctx context.Context, labelID, userID string) bool {
|
||||||
|
var count int
|
||||||
|
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM mail_labels l JOIN mailboxes mb ON mb.id=l.mailbox_id WHERE l.id=? AND mb.user_id=?`, labelID, userID).Scan(&count)
|
||||||
|
return count > 0
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeLabelName(name string) string {
|
func normalizeLabelName(name string) string {
|
||||||
name = strings.Join(strings.Fields(strings.TrimSpace(name)), " ")
|
name = strings.Join(strings.Fields(strings.TrimSpace(name)), " ")
|
||||||
if len([]rune(name)) > 32 {
|
if len([]rune(name)) > 32 {
|
||||||
|
|||||||
@@ -623,7 +623,7 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
|
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
|
||||||
args := []any{user.ID}
|
args := []any{user.ID}
|
||||||
where := `mb.user_id=?`
|
where := `mb.user_id=?`
|
||||||
if mailboxID != "" {
|
if mailboxID != "" && !isAllMailboxID(mailboxID) {
|
||||||
if _, err := a.mailboxForCurrentUserWithID(r, mailboxID); err != nil {
|
if _, err := a.mailboxForCurrentUserWithID(r, mailboxID); err != nil {
|
||||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||||
return
|
return
|
||||||
@@ -642,7 +642,7 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusInternalServerError, "failed to load attachment stats")
|
respondError(w, http.StatusInternalServerError, "failed to load attachment stats")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if mailboxID != "" {
|
if mailboxID != "" && !isAllMailboxID(mailboxID) {
|
||||||
var quotaMB int64
|
var quotaMB int64
|
||||||
if err := a.db.QueryRowContext(r.Context(), `SELECT quota_mb FROM mailboxes WHERE id=? AND user_id=?`, mailboxID, user.ID).Scan("aMB); err != nil {
|
if err := a.db.QueryRowContext(r.Context(), `SELECT quota_mb FROM mailboxes WHERE id=? AND user_id=?`, mailboxID, user.ID).Scan("aMB); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load quota")
|
respondError(w, http.StatusInternalServerError, "failed to load quota")
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>LanQin Email</title>
|
<title>NodeSeek 邮箱</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
+62
-23
@@ -5,39 +5,46 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
--foreground: 240 10% 3.9%;
|
--foreground: 222 47% 11%;
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 240 10% 3.9%;
|
--card-foreground: 222 47% 11%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 240 10% 3.9%;
|
--popover-foreground: 222 47% 11%;
|
||||||
--primary: 240 5.9% 10%;
|
--primary: 224 44% 12%;
|
||||||
--primary-foreground: 0 0% 98%;
|
--primary-foreground: 0 0% 98%;
|
||||||
--secondary: 240 4.8% 95.9%;
|
--secondary: 213 37% 96%;
|
||||||
--secondary-foreground: 240 5.9% 10%;
|
--secondary-foreground: 222 47% 11%;
|
||||||
--muted: 240 4.8% 95.9%;
|
--muted: 213 37% 96%;
|
||||||
--muted-foreground: 240 3.8% 46.1%;
|
--muted-foreground: 216 22% 42%;
|
||||||
--accent: 240 4.8% 95.9%;
|
--accent: 213 37% 94%;
|
||||||
--accent-foreground: 240 5.9% 10%;
|
--accent-foreground: 222 47% 11%;
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 358 88% 61%;
|
||||||
--destructive-foreground: 0 0% 98%;
|
--destructive-foreground: 0 0% 98%;
|
||||||
--border: 240 5.9% 90%;
|
--border: 214 32% 90%;
|
||||||
--input: 240 5.9% 90%;
|
--input: 214 32% 86%;
|
||||||
--ring: 240 5.9% 10%;
|
--ring: 216 22% 42%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
--sidebar-background: 0 0% 98%;
|
--sidebar-background: 0 0% 100%;
|
||||||
--sidebar-foreground: 240 5.3% 26.1%;
|
--sidebar-foreground: 222 47% 11%;
|
||||||
--sidebar-primary: 240 5.9% 10%;
|
--sidebar-primary: 224 44% 12%;
|
||||||
--sidebar-primary-foreground: 0 0% 98%;
|
--sidebar-primary-foreground: 0 0% 98%;
|
||||||
--sidebar-accent: 240 4.8% 95.9%;
|
--sidebar-accent: 213 37% 94%;
|
||||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
--sidebar-accent-foreground: 222 47% 11%;
|
||||||
--sidebar-border: 220 13% 91%;
|
--sidebar-border: 214 32% 90%;
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
--sidebar-ring: 216 22% 42%;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { @apply border-border; }
|
* { @apply border-border; }
|
||||||
body { @apply bg-background text-foreground antialiased; }
|
html {
|
||||||
|
color-scheme: light;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground antialiased;
|
||||||
|
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
html, body, #root { min-height: 100%; }
|
html, body, #root { min-height: 100%; }
|
||||||
html { color-scheme: light; }
|
|
||||||
html.dark { color-scheme: dark; }
|
html.dark { color-scheme: dark; }
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -71,6 +78,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
[data-sidebar="content"] {
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sidebar="group"] {
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sidebar="menu"] {
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sidebar="menu-button"] {
|
||||||
|
color: hsl(var(--sidebar-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sidebar="menu-button"] svg {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
stroke-width: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sidebar="menu-button"][data-active="true"] {
|
||||||
|
background: hsl(var(--sidebar-accent));
|
||||||
|
color: hsl(var(--sidebar-accent-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-sidebar="menu-button"][data-active="true"] svg {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
html.theme-transition body {
|
html.theme-transition body {
|
||||||
transition: background-color 180ms ease, color 180ms ease;
|
transition: background-color 180ms ease, color 180ms ease;
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-8
@@ -4,6 +4,44 @@ export * from "./api-types"
|
|||||||
const REQUEST_TIMEOUT_MS = 15_000
|
const REQUEST_TIMEOUT_MS = 15_000
|
||||||
const MAIL_DELIVERY_TIMEOUT_MS = 60_000
|
const MAIL_DELIVERY_TIMEOUT_MS = 60_000
|
||||||
|
|
||||||
|
export type MailSearchParams = {
|
||||||
|
q?: string
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
|
subject?: string
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
attachmentMode?: "all" | "with" | "without"
|
||||||
|
minSizeKb?: string
|
||||||
|
maxSizeKb?: string
|
||||||
|
readStatus?: "all" | "read" | "unread"
|
||||||
|
flagStatus?: "all" | "starred" | "unstarred"
|
||||||
|
hasAttachments?: boolean
|
||||||
|
unread?: boolean
|
||||||
|
starred?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendMailSearchParams(params: URLSearchParams, search: MailSearchParams | string) {
|
||||||
|
if (typeof search === "string") {
|
||||||
|
if (search) params.set("q", search)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (search.q) params.set("q", search.q)
|
||||||
|
if (search.from) params.set("from", search.from)
|
||||||
|
if (search.to) params.set("to", search.to)
|
||||||
|
if (search.subject) params.set("subject", search.subject)
|
||||||
|
if (search.startDate) params.set("startDate", search.startDate)
|
||||||
|
if (search.endDate) params.set("endDate", search.endDate)
|
||||||
|
if (search.attachmentMode && search.attachmentMode !== "all") params.set("attachmentMode", search.attachmentMode)
|
||||||
|
else if (search.hasAttachments) params.set("hasAttachments", "1")
|
||||||
|
if (search.minSizeKb) params.set("minSizeKb", search.minSizeKb)
|
||||||
|
if (search.maxSizeKb) params.set("maxSizeKb", search.maxSizeKb)
|
||||||
|
if (search.readStatus && search.readStatus !== "all") params.set("readStatus", search.readStatus)
|
||||||
|
else if (search.unread) params.set("unread", "1")
|
||||||
|
if (search.flagStatus && search.flagStatus !== "all") params.set("flagStatus", search.flagStatus)
|
||||||
|
else if (search.starred) params.set("starred", "1")
|
||||||
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, init: RequestInit & { timeoutMs?: number } = {}): Promise<T> {
|
async function request<T>(path: string, init: RequestInit & { timeoutMs?: number } = {}): Promise<T> {
|
||||||
const { timeoutMs, ...requestInit } = init
|
const { timeoutMs, ...requestInit } = init
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
@@ -152,18 +190,21 @@ export const api = {
|
|||||||
return request<MailLabel>(`/api/mail/labels${query}`, { method: "POST", body: JSON.stringify({ name: payload.name, color: payload.color || "" }) })
|
return request<MailLabel>(`/api/mail/labels${query}`, { method: "POST", body: JSON.stringify({ name: payload.name, color: payload.color || "" }) })
|
||||||
},
|
},
|
||||||
deleteLabel: (id: string, mailboxId?: string) => request<{ labels: MailLabel[] }>(`/api/mail/labels/${id}${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`, { method: "DELETE" }),
|
deleteLabel: (id: string, mailboxId?: string) => request<{ labels: MailLabel[] }>(`/api/mail/labels/${id}${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`, { method: "DELETE" }),
|
||||||
messages: (folder: string, q = "", cursor = "", mailboxId?: string) => {
|
messages: (folder: string, search: MailSearchParams | string = "", cursor = "", mailboxId?: string) => {
|
||||||
const params = new URLSearchParams({ folder, q, cursor })
|
const params = new URLSearchParams({ folder, cursor })
|
||||||
|
appendMailSearchParams(params, search)
|
||||||
if (mailboxId) params.set("mailboxId", mailboxId)
|
if (mailboxId) params.set("mailboxId", mailboxId)
|
||||||
return request<ListResponse<MailMessage>>(`/api/mail/messages?${params.toString()}`)
|
return request<ListResponse<MailMessage>>(`/api/mail/messages?${params.toString()}`)
|
||||||
},
|
},
|
||||||
labelMessages: (labelId: string, q = "", cursor = "", mailboxId?: string) => {
|
labelMessages: (labelId: string, search: MailSearchParams | string = "", cursor = "", mailboxId?: string) => {
|
||||||
const params = new URLSearchParams({ labelId, q, cursor })
|
const params = new URLSearchParams({ labelId, cursor })
|
||||||
|
appendMailSearchParams(params, search)
|
||||||
if (mailboxId) params.set("mailboxId", mailboxId)
|
if (mailboxId) params.set("mailboxId", mailboxId)
|
||||||
return request<ListResponse<MailMessage>>(`/api/mail/messages?${params.toString()}`)
|
return request<ListResponse<MailMessage>>(`/api/mail/messages?${params.toString()}`)
|
||||||
},
|
},
|
||||||
starredMessages: (q = "", cursor = "", mailboxId?: string) => {
|
starredMessages: (search: MailSearchParams | string = "", cursor = "", mailboxId?: string) => {
|
||||||
const params = new URLSearchParams({ q, cursor })
|
const params = new URLSearchParams({ cursor })
|
||||||
|
appendMailSearchParams(params, search)
|
||||||
if (mailboxId) params.set("mailboxId", mailboxId)
|
if (mailboxId) params.set("mailboxId", mailboxId)
|
||||||
return request<ListResponse<MailMessage>>(`/api/mail/starred?${params.toString()}`)
|
return request<ListResponse<MailMessage>>(`/api/mail/starred?${params.toString()}`)
|
||||||
},
|
},
|
||||||
@@ -199,5 +240,3 @@ export const api = {
|
|||||||
delete: (id: string) => request<{ ok: boolean }>(`/api/mail/messages/${id}`, { method: "DELETE" }),
|
delete: (id: string) => request<{ ok: boolean }>(`/api/mail/messages/${id}`, { method: "DELETE" }),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+499
-164
File diff suppressed because it is too large
Load Diff
+1104
-130
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user