diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index cd2661c..6d22003 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -68,9 +68,10 @@ func New(cfg Config, logger *slog.Logger) (*App, error) { db.Close() return nil, err } + workerCtx, cancel := context.WithCancel(context.Background()) + a.workerCancel = cancel + go a.scheduledSendWorker(workerCtx) if strings.TrimSpace(cfg.MaildirRoot) != "" { - workerCtx, cancel := context.WithCancel(context.Background()) - a.workerCancel = cancel go a.maildirWorker(workerCtx) } return a, nil @@ -223,6 +224,20 @@ func (a *App) migrate(ctx context.Context) error { storage_path TEXT NOT NULL, created_at TEXT NOT NULL )`, + `CREATE TABLE IF NOT EXISTS scheduled_sends ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE, + draft_id TEXT REFERENCES messages(id) ON DELETE SET NULL, + payload_json TEXT NOT NULL, + send_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + error TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + sent_at TEXT + )`, + `CREATE INDEX IF NOT EXISTS idx_scheduled_sends_due ON scheduled_sends(status, send_at)`, `CREATE TABLE IF NOT EXISTS contacts ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 75d77d9..69d1d64 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -301,6 +301,72 @@ func TestAuthAdminAndLocalDeliveryFlow(t *testing.T) { } } +func TestScheduleSendQueuesFutureMessage(t *testing.T) { + a := newTestApp(t) + ts := httptest.NewServer(a.Router()) + defer ts.Close() + admin := &testClient{t: t, server: ts} + + var login map[string]any + if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK { + t.Fatalf("admin login code=%d", code) + } + var domains struct { + Items []Domain `json:"items"` + } + if code := admin.do("GET", "/api/admin/domains", nil, &domains); code != http.StatusOK || len(domains.Items) == 0 { + t.Fatalf("domains code=%d items=%+v", code, domains.Items) + } + sender := createTestMailbox(t, admin, domains.Items[0].ID, "later", "Later", "Password123!", nil) + recipient := createTestMailbox(t, admin, domains.Items[0].ID, "later-bob", "Later Bob", "Password123!", nil) + + alice := &testClient{t: t, server: ts} + if code := alice.do("POST", "/api/auth/login", map[string]string{"email": sender.Address, "password": "Password123!"}, &login); code != http.StatusOK { + t.Fatalf("sender login code=%d", code) + } + var scheduled ScheduledSend + payload := map[string]any{ + "mailboxId": sender.ID, + "to": []string{recipient.Address}, + "subject": "send later", + "text": "not yet", + "html": "

not yet

", + "sendAt": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339Nano), + } + if code := alice.do("POST", "/api/mail/schedule-send", payload, &scheduled); code != http.StatusCreated || scheduled.Status != "pending" { + t.Fatalf("schedule code=%d scheduled=%+v", code, scheduled) + } + if scheduled.Subject != "send later" || len(scheduled.To) != 1 || scheduled.To[0] != recipient.Address || scheduled.Snippet != "not yet" { + t.Fatalf("scheduled preview not populated: %+v", scheduled) + } + var scheduledList struct { + Items []ScheduledSend `json:"items"` + } + if code := alice.do("GET", "/api/mail/scheduled-sends?mailboxId="+sender.ID, nil, &scheduledList); code != http.StatusOK || len(scheduledList.Items) != 1 || scheduledList.Items[0].ID != scheduled.ID { + t.Fatalf("scheduled list code=%d items=%+v", code, scheduledList.Items) + } + if scheduledList.Items[0].Subject != "send later" || scheduledList.Items[0].Snippet != "not yet" { + t.Fatalf("scheduled list preview not populated: %+v", scheduledList.Items[0]) + } + + bob := &testClient{t: t, server: ts} + if code := bob.do("POST", "/api/auth/login", map[string]string{"email": recipient.Address, "password": "Password123!"}, &login); code != http.StatusOK { + t.Fatalf("recipient login code=%d", code) + } + var inbox struct { + Items []MailMessage `json:"items"` + } + if code := bob.do("GET", "/api/mail/messages?folder=Inbox", nil, &inbox); code != http.StatusOK || len(inbox.Items) != 0 { + t.Fatalf("future scheduled mail should not be delivered immediately: code=%d items=%+v", code, inbox.Items) + } + if code := alice.do("DELETE", "/api/mail/schedule-send/"+scheduled.ID, nil, &map[string]any{}); code != http.StatusOK { + t.Fatalf("cancel scheduled send code=%d", code) + } + if code := alice.do("GET", "/api/mail/scheduled-sends?mailboxId="+sender.ID, nil, &scheduledList); code != http.StatusOK || len(scheduledList.Items) != 0 { + t.Fatalf("scheduled list after cancel code=%d items=%+v", code, scheduledList.Items) + } +} + func TestOpenRegistrationCreatesLoginUserOnly(t *testing.T) { a := newTestApp(t) ts := httptest.NewServer(a.Router()) diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index 9078f23..bc6e462 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/base64" + "encoding/json" "errors" "fmt" "io" @@ -283,17 +284,57 @@ func (a *App) handleMailMessage(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, msg) } +type mailComposeInput 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 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"` + 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 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"` - } + var req mailComposeInput if err := decodeJSON(r, &req); err != nil { badRequest(w, err) return @@ -303,11 +344,34 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusNotFound, "mailbox not found") return } + msg, err := a.sendMailNow(r.Context(), mb, req) + if err != nil { + if errors.Is(err, errNoRecipients) { + badRequest(w, err) + return + } + if errors.Is(err, errInvalidMIME) { + badRequest(w, err) + return + } + if strings.HasPrefix(err.Error(), "smtp delivery failed:") { + respondError(w, http.StatusBadGateway, 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") + +func (a *App) sendMailNow(ctx context.Context, mb *Mailbox, req mailComposeInput) (*MailMessage, error) { 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 { - badRequest(w, errors.New("at least one recipient is required")) - return + return nil, errNoRecipients } if strings.TrimSpace(req.Subject) == "" { req.Subject = "(no subject)" @@ -326,26 +390,22 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) { From: mb.Address, FromName: mb.DisplayName, 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 { - badRequest(w, err) - return + return nil, fmt.Errorf("%w: %v", errInvalidMIME, err) } if a.cfg.SMTPHost != "" { if err := a.sendSMTP(mb.Address, allRecipients, mimeBytes); err != nil { - respondError(w, http.StatusBadGateway, "smtp delivery failed: "+err.Error()) - return + return nil, fmt.Errorf("smtp delivery failed: %w", err) } } - sentFolderID, err := a.ensureFolder(r.Context(), mb.ID, "Sent") + sentFolderID, err := a.ensureFolder(ctx, mb.ID, "Sent") if err != nil { - respondError(w, http.StatusInternalServerError, "failed to load sent folder") - return + 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: mb.Address, FromName: mb.DisplayName, 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(r.Context(), base, req.Attachments) + sentID, err := a.insertMessage(ctx, base, req.Attachments) if err != nil { - respondError(w, http.StatusInternalServerError, "failed to store sent message") - return + return nil, fmt.Errorf("failed to store sent message: %w", err) } // Development/local-domain delivery: known local recipients go to their Inbox. @@ -354,9 +414,9 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) { localRecipients := append(req.To, req.CC...) localRecipients = append(localRecipients, req.BCC...) for _, rcpt := range localRecipients { - rcptMailbox, err := a.mailboxByAddress(r.Context(), rcpt) + rcptMailbox, err := a.mailboxByAddress(ctx, rcpt) if err != nil { - if !a.cfg.CatchAllEnabled || !a.isLocalDomainAddress(r.Context(), rcpt) { + if !a.cfg.CatchAllEnabled || !a.isLocalDomainAddress(ctx, rcpt) { continue } copyMsg := base @@ -365,22 +425,22 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) { copyMsg.RecipientAddr = normalizeEmail(rcpt) copyMsg.MessageUID = newID("uid") copyMsg.IsRead = false - _, _ = a.insertMessage(r.Context(), copyMsg, req.Attachments) + _, _ = a.insertMessage(ctx, copyMsg, req.Attachments) continue } if rcptMailbox.Status != "active" { - if a.cfg.CatchAllEnabled && a.isLocalDomainAddress(r.Context(), rcpt) { + 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 - _, _ = a.insertMessage(r.Context(), copyMsg, req.Attachments) + _, _ = a.insertMessage(ctx, copyMsg, req.Attachments) } continue } - inboxID, err := a.ensureFolder(r.Context(), rcptMailbox.ID, "Inbox") + inboxID, err := a.ensureFolder(ctx, rcptMailbox.ID, "Inbox") if err != nil { continue } @@ -389,13 +449,346 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) { copyMsg.FolderID = inboxID copyMsg.MessageUID = newID("uid") copyMsg.IsRead = false - if inboxMsgID, err := a.insertMessage(r.Context(), copyMsg, req.Attachments); err == nil { - a.applyInboundControls(r.Context(), inboxMsgID, rcptMailbox.ID, copyMsg.From, copyMsg.Subject) + if inboxMsgID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil { + a.applyInboundControls(ctx, inboxMsgID, rcptMailbox.ID, copyMsg.From, copyMsg.Subject) } } - msg, _ := a.messageByID(r.Context(), sentID, true) - respondJSON(w, http.StatusCreated, msg) + msg, _ := a.messageByID(ctx, sentID, true) + return msg, nil +} + +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 + } + 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 + } + 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 + } + _, 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=?,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, 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 + } + } + } + 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.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 + } + 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) 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} + 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, 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) 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 + } + 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, mb, compose); err != nil { + a.markScheduledSendFailed(ctx, id, err.Error()) + return + } + if draftID != "" { + a.deleteMessageFiles(ctx, draftID) + _, _ = a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, 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 { diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go index b5cb1ca..5d3f870 100644 --- a/apps/api/internal/app/router_auth.go +++ b/apps/api/internal/app/router_auth.go @@ -70,6 +70,12 @@ func (a *App) Router() http.Handler { r.Get("/mail/starred", a.handleStarredMessages) r.Get("/mail/messages/{id}", a.handleMailMessage) r.Post("/mail/send", a.handleMailSend) + r.Get("/mail/scheduled-sends", a.handleScheduledSends) + r.Post("/mail/schedule-send", a.handleScheduleSend) + r.Delete("/mail/schedule-send/{id}", a.handleCancelScheduledSend) + r.Post("/mail/drafts", a.handleSaveDraft) + r.Post("/mail/drafts/{id}", a.handleSaveDraft) + r.Delete("/mail/drafts/{id}", a.handleDeleteDraft) r.Post("/mail/messages/{id}/mark-read", a.handleMarkRead) r.Post("/mail/messages/{id}/star", a.handleStar) r.Post("/mail/messages/{id}/labels", a.handleAddMessageLabel) diff --git a/apps/api/internal/app/util.go b/apps/api/internal/app/util.go index d4f5b72..85fdeec 100644 --- a/apps/api/internal/app/util.go +++ b/apps/api/internal/app/util.go @@ -133,6 +133,13 @@ func boolInt(v bool) int { func intBool(v int) bool { return v != 0 } +func nullableString(v string) any { + if strings.TrimSpace(v) == "" { + return nil + } + return v +} + func parseTime(v string) time.Time { t, _ := time.Parse(time.RFC3339Nano, v) return t diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index ced3e2d..14e335f 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -15,6 +15,9 @@ export type DNSRecord = { type: string; name: string; value: string; ttl: number export type DNSCheckResult = { domain: string; status: string; checks: Record } export type ListResponse = { items: T[]; nextCursor?: string } export type SendPayload = { mailboxId?: string; to: string[]; cc: string[]; bcc: string[]; subject: string; text: string; html: string; attachments: { filename: string; contentType: string; contentBase64: string }[] } +export type DraftPayload = Omit & { attachments?: SendPayload["attachments"] } +export type ScheduleSendPayload = SendPayload & { draftId?: string; sendAt: string } +export type ScheduledSend = { id: string; mailboxId: string; draftId?: string; subject: string; to: string[]; snippet: string; sendAt: string; status: "pending" | "sending" | "sent" | "failed" | "cancelled"; error?: string; createdAt: string; updatedAt: string; sentAt?: string } export type Contact = { id: string; name: string; email: string; note: string; createdAt: string } export type MailSignature = { id: string; mailboxId: string; name: string; content: string; isDefault: boolean; createdAt: string; updatedAt: string } export type MailRuleCondition = { field: "from" | "to" | "subject" | "body"; operator: "contains" | "not-contains" | "equals" | "not-equals" | "starts-with" | "ends-with"; value: string } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 4ebb0c6..f11c585 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, MailboxApplyOptions, MailTemplate, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload } from "./api-types" +import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, MailboxApplyOptions, MailTemplate, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload } from "./api-types" export * from "./api-types" const REQUEST_TIMEOUT_MS = 15_000 @@ -121,6 +121,11 @@ export const api = { }, message: (id: string, options: { markRead?: boolean } = {}) => request(`/api/mail/messages/${id}${options.markRead === false ? "?markRead=0" : ""}`), send: (payload: SendPayload) => request("/api/mail/send", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }), + scheduledSends: (mailboxId?: string) => request>(`/api/mail/scheduled-sends${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`), + scheduleSend: (payload: ScheduleSendPayload) => request("/api/mail/schedule-send", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }), + cancelScheduledSend: (id: string) => request<{ ok: boolean }>(`/api/mail/schedule-send/${id}`, { method: "DELETE" }), + saveDraft: (payload: DraftPayload, id?: string) => request(id ? `/api/mail/drafts/${id}` : "/api/mail/drafts", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }), + deleteDraft: (id: string) => request<{ ok: boolean }>(`/api/mail/drafts/${id}`, { method: "DELETE" }), markRead: (id: string, read: boolean) => request<{ ok: boolean }>(`/api/mail/messages/${id}/mark-read`, { method: "POST", body: JSON.stringify({ read }) }), star: (id: string, starred: boolean) => request<{ ok: boolean }>(`/api/mail/messages/${id}/star`, { method: "POST", body: JSON.stringify({ starred }) }), addLabel: (id: string, payload: { name: string; color?: string }) => request<{ labels: MailLabel[] }>(`/api/mail/messages/${id}/labels`, { method: "POST", body: JSON.stringify(payload) }), diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index cee09e9..99f6bed 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -3,8 +3,8 @@ import DOMPurify from "dompurify" import { type InfiniteData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useNavigate } from "react-router-dom" import type { ImperativePanelHandle } from "react-resizable-panels" -import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Bold, Calendar, Check, ChevronDown, ChevronsUpDown, Code2, Copy, Ellipsis, Eraser, FileText, Forward, Highlighter, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, Search, Send, Settings, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, X } from "lucide-react" -import { api, Mailbox, MailFolder, MailLabel, MailMessage, SendPayload } from "@/lib/api" +import { AlignCenter, AlignLeft, AlignRight, Archive, ArrowLeft, Bold, Calendar, Check, ChevronDown, ChevronsUpDown, Clock3, Code2, Copy, Ellipsis, Eraser, FileText, Forward, Highlighter, Image, Inbox, IndentDecrease, IndentIncrease, Italic, Link, List, ListOrdered, Mail, MailCheck, Moon, PanelLeftClose, PanelLeftOpen, Paperclip, PencilLine, Plus, Quote, Redo2, RefreshCcw, Reply, Search, Send, Settings, Signature, SlidersHorizontal, Smile, Star, Strikethrough, Sun, Tag, Trash2, Type, Underline, Undo2, X } from "lucide-react" +import { api, Mailbox, MailFolder, MailLabel, MailMessage, SendPayload, DraftPayload, ScheduledSend } from "@/lib/api" import { cn, formatBytes, formatDate, formatDateTime } from "@/lib/utils" import { applyTheme, getInitialTheme } from "@/lib/theme" import { useDisplayMode } from "@/lib/display-mode" @@ -15,6 +15,7 @@ import { Checkbox } from "@/components/ui/checkbox" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Label } from "@/components/ui/label" import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { ScrollArea } from "@/components/ui/scroll-area" import { Separator } from "@/components/ui/separator" import { Skeleton } from "@/components/ui/skeleton" @@ -36,7 +37,7 @@ import { import { useMe } from "@/hooks/use-me" import { useToast } from "@/hooks/use-toast" -const folderIcons: Record = { inbox: , sent: , archive: , trash: } +const folderIcons: Record = { inbox: , sent: , drafts: , archive: , spam: , trash: } const folderLabels: Record = { Inbox: "收件箱", Sent: "已发送", @@ -46,14 +47,15 @@ const folderLabels: Record = { Trash: "回收站", } -type ComposeDraft = { key: string; to?: string; cc?: string; bcc?: string; subject?: string; text?: string } +type ComposeDraft = { key: string; id?: string; mailboxId?: string; to?: string; cc?: string; bcc?: string; subject?: string; text?: string; html?: string; files?: File[]; isDraft?: boolean } type MailFilter = "all" | "unread" | "starred" | "attachments" -type MailView = "folder" | "starred" | "label" +type MailView = "folder" | "starred" | "label" | "scheduled" type MailListResponse = { items?: MailMessage[]; nextCursor?: string } type PendingConfirm = { title: string; description?: string; confirmText: string; onConfirm: () => void } type MailNotificationState = { latestId: string; latestReceivedAt: string } type MailMenuItem = | { type: "starred"; key: string; label: string; icon: React.ReactNode; count: number } + | { type: "scheduled"; key: string; label: string; icon: React.ReactNode; count: number } | { type: "folder"; key: string; folderName: string; label: string; icon: React.ReactNode; count: number } const filterLabels: Record = { @@ -86,6 +88,7 @@ export function MailPage() { const [lastAutoRefreshAt, setLastAutoRefreshAt] = React.useState(null) const [bulkPending, setBulkPending] = React.useState(false) const [pendingConfirm, setPendingConfirm] = React.useState(null) + const [cancelingScheduledId, setCancelingScheduledId] = React.useState("") const sidebarPanelRef = React.useRef(null) const themeMountedRef = React.useRef(false) const mailNotifyStateRef = React.useRef>({}) @@ -99,6 +102,7 @@ export function MailPage() { const folders = useQuery({ queryKey: ["folders", activeMailboxId], queryFn: () => api.folders(activeMailboxId), enabled: !!activeMailboxId }) const labels = useQuery({ queryKey: ["labels", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId }) const mailStats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId }) + const scheduledSends = useQuery({ queryKey: ["scheduled-sends", activeMailboxId], queryFn: () => api.scheduledSends(activeMailboxId), enabled: !!activeMailboxId, refetchInterval: 30000 }) const mailRefreshInterval = publicSettings.data?.mailAutoRefresh ? Math.max(publicSettings.data.mailRefreshMs || 30000, 5000) : false const inboxProbe = useQuery({ queryKey: ["mail-notifications", activeMailboxId], @@ -117,7 +121,7 @@ export function MailPage() { }, initialPageParam: "", getNextPageParam: (lastPage) => lastPage.nextCursor || undefined, - enabled: !!activeMailboxId && (mailView !== "label" || !!selectedLabelId), + enabled: !!activeMailboxId && mailView !== "scheduled" && (mailView !== "label" || !!selectedLabelId), }) const detail = useQuery({ queryKey: ["message", selectedId], queryFn: () => api.message(selectedId!, { markRead: false }), enabled: !!selectedId }) function updateCachedMessage(id: string, patch: Partial) { @@ -183,6 +187,16 @@ export function MailPage() { }) const del = useMutation({ mutationFn: (id: string) => api.delete(id), onSuccess: async () => { setSelectedId(null); setPendingConfirm(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已删除" }) }, onError: (error) => toast({ title: "删除失败", description: error.message }) }) const move = useMutation({ mutationFn: ({ id, folder }: { id: string; folder: string }) => api.move(id, folder), onSuccess: async () => { setSelectedId(null); await qc.invalidateQueries({ queryKey: ["messages"] }); await qc.invalidateQueries({ queryKey: ["folders"] }); await qc.invalidateQueries({ queryKey: ["mail-stats"] }); await qc.invalidateQueries({ queryKey: ["labels"] }); toast({ title: "已移动" }) } }) + const cancelScheduledSend = useMutation({ + mutationFn: (item: ScheduledSend) => api.cancelScheduledSend(item.id), + onMutate: (item) => setCancelingScheduledId(item.id), + onSuccess: async (_, item) => { + await qc.invalidateQueries({ queryKey: ["scheduled-sends"] }) + toast({ title: item.status === "failed" ? "已移除失败记录" : "已取消定时发送" }) + }, + onError: (error) => toast({ title: "操作失败", description: error instanceof Error ? error.message : "请稍后重试" }), + onSettled: () => setCancelingScheduledId(""), + }) const markAllRead = useMutation({ mutationFn: async (items: MailMessage[]) => { const unread = items.filter((message) => !message.isRead) @@ -309,6 +323,7 @@ export function MailPage() { qc.invalidateQueries({ queryKey: ["folders"] }), qc.invalidateQueries({ queryKey: ["mail-stats"] }), qc.invalidateQueries({ queryKey: ["labels"] }), + qc.invalidateQueries({ queryKey: ["scheduled-sends"] }), qc.invalidateQueries({ queryKey: ["mail-notifications"] }), ]).finally(() => { setLastAutoRefreshAt(new Date()) @@ -328,11 +343,18 @@ export function MailPage() { }) const unreadCount = allMessages.filter((message) => !message.isRead).length const starredCount = mailStats.data?.starredMessages ?? (mailView === "starred" ? allMessages.length : 0) - const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount) + const scheduledItems = scheduledSends.data?.items || [] + const scheduledDraftIds = new Set(scheduledItems.map((item) => item.draftId).filter((draftId): draftId is string => Boolean(draftId))) + const scheduledCount = scheduledItems.length + const scheduledQuery = query.trim().toLowerCase() + const visibleScheduledItems = scheduledQuery + ? scheduledItems.filter((item) => [item.subject, item.snippet, ...(item.to || [])].join(" ").toLowerCase().includes(scheduledQuery)) + : scheduledItems + const mailMenuItems = buildMailMenuItems(folders.data?.items || [], starredCount, scheduledCount) const labelItems = labels.data?.items || [] const selectedLabel = labelItems.find((item) => item.id === selectedLabelId) - const viewTitle = mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder - const emptyMessage = allMessages.length === 0 ? (mailView === "starred" ? "暂无星标邮件" : mailView === "label" ? "当前标签没有邮件" : "当前文件夹没有邮件") : "当前筛选条件下没有邮件" + const viewTitle = mailView === "scheduled" ? "待发送" : mailView === "starred" ? "星标邮件" : mailView === "label" ? selectedLabel?.name || "标签" : folderLabels[folder] || folder + const emptyMessage = getEmptyMessage(mailView, folder, allMessages.length) const visibleMessageIds = visibleMessages.map((message) => message.id) const selectedCountOnPage = compactSelectedIds.filter((id) => visibleMessageIds.includes(id)).length const compactAllSelected = visibleMessageIds.length > 0 && selectedCountOnPage === visibleMessageIds.length @@ -351,6 +373,7 @@ export function MailPage() { qc.invalidateQueries({ queryKey: ["folders"] }), qc.invalidateQueries({ queryKey: ["mail-stats"] }), qc.invalidateQueries({ queryKey: ["labels"] }), + qc.invalidateQueries({ queryKey: ["scheduled-sends"] }), ]) } async function runBulkAction(action: BulkAction) { @@ -404,6 +427,32 @@ export function MailPage() { function openCompose(draft?: ComposeDraft) { setComposeDraft(draft || { key: `new-${Date.now()}` }); setComposeOpen(true) } function openReply(message: MailMessage) { openCompose({ key: `reply-${message.id}-${Date.now()}`, to: message.from, subject: withPrefix(message.subject, "Re:"), text: quoteMessage(message) }) } function openForward(message: MailMessage) { openCompose({ key: `forward-${message.id}-${Date.now()}`, subject: withPrefix(message.subject, "Fwd:"), text: quoteMessage(message) }) } + async function openDraft(message: MailMessage) { + if (scheduledDraftIds.has(message.id)) { + toast({ title: "这封草稿已在待发送队列中", description: "请先取消定时发送,再继续编辑。" }) + openScheduled() + return + } + try { + const detail = await api.message(message.id, { markRead: false }) + openCompose({ + key: `draft-${detail.id}-${Date.now()}`, + id: detail.id, + mailboxId: detail.mailboxId, + to: detail.to.join(", "), + cc: detail.cc.join(", "), + bcc: (detail.bcc || []).join(", "), + subject: detail.subject === "(无主题)" ? "" : detail.subject, + text: detail.bodyText || "", + html: detail.bodyHtml || "", + files: await attachmentFilesFromMessage(detail), + isDraft: true, + }) + setSelectedId(null) + } catch (error) { + toast({ title: "打开草稿失败", description: error instanceof Error ? error.message : "请稍后重试" }) + } + } function switchMailbox(mailboxId: string) { setSelectedMailboxId(mailboxId) setFolder("Inbox") @@ -424,6 +473,12 @@ export function MailPage() { setSelectedId(null) setMailFilter("all") } + function openScheduled() { + setMailView("scheduled") + setSelectedLabelId("") + setSelectedId(null) + setMailFilter("all") + } function openLabel(labelId: string) { setSelectedLabelId(labelId) setMailView("label") @@ -431,9 +486,16 @@ export function MailPage() { setMailFilter("all") } function openMessage(messageId: string | null) { - setSelectedId(messageId) - if (!messageId) return + if (!messageId) { + setSelectedId(null) + return + } const message = allMessages.find((item) => item.id === messageId) + if (message?.folder === "Drafts") { + void openDraft(message) + return + } + setSelectedId(messageId) if (message && !message.isRead) { markRead.mutate({ id: message.id, read: true }) } @@ -506,9 +568,9 @@ export function MailPage() { {mailMenuItems.map((item) => ( item.type === "starred" ? openStarred() : openFolder(item.folderName)} + onClick={() => item.type === "starred" ? openStarred() : item.type === "scheduled" ? openScheduled() : openFolder(item.folderName)} > {item.icon} {!sidebarCollapsed && {item.label}} @@ -564,28 +626,42 @@ export function MailPage() { {autoRefreshing ? "自动刷新中..." : lastAutoRefreshAt ? `已刷新 ${lastAutoRefreshAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : "自动刷新已开启"} )} - - - - - - - {(Object.keys(filterLabels) as MailFilter[]).map((value) => ( - setMailFilter(value)}> - {filterLabels[value]} - - ))} - - + {mailView !== "scheduled" && ( + <> + + + + + + + {(Object.keys(filterLabels) as MailFilter[]).map((value) => ( + setMailFilter(value)}> + {filterLabels[value]} + + ))} + + + + )}
- setQuery(e.target.value)} placeholder="搜索邮件" className="pl-9" /> + setQuery(e.target.value)} placeholder={mailView === "scheduled" ? "搜索待发送" : "搜索邮件"} className="pl-9" />
{!mailboxList.isLoading && !hasMailboxes ? ( + ) : mailView === "scheduled" ? ( + cancelScheduledSend.mutate(item)} + /> ) : displayMode === "compact" ? ( setSelectedId(null)} onStar={(message) => star.mutate({ id: message.id, starred: !message.isStarred })} onReply={openReply} @@ -636,7 +713,7 @@ export function MailPage() { {messages.isLoading && } - {visibleMessages.map((m) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} />)} + {visibleMessages.map((m) => toggleCompactSelect(m.id, checked)} onClick={() => openMessage(m.id)} onStar={() => star.mutate({ id: m.id, starred: !m.isStarred })} />)} {!messages.isLoading && visibleMessages.length === 0 &&
{emptyMessage}
} {!messages.isLoading && hasMoreMessages && (
@@ -695,7 +772,7 @@ export function MailPage() { - { setComposeOpen(open); if (!open) setComposeDraft(undefined) }} onSent={() => { setComposeOpen(false); setComposeDraft(undefined); qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["labels"] }) }} /> + { setComposeOpen(open); if (!open) setComposeDraft(undefined) }} onSent={() => { setComposeOpen(false); setComposeDraft(undefined); qc.invalidateQueries({ queryKey: ["messages"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["mail-stats"] }); qc.invalidateQueries({ queryKey: ["labels"] }); qc.invalidateQueries({ queryKey: ["scheduled-sends"] }) }} /> ({ +function buildMailMenuItems(folders: MailFolder[], starredCount: number, scheduledCount: number): MailMenuItem[] { + const byName = new Map(folders.map((item) => [item.name, item])) + const normalizedFolders = ["Inbox", "Drafts", "Sent", "Archive", "Spam", "Trash"].map((name) => byName.get(name) || { id: `virtual-${name}`, name, role: name.toLowerCase(), unreadCount: 0, totalCount: 0 }) + for (const item of folders) { + if (!normalizedFolders.some((folder) => folder.name === item.name)) normalizedFolders.push(item) + } + const folderItems: MailMenuItem[] = normalizedFolders.map((item) => ({ type: "folder", key: item.id, folderName: item.name, label: folderLabels[item.name] || item.name, icon: folderIcons[item.role] || , - count: item.unreadCount, + count: item.name === "Drafts" ? item.totalCount : item.unreadCount, })) const starredItem: MailMenuItem = { type: "starred", key: "starred", label: "星标邮件", icon: , count: starredCount } + const scheduledItem: MailMenuItem = { type: "scheduled", key: "scheduled", label: "待发送", icon: , count: scheduledCount } const inboxIndex = folderItems.findIndex((item) => item.type === "folder" && item.folderName === "Inbox") const insertAt = inboxIndex >= 0 ? inboxIndex + 1 : 0 - return [...folderItems.slice(0, insertAt), starredItem, ...folderItems.slice(insertAt)] + return [...folderItems.slice(0, insertAt), starredItem, scheduledItem, ...folderItems.slice(insertAt)] } function FolderSkeleton() { return
} function MessageSkeleton() { return
{Array.from({ length: 6 }).map((_, i) =>
)}
} +function getEmptyMessage(mailView: MailView, folder: string, total: number) { + if (mailView === "scheduled") return total === 0 ? "没有待发送邮件" : "当前搜索没有匹配的定时邮件" + if (total > 0) return "当前筛选条件下没有邮件" + if (mailView === "starred") return "暂无星标邮件" + if (mailView === "label") return "当前标签没有邮件" + if (folder === "Inbox") return "收件箱暂时为空" + if (folder === "Drafts") return "还没有草稿" + if (folder === "Sent") return "还没有已发送邮件" + if (folder === "Trash") return "回收站是空的" + if (folder === "Spam") return "暂无垃圾邮件" + return "当前文件夹没有邮件" +} + function NoMailboxState({ onOpenSettings }: { onOpenSettings: () => void }) { return (
@@ -745,6 +841,79 @@ function NoMailboxState({ onOpenSettings }: { onOpenSettings: () => void }) { ) } +function ScheduledSendView({ compact, items, total, loading, query, cancelingId, onCancel }: { compact: boolean; items: ScheduledSend[]; total: number; loading: boolean; query: string; cancelingId: string; onCancel: (item: ScheduledSend) => void }) { + const empty = query.trim() ? "当前搜索没有匹配的定时邮件" : "没有待发送邮件" + return ( +
+
+
+
待发送
+
{items.length} / {total} 封定时邮件
+
+
+ + {loading && } + {!loading && items.length === 0 &&
{empty}
} + {!loading && items.map((item) => ( + onCancel(item)} /> + ))} +
+
+ ) +} + +function ScheduledSendSkeleton() { + return ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ + + +
+ ))} +
+ ) +} + +function ScheduledSendRow({ item, compact, pending, onCancel }: { item: ScheduledSend; compact: boolean; pending: boolean; onCancel: () => void }) { + const recipients = item.to?.length ? item.to.join(", ") : "未填写收件人" + const failed = item.status === "failed" + return ( +
+
+
+
+ {item.subject || "(无主题)"} + +
+
发给 {recipients}
+ {item.snippet &&
{item.snippet}
} + {failed && item.error &&
{item.error}
} +
+
+
发送时间
+
{formatDateTime(item.sendAt)}
+
+
+ +
+
+
+ ) +} + +function ScheduledStatusBadge({ status }: { status: ScheduledSend["status"] }) { + const label = status === "pending" ? "等待发送" : status === "sending" ? "发送中" : status === "failed" ? "发送失败" : status === "sent" ? "已发送" : "已取消" + return ( + + {label} + + ) +} + type BulkAction = "read" | "unread" | "star" | "unstar" | "archive" | "trash" | "spam" | "delete" function BulkActionMenu({ pending, onAction }: { pending: boolean; onAction: (action: BulkAction) => void }) { @@ -789,6 +958,7 @@ function CompactMailView({ onSelect, onSelectAll, onToggleSelected, + scheduledDraftIds, onLoadMore, onCloseReader, onStar, @@ -821,6 +991,7 @@ function CompactMailView({ onSelect: (id: string | null) => void onSelectAll: (checked: boolean) => void onToggleSelected: (id: string, checked: boolean) => void + scheduledDraftIds: Set onLoadMore: () => void onCloseReader: () => void onStar: (message: MailMessage) => void @@ -881,7 +1052,7 @@ function CompactMailView({
{loading && } - {messages.map((message) => onToggleSelected(message.id, checked)} onClick={() => onSelect(message.id)} onStar={() => onStar(message)} />)} + {messages.map((message) => onToggleSelected(message.id, checked)} onClick={() => onSelect(message.id)} onStar={() => onStar(message)} />)} {!loading && messages.length === 0 &&
{emptyMessage}
} {!loading && hasMore && (
@@ -935,12 +1106,18 @@ function CompactMessageDetail({
- {selected && } + {selected?.folder === "Drafts" ? ( + + ) : ( + <> + {selected && } + {selected && } + {selected && } + {selected && } + {selected && } + + )} {selected && } - {selected && } - {selected && } - {selected && } - {selected && }
@@ -988,7 +1165,7 @@ function CompactMessageDetail({ ) } -function CompactMessageRow({ message, active, checked, onCheckedChange, onClick, onStar }: { message: MailMessage; active: boolean; checked: boolean; onCheckedChange: (checked: boolean) => void; onClick: () => void; onStar: () => void }) { +function CompactMessageRow({ message, active, checked, scheduled, onCheckedChange, onClick, onStar }: { message: MailMessage; active: boolean; checked: boolean; scheduled?: boolean; onCheckedChange: (checked: boolean) => void; onClick: () => void; onStar: () => void }) { const visibleLabels = (message.labels || []).slice(0, 2) const senderName = senderDisplayName(message) return ( @@ -999,6 +1176,7 @@ function CompactMessageRow({ message, active, checked, onCheckedChange, onClick,
{message.subject} {message.snippet} + {scheduled && 已定时} {visibleLabels.map((label) => )} {message.hasAttachments && }
@@ -1142,6 +1320,7 @@ function MessageRow({ message, active, checked, + scheduled, onCheckedChange, onClick, onStar, @@ -1149,6 +1328,7 @@ function MessageRow({ message: MailMessage active: boolean checked: boolean + scheduled?: boolean onCheckedChange: (checked: boolean) => void onClick: () => void onStar: () => void @@ -1184,6 +1364,7 @@ function MessageRow({
{message.subject} + {scheduled && 已定时} {visibleLabels.map((label) => )} {hiddenLabelCount > 0 && +{hiddenLabelCount}} {message.hasAttachments && } @@ -1251,35 +1432,139 @@ function MessageLabels({ messageLabels, availableLabels, onAdd, onRemove, pendin function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox?: Mailbox; open: boolean; draft?: ComposeDraft; onOpenChange: (v: boolean) => void; onSent: () => void }) { const { toast } = useToast() + const qc = useQueryClient() const [files, setFiles] = React.useState([]) + const [draftAttachments, setDraftAttachments] = React.useState([]) + const [attachmentsTouched, setAttachmentsTouched] = React.useState(false) + const [draftId, setDraftId] = React.useState(draft?.id || "") + const [toValue, setToValue] = React.useState(draft?.to || "") + const [ccValue, setCcValue] = React.useState(draft?.cc || "") + const [bccValue, setBccValue] = React.useState(draft?.bcc || "") + const [subjectValue, setSubjectValue] = React.useState(draft?.subject || "") + const [draftStatus, setDraftStatus] = React.useState<"idle" | "saving" | "saved" | "error">("idle") + const [lastSavedAt, setLastSavedAt] = React.useState(null) + const [scheduleDialogOpen, setScheduleDialogOpen] = React.useState(false) + const sendStartedRef = React.useRef(false) + const lastSavedPayloadRef = React.useRef("") const [showCc, setShowCc] = React.useState(Boolean(draft?.cc)) const [showBcc, setShowBcc] = React.useState(Boolean(draft?.bcc)) const [sendSeparately, setSendSeparately] = React.useState(false) const defaultSignature = useQuery({ queryKey: ["signature", "default", mailbox?.id], queryFn: () => api.defaultSignature(mailbox?.id), enabled: open && !!mailbox?.id }) const signatureText = defaultSignature.data?.signature?.content || "" - const composerText = draft?.text !== undefined ? draft.text : signatureText ? `\n\n-- \n${signatureText}` : "" - const [body, setBody] = React.useState(() => plainTextComposerValue(composerText)) + const composerText = draft?.html || (draft?.text !== undefined ? draft.text : signatureText ? `\n\n-- \n${signatureText}` : "") + const [body, setBody] = React.useState(() => draft?.html !== undefined ? htmlComposerValue(draft.html) : plainTextComposerValue(composerText)) + const activeMailboxId = draft?.mailboxId || mailbox?.id || "" + const composePayload = React.useMemo(() => ({ + mailboxId: activeMailboxId, + to: splitEmails(toValue), + cc: showCc ? splitEmails(ccValue) : [], + bcc: showBcc ? splitEmails(bccValue) : [], + subject: subjectValue, + text: body.text, + html: body.html || plainTextToHtml(body.text), + ...(attachmentsTouched ? { attachments: draftAttachments } : {}), + }), [activeMailboxId, toValue, showCc, ccValue, showBcc, bccValue, subjectValue, body, attachmentsTouched, draftAttachments]) + const hasDraftContent = open && !!activeMailboxId && (toValue.trim() || ccValue.trim() || bccValue.trim() || subjectValue.trim() || body.text.trim() || body.html.trim()) const send = useMutation({ mutationFn: async (payloads: SendPayload[]) => { const sent: MailMessage[] = [] for (const payload of payloads) sent.push(await api.send(payload)) return sent }, - onSuccess: (_, payloads) => { + onSuccess: async (_, payloads) => { + if (draftId) { + try { + await api.deleteDraft(draftId) + } catch {} + } toast({ title: payloads.length > 1 ? `已分别发送 ${payloads.length} 封邮件` : "发送成功" }) setFiles([]) + setDraftId("") onSent() }, onError: (e) => toast({ title: "发送失败", description: e.message }), }) + const scheduleSend = useMutation({ + mutationFn: (payload: SendPayload & { draftId?: string; sendAt: string }) => api.scheduleSend(payload), + onSuccess: (scheduled) => { + sendStartedRef.current = true + toast({ title: `已定时发送 ${formatDateTime(scheduled.sendAt)}` }) + setScheduleDialogOpen(false) + setFiles([]) + void Promise.all([ + qc.invalidateQueries({ queryKey: ["messages"] }), + qc.invalidateQueries({ queryKey: ["folders"] }), + qc.invalidateQueries({ queryKey: ["mail-stats"] }), + qc.invalidateQueries({ queryKey: ["scheduled-sends"] }), + ]) + onSent() + }, + onError: (e) => toast({ title: "定时发送失败", description: e.message }), + }) React.useEffect(() => { if (!open) return - setShowCc(Boolean(draft?.cc)) - setShowBcc(Boolean(draft?.bcc)) + sendStartedRef.current = false + const nextShowCc = Boolean(draft?.cc) + const nextShowBcc = Boolean(draft?.bcc) + const nextBody = draft?.html !== undefined ? htmlComposerValue(draft.html) : plainTextComposerValue(composerText) + lastSavedPayloadRef.current = JSON.stringify({ + mailboxId: draft?.mailboxId || mailbox?.id || "", + to: splitEmails(draft?.to || ""), + cc: nextShowCc ? splitEmails(draft?.cc || "") : [], + bcc: nextShowBcc ? splitEmails(draft?.bcc || "") : [], + subject: draft?.subject || "", + text: nextBody.text, + html: nextBody.html || plainTextToHtml(nextBody.text), + draftId: draft?.id || "", + }) + setDraftId(draft?.id || "") + setToValue(draft?.to || "") + setCcValue(draft?.cc || "") + setBccValue(draft?.bcc || "") + setSubjectValue(draft?.subject || "") + setBody(nextBody) + setDraftStatus("idle") + setLastSavedAt(null) + setShowCc(nextShowCc) + setShowBcc(nextShowBcc) setSendSeparately(false) - setFiles([]) - }, [open, draft?.key, draft?.cc, draft?.bcc]) + setFiles(draft?.files || []) + setDraftAttachments([]) + setAttachmentsTouched(false) + }, [open, draft?.key, draft?.id, draft?.mailboxId, draft?.to, draft?.cc, draft?.bcc, draft?.subject, draft?.html, draft?.files, mailbox?.id, composerText]) + + React.useEffect(() => { + let cancelled = false + Promise.all(files.map(fileToAttachment)).then((attachments) => { + if (!cancelled) setDraftAttachments(attachments) + }) + return () => { cancelled = true } + }, [files]) + + React.useEffect(() => { + if (!open || sendStartedRef.current || !hasDraftContent) return + const payloadKey = JSON.stringify({ ...composePayload, draftId }) + if (payloadKey === lastSavedPayloadRef.current) return + const timer = window.setTimeout(async () => { + try { + setDraftStatus("saving") + const saved = await api.saveDraft(composePayload, draftId || undefined) + setDraftId(saved.id) + lastSavedPayloadRef.current = JSON.stringify({ ...composePayload, draftId: saved.id }) + setLastSavedAt(new Date()) + setDraftStatus("saved") + await Promise.all([ + qc.invalidateQueries({ queryKey: ["messages"] }), + qc.invalidateQueries({ queryKey: ["folders"] }), + qc.invalidateQueries({ queryKey: ["mail-stats"] }), + ]) + } catch { + setDraftStatus("error") + } + }, 5000) + return () => window.clearTimeout(timer) + }, [open, hasDraftContent, composePayload, draftId, qc]) async function submit(e: React.FormEvent) { e.preventDefault() @@ -1287,26 +1572,51 @@ function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox toast({ title: "请选择发件邮箱" }) return } - const form = new FormData(e.currentTarget) + sendStartedRef.current = true const attachments = await Promise.all(files.map(fileToAttachment)) - const to = splitEmails(String(form.get("to") || "")) - const cc = showCc ? splitEmails(String(form.get("cc") || "")) : [] - const bcc = showBcc ? splitEmails(String(form.get("bcc") || "")) : [] + const to = splitEmails(toValue) + const cc = showCc ? splitEmails(ccValue) : [] + const bcc = showBcc ? splitEmails(bccValue) : [] const text = body.text const html = body.html || plainTextToHtml(text) - const payload: SendPayload = { mailboxId: mailbox.id, to, cc, bcc, subject: String(form.get("subject") || ""), text, html, attachments } + const payload: SendPayload = { mailboxId: mailbox.id, to, cc, bcc, subject: subjectValue, text, html, attachments } const separateRecipients = Array.from(new Set([...to, ...cc, ...bcc])) const payloads = sendSeparately && separateRecipients.length > 0 ? separateRecipients.map((recipient): SendPayload => ({ ...payload, to: [recipient], cc: [], bcc: [] })) : [payload] send.mutate(payloads) } + async function scheduleAt(sendAt: string) { + if (!mailbox) { + toast({ title: "请选择发件邮箱" }) + return + } + const attachments = await Promise.all(files.map(fileToAttachment)) + const payload: SendPayload & { draftId?: string; sendAt: string } = { + mailboxId: mailbox.id, + to: splitEmails(toValue), + cc: showCc ? splitEmails(ccValue) : [], + bcc: showBcc ? splitEmails(bccValue) : [], + subject: subjectValue, + text: body.text, + html: body.html || plainTextToHtml(body.text), + attachments, + draftId: draftId || undefined, + sendAt, + } + scheduleSend.mutate(payload) + } return (
- 写信 + + {draftId ? "编辑草稿" : "写信"} + + {draftStatus === "saving" ? "正在保存草稿..." : draftStatus === "saved" && lastSavedAt ? `草稿已保存 ${lastSavedAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : draftStatus === "error" ? "草稿保存失败" : ""} + +
@@ -1325,35 +1635,38 @@ function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox
} > - + setToValue(event.target.value)} required className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" /> {showCc && ( - + setCcValue(event.target.value)} className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" /> )} {showBcc && ( - + setBccValue(event.target.value)} className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" /> )} - + setSubjectValue(event.target.value)} className="h-10 flex-1 rounded-none border-0 px-0 shadow-none focus-visible:ring-0" /> setFiles((current) => [...current, ...nextFiles])} - onRemoveFile={(index) => setFiles((current) => current.filter((_, itemIndex) => itemIndex !== index))} + onPickFiles={(nextFiles) => { setAttachmentsTouched(true); setFiles((current) => [...current, ...nextFiles]) }} + onRemoveFile={(index) => { setAttachmentsTouched(true); setFiles((current) => current.filter((_, itemIndex) => itemIndex !== index)) }} />
+ + ) @@ -1371,19 +1684,187 @@ function ComposeField({ label, children, action }: { label: string; children: Re ) } -type ComposerValue = { text: string; html: string } +function ScheduleSendDialog({ open, pending, onOpenChange, onConfirm }: { open: boolean; pending: boolean; onOpenChange: (open: boolean) => void; onConfirm: (sendAt: string) => void }) { + const [value, setValue] = React.useState("") + const { toast } = useToast() + const presets = React.useMemo(() => scheduledSendPresets(), [open]) -function MailBodyComposer({ defaultValue, files, signatureText, onChange, onPickFiles, onRemoveFile }: { defaultValue: string; files: File[]; signatureText: string; onChange: (value: ComposerValue) => void; onPickFiles: (files: File[]) => void; onRemoveFile: (index: number) => void }) { + React.useEffect(() => { + if (open) setValue(defaultScheduledSendValue()) + }, [open]) + + function submit(event: React.FormEvent) { + event.preventDefault() + const date = new Date(value) + if (!value || Number.isNaN(date.getTime()) || !date.getTime()) { + toast({ title: "请选择发送时间" }) + return + } + if (date.getTime() <= Date.now() + 30_000) { + toast({ title: "发送时间需要晚于当前时间" }) + return + } + onConfirm(date.toISOString()) + } + + return ( + + +
+ + 定时发送 + +
+ {presets.map((preset) => ( + + ))} +
+
+ + setValue(event.target.value)} /> +
+ + + + +
+
+
+ ) +} + +type ComposerValue = { text: string; html: string } +type InsertDialogState = { kind: "link" | "image"; selectedText: string } +type InsertDialogValue = { url: string; text: string; alt: string } +type EditorToolbarState = { + bold: boolean + italic: boolean + underline: boolean + strikeThrough: boolean + unorderedList: boolean + orderedList: boolean + justifyLeft: boolean + justifyCenter: boolean + justifyRight: boolean + fontName: string + fontSize: string +} + +const defaultToolbarState: EditorToolbarState = { + bold: false, + italic: false, + underline: false, + strikeThrough: false, + unorderedList: false, + orderedList: false, + justifyLeft: false, + justifyCenter: false, + justifyRight: false, + fontName: "", + fontSize: "3", +} +const composerFontOptions = ["Arial", "Georgia", "Times New Roman", "Courier New", "Microsoft YaHei"] +const composerFontSizeOptions = [ + ["2", "小号"], + ["3", "正文"], + ["4", "中号"], + ["5", "大号"], +] as const +const composerEmojiOptions = ["😀", "😄", "😊", "🙂", "😉", "😍", "😘", "😎", "🤔", "👍", "👏", "🙏", "💪", "🎉", "🔥", "✨", "❤️", "✅", "📌", "📅", "☕", "💡", "🚀", "⭐"] + +function queryCommandState(command: string) { + try { + return document.queryCommandState(command) + } catch { + return false + } +} + +function queryCommandValue(command: string) { + try { + return String(document.queryCommandValue(command) || "") + } catch { + return "" + } +} + +function normalizeFontName(value: string) { + const cleaned = value.replace(/["']/g, "").split(",")[0]?.trim() || "" + if (!cleaned || cleaned === "默认字体") return "" + if (/microsoft yahei/i.test(cleaned) || cleaned.includes("微软雅黑")) return "Microsoft YaHei" + const lower = cleaned.toLowerCase() + return composerFontOptions.find((font) => { + const option = font.toLowerCase() + return lower === option || lower.includes(option) || option.includes(lower) + }) || "" +} + +function normalizeFontSize(value: string) { + const cleaned = value.trim().toLowerCase() + if (!cleaned) return "" + if (composerFontSizeOptions.some(([size]) => size === cleaned)) return cleaned + const px = Number(cleaned.replace("px", "")) + if (Number.isFinite(px)) { + if (px <= 13) return "2" + if (px <= 17) return "3" + if (px <= 22) return "4" + return "5" + } + if (cleaned.includes("small")) return "2" + if (cleaned.includes("large") || cleaned.includes("x-large")) return "5" + if (cleaned.includes("medium") || cleaned.includes("normal")) return "3" + return "" +} + +function fontLabel(value: string) { + const normalized = normalizeFontName(value) + if (!normalized) return "默认字体" + return normalized === "Microsoft YaHei" ? "微软雅黑" : normalized +} + +function fontSizeLabel(value: string) { + const normalized = normalizeFontSize(value) || "3" + return composerFontSizeOptions.find(([size]) => size === normalized)?.[1] || "正文" +} + +function selectionElementInside(root: HTMLElement | null) { + const selection = window.getSelection() + const node = selection?.anchorNode + if (!root || !node || !root.contains(node)) return null + return node instanceof HTMLElement ? node : node.parentElement +} + +function normalizeInsertUrl(value: string, kind: InsertDialogState["kind"]) { + const trimmed = value.trim() + if (!trimmed) return "" + const allowed = kind === "image" ? /^(https?:|cid:|data:image\/|\/)/i : /^(https?:|mailto:|tel:|#|\/)/i + return allowed.test(trimmed) ? trimmed : `https://${trimmed}` +} + +function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onChange, onPickFiles, onRemoveFile }: { defaultValue: string; defaultHtml?: string; files: File[]; signatureText: string; onChange: (value: ComposerValue) => void; onPickFiles: (files: File[]) => void; onRemoveFile: (index: number) => void }) { const editorRef = React.useRef(null) const fileInputRef = React.useRef(null) - const initialHtmlRef = React.useRef(plainTextToHtml(defaultValue)) + const selectionRef = React.useRef(null) + const initialHtmlRef = React.useRef(defaultHtml !== undefined ? DOMPurify.sanitize(defaultHtml) : plainTextToHtml(defaultValue)) const dirtyRef = React.useRef(false) const lastDefaultRef = React.useRef(defaultValue) const [formatOpen, setFormatOpen] = React.useState(true) + const [scheduleOpen, setScheduleOpen] = React.useState(false) + const [emojiOpen, setEmojiOpen] = React.useState(false) + const [insertDialog, setInsertDialog] = React.useState(null) + const [toolbarState, setToolbarState] = React.useState(defaultToolbarState) const [empty, setEmpty] = React.useState(!defaultValue.trim()) React.useEffect(() => { - const next = plainTextComposerValue(defaultValue) + const next = defaultHtml !== undefined ? htmlComposerValue(defaultHtml) : plainTextComposerValue(defaultValue) onChange(next) setEmpty(!next.text.trim()) }, []) @@ -1392,17 +1873,47 @@ function MailBodyComposer({ defaultValue, files, signatureText, onChange, onPick if (defaultValue === lastDefaultRef.current) return lastDefaultRef.current = defaultValue if (!dirtyRef.current) { - const next = plainTextComposerValue(defaultValue) + const next = defaultHtml !== undefined ? htmlComposerValue(defaultHtml) : plainTextComposerValue(defaultValue) if (editorRef.current) editorRef.current.innerHTML = next.html setEmpty(!next.text.trim()) onChange(next) } - }, [defaultValue, onChange]) + }, [defaultValue, defaultHtml, onChange]) + + React.useEffect(() => { + function handleSelectionChange() { + const selection = window.getSelection() + const anchorNode = selection?.anchorNode + if (!anchorNode || !editorRef.current?.contains(anchorNode)) return + saveSelection() + updateToolbarState() + } + document.addEventListener("selectionchange", handleSelectionChange) + return () => document.removeEventListener("selectionchange", handleSelectionChange) + }, []) function focusEditor() { window.requestAnimationFrame(() => editorRef.current?.focus()) } + function saveSelection() { + const selection = window.getSelection() + const anchorNode = selection?.anchorNode + if (!selection || selection.rangeCount === 0 || !anchorNode || !editorRef.current?.contains(anchorNode)) return + selectionRef.current = selection.getRangeAt(0).cloneRange() + } + + function restoreSelection() { + if (!selectionRef.current) return + try { + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(selectionRef.current) + } catch { + selectionRef.current = null + } + } + function syncEditor() { const next = composerValueFromElement(editorRef.current) setEmpty(!next.text.trim()) @@ -1412,21 +1923,57 @@ function MailBodyComposer({ defaultValue, files, signatureText, onChange, onPick function runCommand(command: string, value?: string) { dirtyRef.current = true editorRef.current?.focus() + restoreSelection() document.execCommand(command, false, value) + saveSelection() syncEditor() + updateToolbarState() } - function insertLink() { - const url = window.prompt("链接地址") + function updateToolbarState() { + const currentElement = selectionElementInside(editorRef.current) + const computedStyle = currentElement ? window.getComputedStyle(currentElement) : null + setToolbarState((current) => ({ + ...current, + bold: queryCommandState("bold"), + italic: queryCommandState("italic"), + underline: queryCommandState("underline"), + strikeThrough: queryCommandState("strikeThrough"), + unorderedList: queryCommandState("insertUnorderedList"), + orderedList: queryCommandState("insertOrderedList"), + justifyLeft: queryCommandState("justifyLeft"), + justifyCenter: queryCommandState("justifyCenter"), + justifyRight: queryCommandState("justifyRight"), + fontName: normalizeFontName(queryCommandValue("fontName")) || normalizeFontName(computedStyle?.fontFamily || "") || current.fontName, + fontSize: normalizeFontSize(queryCommandValue("fontSize")) || normalizeFontSize(computedStyle?.fontSize || "") || current.fontSize, + })) + } + + function applyFont(font: string) { + runCommand("fontName", font) + setToolbarState((current) => ({ ...current, fontName: font })) + } + + function applyFontSize(size: string) { + runCommand("fontSize", size) + setToolbarState((current) => ({ ...current, fontSize: size })) + } + + function openInsertDialog(kind: InsertDialogState["kind"]) { + saveSelection() + setInsertDialog({ kind, selectedText: selectionRef.current?.toString().trim() || "" }) + } + + function confirmInsert(value: InsertDialogValue) { + if (!insertDialog) return + const url = normalizeInsertUrl(value.url, insertDialog.kind) if (!url) return - const selected = window.getSelection()?.toString() - if (selected) runCommand("createLink", url) - else runCommand("insertHTML", `${escapeHtml(url)}`) - } - - function insertImage() { - const url = window.prompt("图片链接") - if (url) runCommand("insertImage", url) + if (insertDialog.kind === "link") { + const text = value.text.trim() || insertDialog.selectedText || value.url.trim() + runCommand("insertHTML", `${escapeHtml(text)}`) + return + } + runCommand("insertHTML", `${escapeHtml(value.alt.trim())}`) } function insertSignature() { @@ -1434,6 +1981,17 @@ function MailBodyComposer({ defaultValue, files, signatureText, onChange, onPick runCommand("insertHTML", `

--
${plainTextToHtmlFragment(signatureText)}`) } + function insertSchedule(schedule: ScheduleDraft) { + const normalized = normalizeSchedule(schedule) + runCommand("insertHTML", scheduleToHtml(normalized)) + onPickFiles([scheduleToFile(normalized)]) + } + + function insertEmoji(emoji: string) { + runCommand("insertText", emoji) + setEmojiOpen(false) + } + function handlePickedFiles(event: React.ChangeEvent) { const nextFiles = Array.from(event.currentTarget.files || []) if (nextFiles.length > 0) onPickFiles(nextFiles) @@ -1443,11 +2001,11 @@ function MailBodyComposer({ defaultValue, files, signatureText, onChange, onPick return (
-
+
runCommand("undo")}> runCommand("redo")}> - } onClick={insertImage} /> + } onClick={() => openInsertDialog("image")} /> + + +
+ {composerEmojiOptions.map((emoji) => ( + + ))} +
+
+
} active={formatOpen} onClick={() => setFormatOpen((value) => !value)} />
} onClick={insertSignature} disabled={!signatureText.trim()} /> @@ -1471,38 +2044,44 @@ function MailBodyComposer({ defaultValue, files, signatureText, onChange, onPick
{formatOpen && ( -
+
runCommand("removeFormat")}> - - {["Arial", "Georgia", "Times New Roman", "Courier New"].map((font) => ( - runCommand("fontName", font)}>{font} + {composerFontOptions.map((font) => ( + applyFont(font)}> + + {font} + ))} - - {[["2", "小号"], ["3", "正文"], ["4", "中号"], ["5", "大号"]].map(([size, label]) => ( - runCommand("fontSize", size)}>{label} + {composerFontSizeOptions.map(([size, label]) => ( + applyFontSize(size)}> + + {label} + ))} - runCommand("bold")}> - runCommand("italic")}> - runCommand("underline")}> - runCommand("strikeThrough")}> + runCommand("bold")}> + runCommand("italic")}> + runCommand("underline")}> + runCommand("strikeThrough")}>
@@ -1553,8 +2132,10 @@ function MailBodyComposer({ defaultValue, files, signatureText, onChange, onPick contentEditable suppressContentEditableWarning className="mail-html min-h-[280px] overflow-y-auto px-6 py-5 text-base leading-7 outline-none" - onFocus={focusEditor} - onInput={() => { dirtyRef.current = true; syncEditor() }} + onFocus={() => { saveSelection(); updateToolbarState() }} + onMouseUp={() => { saveSelection(); updateToolbarState() }} + onKeyUp={() => { saveSelection(); updateToolbarState() }} + onInput={() => { dirtyRef.current = true; saveSelection(); syncEditor(); updateToolbarState() }} onBlur={syncEditor} dangerouslySetInnerHTML={{ __html: initialHtmlRef.current }} /> @@ -1575,24 +2156,378 @@ function MailBodyComposer({ defaultValue, files, signatureText, onChange, onPick
)} + { if (!open) setInsertDialog(null) }} onConfirm={confirmInsert} /> + { insertSchedule(schedule); setScheduleOpen(false) }} />
) } function ToolbarTextButton({ label, icon, active, disabled, onClick }: { label: string; icon: React.ReactNode; active?: boolean; disabled?: boolean; onClick?: () => void }) { return ( - ) } -function ToolbarButton({ label, children, onClick, disabled }: { label: string; children: React.ReactNode; onClick?: () => void; disabled?: boolean }) { - return +function InsertContentDialog({ state, onOpenChange, onConfirm }: { state: InsertDialogState | null; onOpenChange: (open: boolean) => void; onConfirm: (value: InsertDialogValue) => void }) { + const kind = state?.kind || "link" + const [url, setUrl] = React.useState("") + const [text, setText] = React.useState("") + const [alt, setAlt] = React.useState("") + + React.useEffect(() => { + if (!state) return + setUrl("") + setText(state.kind === "link" ? state.selectedText : "") + setAlt("") + }, [state]) + + function submit(event: React.FormEvent) { + event.preventDefault() + if (!url.trim()) return + onConfirm({ url, text, alt }) + onOpenChange(false) + } + + return ( + + +
+ + {kind === "link" ? "插入链接" : "插入图片"} + +
+ + setUrl(event.target.value)} placeholder={kind === "link" ? "https://example.com" : "https://example.com/image.png"} autoFocus /> +
+ {kind === "link" ? ( +
+ + setText(event.target.value)} placeholder="默认使用链接地址" /> +
+ ) : ( +
+ + setAlt(event.target.value)} placeholder="图片说明" /> +
+ )} + + + + +
+
+
+ ) +} + +type ScheduleDraft = { + title: string + start: string + durationMinutes: number + reminderMinutes: number + repeat: "none" | "daily" | "weekly" | "monthly" | "yearly" + allDay: boolean + customDuration: boolean + customReminder: boolean + lunar: boolean + location: string + description: string +} + +const durationOptions = [ + { value: "15", label: "15分钟" }, + { value: "30", label: "30分钟" }, + { value: "60", label: "1小时" }, + { value: "120", label: "2小时" }, + { value: "1440", label: "1天" }, +] +const reminderOptions = [ + { value: "0", label: "准时" }, + { value: "5", label: "5分钟前" }, + { value: "15", label: "15分钟前" }, + { value: "30", label: "30分钟前" }, + { value: "60", label: "1小时前" }, + { value: "1440", label: "1天前" }, +] +const repeatOptions = [ + { value: "none", label: "永不" }, + { value: "daily", label: "每天" }, + { value: "weekly", label: "每周" }, + { value: "monthly", label: "每月" }, + { value: "yearly", label: "每年" }, +] as const + +function ScheduleDialog({ open, onOpenChange, onConfirm }: { open: boolean; onOpenChange: (open: boolean) => void; onConfirm: (schedule: ScheduleDraft) => void }) { + const [duration, setDuration] = React.useState("60") + const [reminder, setReminder] = React.useState("15") + const [repeat, setRepeat] = React.useState("none") + const [allDay, setAllDay] = React.useState(false) + const [customDuration, setCustomDuration] = React.useState(false) + const [customReminder, setCustomReminder] = React.useState(false) + const [lunar, setLunar] = React.useState(false) + const defaultStart = React.useMemo(() => defaultScheduleStartValue(), [open]) + const { toast } = useToast() + + function submit(event: React.FormEvent) { + event.preventDefault() + const form = new FormData(event.currentTarget) + const title = String(form.get("title") || "").trim() + if (!title) { + toast({ title: "请输入日程主题" }) + return + } + const durationMinutes = customDuration ? Number(form.get("customDuration") || 60) : Number(duration) + const reminderMinutes = customReminder ? Number(form.get("customReminder") || 15) : Number(reminder) + onConfirm({ + title, + start: String(form.get("start") || defaultStart), + durationMinutes: Math.max(1, durationMinutes || 60), + reminderMinutes: Math.max(0, reminderMinutes || 0), + repeat, + allDay, + customDuration, + customReminder, + lunar, + location: String(form.get("location") || ""), + description: String(form.get("description") || ""), + }) + event.currentTarget.reset() + setDuration("60") + setReminder("15") + setRepeat("none") + setAllDay(false) + setCustomDuration(false) + setCustomReminder(false) + setLunar(false) + } + + return ( + + + + 新建日程 + +
+ +
+ + + + + + {customDuration ? ( + + ) : ( + + )} + + + + {customReminder ? ( + + ) : ( + + )} + + + + + + + + + + + + +
+ + + + +
+
+
+ ) +} + +function ScheduleRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ) +} + +function CheckLabel({ id, label, checked, onCheckedChange }: { id: string; label: string; checked: boolean; onCheckedChange: (checked: boolean) => void }) { + return ( +
+ onCheckedChange(value === true)} /> + +
+ ) +} + +function ToolbarButton({ label, children, active, onClick, disabled }: { label: string; children: React.ReactNode; active?: boolean; onClick?: () => void; disabled?: boolean }) { + return ( + + ) } function splitEmails(s: string) { return s.split(/[;,,\s]+/).map((v) => v.trim()).filter(Boolean) } +function defaultScheduledSendValue() { + const date = new Date(Date.now() + 30 * 60 * 1000) + const minute = date.getMinutes() + date.setMinutes(minute + (5 - (minute % 5 || 5))) + return toDateTimeLocalValue(date) +} +function scheduledSendPresets() { + return [ + { label: "30 分钟后", value: toDateTimeLocalValue(new Date(Date.now() + 30 * 60 * 1000)) }, + { label: "2 小时后", value: toDateTimeLocalValue(new Date(Date.now() + 2 * 60 * 60 * 1000)) }, + { label: "明早 9 点", value: toDateTimeLocalValue(nextMorningAtNine()) }, + { label: "下周一 9 点", value: toDateTimeLocalValue(nextMondayAtNine()) }, + ] +} +function nextMorningAtNine() { + const date = new Date() + date.setDate(date.getDate() + 1) + date.setHours(9, 0, 0, 0) + return date +} +function nextMondayAtNine() { + const date = new Date() + const day = date.getDay() + const daysUntilMonday = (8 - day) % 7 || 7 + date.setDate(date.getDate() + daysUntilMonday) + date.setHours(9, 0, 0, 0) + return date +} +function defaultScheduleStartValue() { + const date = new Date() + date.setMinutes(date.getMinutes() + (60 - (date.getMinutes() % 60 || 60))) + return toDateTimeLocalValue(date) +} +function toDateTimeLocalValue(date: Date) { + const pad = (value: number) => String(value).padStart(2, "0") + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}` +} +function normalizeSchedule(schedule: ScheduleDraft): ScheduleDraft { + return { ...schedule, title: schedule.title.trim(), location: schedule.location.trim(), description: schedule.description.trim() } +} +function scheduleToHtml(schedule: ScheduleDraft) { + const start = parseScheduleStart(schedule) + const end = schedule.allDay ? new Date(start.getTime() + 24 * 60 * 60 * 1000) : new Date(start.getTime() + schedule.durationMinutes * 60 * 1000) + const rows = [ + ["时间", schedule.allDay ? formatDate(start.toISOString()) : `${formatDateTime(start.toISOString())} - ${formatTimeOnly(end)}`], + ["持续", schedule.allDay ? "全天" : durationLabel(schedule.durationMinutes)], + ["提醒", reminderLabel(schedule.reminderMinutes)], + ["重复", repeatLabel(schedule.repeat)], + schedule.location ? ["位置", schedule.location] : undefined, + schedule.description ? ["描述", schedule.description] : undefined, + ].filter(Boolean) as string[][] + return DOMPurify.sanitize(` +
+
${escapeHtml(schedule.title)}
+ ${rows.map(([label, value]) => `
${label}:${escapeHtml(value)}
`).join("")} +
+ `) +} +function scheduleToFile(schedule: ScheduleDraft) { + const ics = scheduleToIcs(schedule) + const filename = `${safeFilename(schedule.title || "schedule")}.ics` + return new File([ics], filename, { type: "text/calendar;charset=utf-8" }) +} +function scheduleToIcs(schedule: ScheduleDraft) { + const start = parseScheduleStart(schedule) + const end = schedule.allDay ? new Date(start.getTime() + 24 * 60 * 60 * 1000) : new Date(start.getTime() + schedule.durationMinutes * 60 * 1000) + const uid = `${Date.now()}-${Math.random().toString(36).slice(2)}@lanqin-email` + const lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//LanQin Email//Webmail//CN", + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + "BEGIN:VEVENT", + `UID:${uid}`, + `DTSTAMP:${toIcsDateTime(new Date())}`, + schedule.allDay ? `DTSTART;VALUE=DATE:${toIcsDate(start)}` : `DTSTART:${toIcsDateTime(start)}`, + schedule.allDay ? `DTEND;VALUE=DATE:${toIcsDate(end)}` : `DTEND:${toIcsDateTime(end)}`, + `SUMMARY:${escapeIcs(schedule.title)}`, + schedule.location ? `LOCATION:${escapeIcs(schedule.location)}` : "", + schedule.description ? `DESCRIPTION:${escapeIcs(schedule.description)}` : "", + schedule.repeat !== "none" ? `RRULE:FREQ=${schedule.repeat.toUpperCase()}` : "", + ].filter(Boolean) + if (schedule.reminderMinutes > 0) { + lines.push("BEGIN:VALARM", `TRIGGER:-PT${schedule.reminderMinutes}M`, "ACTION:DISPLAY", `DESCRIPTION:${escapeIcs(schedule.title)}`, "END:VALARM") + } + lines.push("END:VEVENT", "END:VCALENDAR") + return `${lines.join("\r\n")}\r\n` +} +function parseScheduleStart(schedule: ScheduleDraft) { + const value = schedule.allDay ? `${schedule.start.slice(0, 10)}T00:00` : schedule.start + const date = new Date(value) + return Number.isNaN(date.getTime()) ? new Date() : date +} +function toIcsDateTime(date: Date) { + const pad = (value: number) => String(value).padStart(2, "0") + return `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}T${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z` +} +function toIcsDate(date: Date) { + const pad = (value: number) => String(value).padStart(2, "0") + return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` +} +function formatTimeOnly(date: Date) { + return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }) +} +function durationLabel(minutes: number) { + if (minutes % 1440 === 0) return `${minutes / 1440}天` + if (minutes % 60 === 0) return `${minutes / 60}小时` + return `${minutes}分钟` +} +function reminderLabel(minutes: number) { + if (minutes <= 0) return "准时" + return `${durationLabel(minutes)}前` +} +function repeatLabel(repeat: ScheduleDraft["repeat"]) { + return ({ none: "永不", daily: "每天", weekly: "每周", monthly: "每月", yearly: "每年" } as Record)[repeat] +} +function safeFilename(value: string) { + return value.trim().replace(/[\\/:*?"<>|]+/g, "-").replace(/\s+/g, "-").slice(0, 64) || "schedule" +} +function escapeIcs(value: string) { + return value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/,/g, "\\,").replace(/;/g, "\\;") +} function plainTextComposerValue(value: string): ComposerValue { return { text: value, html: plainTextToHtml(value) } } +function htmlComposerValue(value: string): ComposerValue { + const html = DOMPurify.sanitize(value || "") + const text = stripHtml(html) + return { text, html: html || plainTextToHtml(text) } +} function composerValueFromElement(element: HTMLElement | null): ComposerValue { const text = (element?.innerText || "").replace(/\u00a0/g, " ").trimEnd() if (!text.trim()) return { text: "", html: "" } @@ -1644,3 +2579,11 @@ async function fileToAttachment(file: File) { for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]) return { filename: file.name, contentType: file.type || "application/octet-stream", contentBase64: btoa(binary) } } +async function attachmentFilesFromMessage(message: MailMessage) { + if (!message.attachments?.length) return [] + return Promise.all(message.attachments.map(async (attachment) => { + const response = await fetch(`/api/mail/attachments/${attachment.id}`, { credentials: "include" }) + const blob = await response.blob() + return new File([blob], attachment.filename, { type: attachment.contentType || blob.type || "application/octet-stream" }) + })) +}