feat(mail): 支持草稿与定时发送
- 新增草稿保存、草稿编辑/删除、定时发送列表与取消接口,并在后台加入待发送任务表与定时发送工作协程。 - 邮件撰写页支持草稿自动保存、定时发送弹窗、草稿入口识别,以及待发送视图与状态标记。 - 同步补充前端 API 类型与调用,并增加定时发送流程的后端测试。
This commit is contained in:
@@ -68,9 +68,10 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
|||||||
db.Close()
|
db.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(cfg.MaildirRoot) != "" {
|
|
||||||
workerCtx, cancel := context.WithCancel(context.Background())
|
workerCtx, cancel := context.WithCancel(context.Background())
|
||||||
a.workerCancel = cancel
|
a.workerCancel = cancel
|
||||||
|
go a.scheduledSendWorker(workerCtx)
|
||||||
|
if strings.TrimSpace(cfg.MaildirRoot) != "" {
|
||||||
go a.maildirWorker(workerCtx)
|
go a.maildirWorker(workerCtx)
|
||||||
}
|
}
|
||||||
return a, nil
|
return a, nil
|
||||||
@@ -223,6 +224,20 @@ func (a *App) migrate(ctx context.Context) error {
|
|||||||
storage_path TEXT NOT NULL,
|
storage_path TEXT NOT NULL,
|
||||||
created_at 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 (
|
`CREATE TABLE IF NOT EXISTS contacts (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -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": "<p>not yet</p>",
|
||||||
|
"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) {
|
func TestOpenRegistrationCreatesLoginUserOnly(t *testing.T) {
|
||||||
a := newTestApp(t)
|
a := newTestApp(t)
|
||||||
ts := httptest.NewServer(a.Router())
|
ts := httptest.NewServer(a.Router())
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -283,8 +284,7 @@ func (a *App) handleMailMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondJSON(w, http.StatusOK, msg)
|
respondJSON(w, http.StatusOK, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) {
|
type mailComposeInput struct {
|
||||||
var req struct {
|
|
||||||
MailboxID string `json:"mailboxId"`
|
MailboxID string `json:"mailboxId"`
|
||||||
To []string `json:"to"`
|
To []string `json:"to"`
|
||||||
CC []string `json:"cc"`
|
CC []string `json:"cc"`
|
||||||
@@ -294,6 +294,47 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) {
|
|||||||
HTML string `json:"html"`
|
HTML string `json:"html"`
|
||||||
Attachments []AttachmentInput `json:"attachments"`
|
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 mailComposeInput
|
||||||
if err := decodeJSON(r, &req); err != nil {
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
@@ -303,11 +344,34 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||||
return
|
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)
|
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...)...)
|
allRecipients := append(append([]string{}, req.To...), append(req.CC, req.BCC...)...)
|
||||||
if len(allRecipients) == 0 {
|
if len(allRecipients) == 0 {
|
||||||
badRequest(w, errors.New("at least one recipient is required"))
|
return nil, errNoRecipients
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(req.Subject) == "" {
|
if strings.TrimSpace(req.Subject) == "" {
|
||||||
req.Subject = "(no 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,
|
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 {
|
if err != nil {
|
||||||
badRequest(w, err)
|
return nil, fmt.Errorf("%w: %v", errInvalidMIME, err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if a.cfg.SMTPHost != "" {
|
if a.cfg.SMTPHost != "" {
|
||||||
if err := a.sendSMTP(mb.Address, allRecipients, mimeBytes); err != nil {
|
if err := a.sendSMTP(mb.Address, allRecipients, mimeBytes); err != nil {
|
||||||
respondError(w, http.StatusBadGateway, "smtp delivery failed: "+err.Error())
|
return nil, fmt.Errorf("smtp delivery failed: %w", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sentFolderID, err := a.ensureFolder(r.Context(), mb.ID, "Sent")
|
sentFolderID, err := a.ensureFolder(ctx, mb.ID, "Sent")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load sent folder")
|
return nil, fmt.Errorf("failed to load sent folder: %w", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
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}
|
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 {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to store sent message")
|
return nil, fmt.Errorf("failed to store sent message: %w", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Development/local-domain delivery: known local recipients go to their Inbox.
|
// 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(req.To, req.CC...)
|
||||||
localRecipients = append(localRecipients, req.BCC...)
|
localRecipients = append(localRecipients, req.BCC...)
|
||||||
for _, rcpt := range localRecipients {
|
for _, rcpt := range localRecipients {
|
||||||
rcptMailbox, err := a.mailboxByAddress(r.Context(), rcpt)
|
rcptMailbox, err := a.mailboxByAddress(ctx, rcpt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !a.cfg.CatchAllEnabled || !a.isLocalDomainAddress(r.Context(), rcpt) {
|
if !a.cfg.CatchAllEnabled || !a.isLocalDomainAddress(ctx, rcpt) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
copyMsg := base
|
copyMsg := base
|
||||||
@@ -365,22 +425,22 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) {
|
|||||||
copyMsg.RecipientAddr = normalizeEmail(rcpt)
|
copyMsg.RecipientAddr = normalizeEmail(rcpt)
|
||||||
copyMsg.MessageUID = newID("uid")
|
copyMsg.MessageUID = newID("uid")
|
||||||
copyMsg.IsRead = false
|
copyMsg.IsRead = false
|
||||||
_, _ = a.insertMessage(r.Context(), copyMsg, req.Attachments)
|
_, _ = a.insertMessage(ctx, copyMsg, req.Attachments)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if rcptMailbox.Status != "active" {
|
if rcptMailbox.Status != "active" {
|
||||||
if a.cfg.CatchAllEnabled && a.isLocalDomainAddress(r.Context(), rcpt) {
|
if a.cfg.CatchAllEnabled && a.isLocalDomainAddress(ctx, rcpt) {
|
||||||
copyMsg := base
|
copyMsg := base
|
||||||
copyMsg.MailboxID = ""
|
copyMsg.MailboxID = ""
|
||||||
copyMsg.FolderID = ""
|
copyMsg.FolderID = ""
|
||||||
copyMsg.RecipientAddr = normalizeEmail(rcpt)
|
copyMsg.RecipientAddr = normalizeEmail(rcpt)
|
||||||
copyMsg.MessageUID = newID("uid")
|
copyMsg.MessageUID = newID("uid")
|
||||||
copyMsg.IsRead = false
|
copyMsg.IsRead = false
|
||||||
_, _ = a.insertMessage(r.Context(), copyMsg, req.Attachments)
|
_, _ = a.insertMessage(ctx, copyMsg, req.Attachments)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
inboxID, err := a.ensureFolder(r.Context(), rcptMailbox.ID, "Inbox")
|
inboxID, err := a.ensureFolder(ctx, rcptMailbox.ID, "Inbox")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -389,13 +449,346 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) {
|
|||||||
copyMsg.FolderID = inboxID
|
copyMsg.FolderID = inboxID
|
||||||
copyMsg.MessageUID = newID("uid")
|
copyMsg.MessageUID = newID("uid")
|
||||||
copyMsg.IsRead = false
|
copyMsg.IsRead = false
|
||||||
if inboxMsgID, err := a.insertMessage(r.Context(), copyMsg, req.Attachments); err == nil {
|
if inboxMsgID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
|
||||||
a.applyInboundControls(r.Context(), inboxMsgID, rcptMailbox.ID, copyMsg.From, copyMsg.Subject)
|
a.applyInboundControls(ctx, inboxMsgID, rcptMailbox.ID, copyMsg.From, copyMsg.Subject)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
msg, _ := a.messageByID(r.Context(), sentID, true)
|
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 = "<p>" + htmlEscape(compose.Text) + "</p>"
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
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 = "<p>" + htmlEscape(compose.Text) + "</p>"
|
||||||
|
}
|
||||||
|
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 {
|
func (a *App) isLocalDomainAddress(ctx context.Context, address string) bool {
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ func (a *App) Router() http.Handler {
|
|||||||
r.Get("/mail/starred", a.handleStarredMessages)
|
r.Get("/mail/starred", a.handleStarredMessages)
|
||||||
r.Get("/mail/messages/{id}", a.handleMailMessage)
|
r.Get("/mail/messages/{id}", a.handleMailMessage)
|
||||||
r.Post("/mail/send", a.handleMailSend)
|
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}/mark-read", a.handleMarkRead)
|
||||||
r.Post("/mail/messages/{id}/star", a.handleStar)
|
r.Post("/mail/messages/{id}/star", a.handleStar)
|
||||||
r.Post("/mail/messages/{id}/labels", a.handleAddMessageLabel)
|
r.Post("/mail/messages/{id}/labels", a.handleAddMessageLabel)
|
||||||
|
|||||||
@@ -133,6 +133,13 @@ func boolInt(v bool) int {
|
|||||||
|
|
||||||
func intBool(v int) bool { return v != 0 }
|
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 {
|
func parseTime(v string) time.Time {
|
||||||
t, _ := time.Parse(time.RFC3339Nano, v)
|
t, _ := time.Parse(time.RFC3339Nano, v)
|
||||||
return t
|
return t
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export type DNSRecord = { type: string; name: string; value: string; ttl: number
|
|||||||
export type DNSCheckResult = { domain: string; status: string; checks: Record<string, { ok: boolean; message: string; found?: string[] }> }
|
export type DNSCheckResult = { domain: string; status: string; checks: Record<string, { ok: boolean; message: string; found?: string[] }> }
|
||||||
export type ListResponse<T> = { items: T[]; nextCursor?: string }
|
export type ListResponse<T> = { 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 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<SendPayload, "attachments"> & { 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 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 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 }
|
export type MailRuleCondition = { field: "from" | "to" | "subject" | "body"; operator: "contains" | "not-contains" | "equals" | "not-equals" | "starts-with" | "ends-with"; value: string }
|
||||||
|
|||||||
@@ -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"
|
export * from "./api-types"
|
||||||
|
|
||||||
const REQUEST_TIMEOUT_MS = 15_000
|
const REQUEST_TIMEOUT_MS = 15_000
|
||||||
@@ -121,6 +121,11 @@ export const api = {
|
|||||||
},
|
},
|
||||||
message: (id: string, options: { markRead?: boolean } = {}) => request<MailMessage>(`/api/mail/messages/${id}${options.markRead === false ? "?markRead=0" : ""}`),
|
message: (id: string, options: { markRead?: boolean } = {}) => request<MailMessage>(`/api/mail/messages/${id}${options.markRead === false ? "?markRead=0" : ""}`),
|
||||||
send: (payload: SendPayload) => request<MailMessage>("/api/mail/send", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
send: (payload: SendPayload) => request<MailMessage>("/api/mail/send", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
|
||||||
|
scheduledSends: (mailboxId?: string) => request<ListResponse<ScheduledSend>>(`/api/mail/scheduled-sends${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
|
||||||
|
scheduleSend: (payload: ScheduleSendPayload) => request<ScheduledSend>("/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<MailMessage>(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 }) }),
|
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 }) }),
|
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) }),
|
addLabel: (id: string, payload: { name: string; color?: string }) => request<{ labels: MailLabel[] }>(`/api/mail/messages/${id}/labels`, { method: "POST", body: JSON.stringify(payload) }),
|
||||||
|
|||||||
+1029
-86
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user