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 (
+
+
)
@@ -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 (
+
+ )
+}
+
+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", `
`)
}
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")} />
fileInputRef.current?.click()}>附件
- 链接
- 图片链接
+ openInsertDialog("link")}>链接
+ openInsertDialog("image")}>图片链接
runCommand("insertHorizontalRule")}>分隔线
} onClick={() => fileInputRef.current?.click()} />
-
} onClick={() => runCommand("insertText", "\n[日程]\n")} />
-
} onClick={() => runCommand("insertText", "🙂")} />
+
} onClick={() => setScheduleOpen(true)} />
+
+
+
+
+
+
+ {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}
+
))}
-
- 字号
+
+ {fontSizeLabel(toolbarState.fontSize)}
- {[["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")}>
@@ -1532,14 +2111,14 @@ function MailBodyComposer({ defaultValue, files, signatureText, onChange, onPick
-
runCommand("insertUnorderedList")}>
-
runCommand("insertOrderedList")}>
+
runCommand("insertUnorderedList")}>
+
runCommand("insertOrderedList")}>
runCommand("outdent")}>
runCommand("indent")}>
-
runCommand("justifyLeft")}>
-
runCommand("justifyCenter")}>
-
runCommand("justifyRight")}>
+
runCommand("justifyLeft")}>
+
runCommand("justifyCenter")}>
+
runCommand("justifyRight")}>
runCommand("formatBlock", "blockquote")}>
runCommand("formatBlock", "pre")}>
@@ -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 (
-
+
{icon}{label}
)
}
-function ToolbarButton({ label, children, onClick, disabled }: { label: string; children: React.ReactNode; onClick?: () => void; disabled?: boolean }) {
- return {children}
+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 (
+
+ )
+}
+
+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 (
+
+ )
+}
+
+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 (
+
+ {children}
+
+ )
}
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" })
+ }))
+}