feat(mail): 支持草稿与定时发送

- 新增草稿保存、草稿编辑/删除、定时发送列表与取消接口,并在后台加入待发送任务表与定时发送工作协程。
- 邮件撰写页支持草稿自动保存、定时发送弹窗、草稿入口识别,以及待发送视图与状态标记。
- 同步补充前端 API 类型与调用,并增加定时发送流程的后端测试。
This commit is contained in:
LanQin_
2026-06-17 13:43:19 +08:00
parent f0044e477c
commit a8c2fc852c
8 changed files with 1577 additions and 139 deletions
+66
View File
@@ -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) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())