feat: customize NodeSeek-style webmail
This commit is contained in:
@@ -24,6 +24,10 @@ const mailMessagesPageSize = 30
|
||||
|
||||
const customFolderDefaultSortOrderBase = 100000
|
||||
|
||||
func isAllMailboxID(mailboxID string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(mailboxID), "all")
|
||||
}
|
||||
|
||||
type AttachmentInput struct {
|
||||
Filename string `json:"filename"`
|
||||
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) {
|
||||
if isAllMailboxID(r.URL.Query().Get("mailboxId")) {
|
||||
a.handleAllMailFolders(w, r)
|
||||
return
|
||||
}
|
||||
mb, err := a.mailboxForCurrentUser(r)
|
||||
if err != nil {
|
||||
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})
|
||||
}
|
||||
|
||||
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) {
|
||||
mb, err := a.mailboxForCurrentUser(r)
|
||||
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) {
|
||||
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)
|
||||
if err != nil {
|
||||
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) {
|
||||
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)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
@@ -394,9 +467,15 @@ func (a *App) respondMailMessageList(w http.ResponseWriter, r *http.Request, whe
|
||||
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 ?)`
|
||||
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 + "%"
|
||||
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)
|
||||
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})
|
||||
}
|
||||
|
||||
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) {
|
||||
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)
|
||||
if err != nil {
|
||||
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) {
|
||||
user := currentUser(r)
|
||||
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
|
||||
args := []any{user.ID}
|
||||
where := `user_id=?`
|
||||
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
|
||||
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)
|
||||
WHERE `+where+` AND status IN (?,?,?)
|
||||
ORDER BY send_at ASC, created_at DESC`, args...)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load scheduled sends")
|
||||
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) {
|
||||
user := currentUser(r)
|
||||
mb, err := a.mailboxForCurrentUser(r)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
|
||||
status := strings.TrimSpace(r.URL.Query().Get("status"))
|
||||
if strings.EqualFold(status, "all") {
|
||||
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=?`
|
||||
args := []any{user.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 !validSendQueueStatus(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()
|
||||
}
|
||||
|
||||
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) {
|
||||
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
|
||||
@@ -2259,6 +2501,12 @@ func (a *App) labelBelongsToMailbox(ctx context.Context, labelID, mailboxID stri
|
||||
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 {
|
||||
name = strings.Join(strings.Fields(strings.TrimSpace(name)), " ")
|
||||
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"))
|
||||
args := []any{user.ID}
|
||||
where := `mb.user_id=?`
|
||||
if mailboxID != "" {
|
||||
if mailboxID != "" && !isAllMailboxID(mailboxID) {
|
||||
if _, err := a.mailboxForCurrentUserWithID(r, mailboxID); err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
@@ -642,7 +642,7 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load attachment stats")
|
||||
return
|
||||
}
|
||||
if mailboxID != "" {
|
||||
if mailboxID != "" && !isAllMailboxID(mailboxID) {
|
||||
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 {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load quota")
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>LanQin Email</title>
|
||||
<title>NodeSeek 邮箱</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+62
-23
@@ -5,39 +5,46 @@
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--foreground: 222 47% 11%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--card-foreground: 222 47% 11%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--popover-foreground: 222 47% 11%;
|
||||
--primary: 224 44% 12%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--secondary: 213 37% 96%;
|
||||
--secondary-foreground: 222 47% 11%;
|
||||
--muted: 213 37% 96%;
|
||||
--muted-foreground: 216 22% 42%;
|
||||
--accent: 213 37% 94%;
|
||||
--accent-foreground: 222 47% 11%;
|
||||
--destructive: 358 88% 61%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 5.9% 10%;
|
||||
--border: 214 32% 90%;
|
||||
--input: 214 32% 86%;
|
||||
--ring: 216 22% 42%;
|
||||
--radius: 0.5rem;
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-background: 0 0% 100%;
|
||||
--sidebar-foreground: 222 47% 11%;
|
||||
--sidebar-primary: 224 44% 12%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
--sidebar-accent: 213 37% 94%;
|
||||
--sidebar-accent-foreground: 222 47% 11%;
|
||||
--sidebar-border: 214 32% 90%;
|
||||
--sidebar-ring: 216 22% 42%;
|
||||
}
|
||||
|
||||
* { @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 { color-scheme: light; }
|
||||
html.dark { color-scheme: 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 {
|
||||
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 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> {
|
||||
const { timeoutMs, ...requestInit } = init
|
||||
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 || "" }) })
|
||||
},
|
||||
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) => {
|
||||
const params = new URLSearchParams({ folder, q, cursor })
|
||||
messages: (folder: string, search: MailSearchParams | string = "", cursor = "", mailboxId?: string) => {
|
||||
const params = new URLSearchParams({ folder, cursor })
|
||||
appendMailSearchParams(params, search)
|
||||
if (mailboxId) params.set("mailboxId", mailboxId)
|
||||
return request<ListResponse<MailMessage>>(`/api/mail/messages?${params.toString()}`)
|
||||
},
|
||||
labelMessages: (labelId: string, q = "", cursor = "", mailboxId?: string) => {
|
||||
const params = new URLSearchParams({ labelId, q, cursor })
|
||||
labelMessages: (labelId: string, search: MailSearchParams | string = "", cursor = "", mailboxId?: string) => {
|
||||
const params = new URLSearchParams({ labelId, cursor })
|
||||
appendMailSearchParams(params, search)
|
||||
if (mailboxId) params.set("mailboxId", mailboxId)
|
||||
return request<ListResponse<MailMessage>>(`/api/mail/messages?${params.toString()}`)
|
||||
},
|
||||
starredMessages: (q = "", cursor = "", mailboxId?: string) => {
|
||||
const params = new URLSearchParams({ q, cursor })
|
||||
starredMessages: (search: MailSearchParams | string = "", cursor = "", mailboxId?: string) => {
|
||||
const params = new URLSearchParams({ cursor })
|
||||
appendMailSearchParams(params, search)
|
||||
if (mailboxId) params.set("mailboxId", mailboxId)
|
||||
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" }),
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
+492
-157
File diff suppressed because it is too large
Load Diff
+1084
-110
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user