Files
NewSzxcn-Email/apps/api/internal/app/app_test.go
T
LanQin_ 0b982a5ff4 feat(mail): 重构邮箱权限体系并细化前台能力控制
- 新增邮箱前台权限与默认普通用户权限迁移,拆分读信、发信、草稿、定时、整理、标签、附件、联系人等能力。
- 收紧后端路由与处理逻辑,按权限限制邮箱前台、个人中心和定时发送相关接口。
- 前端根据权限动态隐藏或禁用邮件、个人中心中的对应功能入口与操作。
- 补充权限迁移与访问控制测试,覆盖普通用户邮箱前台、发信和定时发送的权限校验。
2026-06-22 15:54:15 +08:00

1469 lines
59 KiB
Go

package app
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func newTestApp(t *testing.T) *App {
t.Helper()
dir := t.TempDir()
cfg := Config{
Addr: ":0",
DBPath: filepath.Join(dir, "lanqin.db"),
DataDir: filepath.Join(dir, "data"),
CookieName: "lanqin_test",
SessionTTLHours: 24,
AdminEmail: "admin@lanqin.local",
AdminPassword: "ChangeMe123!",
PublicHostname: "mail.example.test",
PublicBaseURL: "http://localhost:5173",
AllowInsecureHTTP: true,
}
a, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = a.Close() })
return a
}
func startFakeSMTP(t *testing.T) (string, string, <-chan string) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
received := make(chan string, 1)
t.Cleanup(func() { _ = ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go handleFakeSMTPConn(conn, received)
}
}()
host, port, err := net.SplitHostPort(ln.Addr().String())
if err != nil {
t.Fatal(err)
}
return host, port, received
}
func handleFakeSMTPConn(conn net.Conn, received chan<- string) {
defer conn.Close()
reader := bufio.NewReader(conn)
_, _ = io.WriteString(conn, "220 lanqin.test ESMTP\r\n")
for {
line, err := reader.ReadString('\n')
if err != nil {
return
}
cmd := strings.ToUpper(strings.TrimSpace(line))
switch {
case strings.HasPrefix(cmd, "EHLO") || strings.HasPrefix(cmd, "HELO"):
_, _ = io.WriteString(conn, "250-lanqin.test\r\n250 OK\r\n")
case strings.HasPrefix(cmd, "DATA"):
_, _ = io.WriteString(conn, "354 End data with <CR><LF>.<CR><LF>\r\n")
var data strings.Builder
for {
line, err := reader.ReadString('\n')
if err != nil {
return
}
if strings.TrimRight(line, "\r\n") == "." {
break
}
data.WriteString(line)
}
select {
case received <- data.String():
default:
}
_, _ = io.WriteString(conn, "250 OK\r\n")
case strings.HasPrefix(cmd, "QUIT"):
_, _ = io.WriteString(conn, "221 Bye\r\n")
return
default:
_, _ = io.WriteString(conn, "250 OK\r\n")
}
}
}
type testClient struct {
t *testing.T
server *httptest.Server
cookie *http.Cookie
}
func (c *testClient) do(method, path string, body any, out any) int {
c.t.Helper()
var reader io.Reader
if body != nil {
b, _ := json.Marshal(body)
reader = bytes.NewReader(b)
}
req, err := http.NewRequest(method, c.server.URL+path, reader)
if err != nil {
c.t.Fatal(err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if c.cookie != nil {
req.AddCookie(c.cookie)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
c.t.Fatal(err)
}
defer resp.Body.Close()
for _, cookie := range resp.Cookies() {
if strings.Contains(cookie.Name, "lanqin") && cookie.Value != "" {
c.cookie = cookie
}
}
if out != nil {
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
c.t.Fatalf("decode %s %s: %v", method, path, err)
}
} else {
_, _ = io.Copy(io.Discard, resp.Body)
}
return resp.StatusCode
}
func createTestDomain(t *testing.T, admin *testClient, name string) Domain {
t.Helper()
var domain Domain
if code := admin.do("POST", "/api/admin/domains", map[string]string{"name": name}, &domain); code != http.StatusCreated {
t.Fatalf("create domain %s code=%d domain=%+v", name, code, domain)
}
return domain
}
func createTestMailbox(t *testing.T, admin *testClient, domainID, localPart, displayName, password string, extra map[string]any) Mailbox {
t.Helper()
payload := map[string]any{"domainId": domainID, "localPart": localPart, "displayName": displayName, "password": password}
for key, value := range extra {
payload[key] = value
}
var mailbox Mailbox
if code := admin.do("POST", "/api/admin/mailboxes", payload, &mailbox); code != http.StatusCreated {
t.Fatalf("create mailbox %s code=%d mailbox=%+v", localPart, code, mailbox)
}
return mailbox
}
func updateRegularPermissionGroup(t *testing.T, admin *testClient, permissions []string) PermissionGroup {
t.Helper()
var group PermissionGroup
if code := admin.do("POST", "/api/admin/permission-groups/"+PermissionGroupRegular, map[string]any{
"name": "Regular Users",
"description": "Default permissions for regular users",
"permissions": permissions,
}, &group); code != http.StatusOK {
t.Fatalf("update regular permission group code=%d group=%+v", code, group)
}
return group
}
func systemSettingsPayload(settings SystemSettings) map[string]any {
return map[string]any{
"publicHostname": settings.PublicHostname,
"publicBaseUrl": settings.PublicBaseURL,
"smtpHost": settings.SMTPHost,
"smtpPort": settings.SMTPPort,
"smtpUsername": settings.SMTPUsername,
"smtpPassword": "",
"smtpRequireTls": settings.SMTPRequireTLS,
"maildirRoot": settings.MaildirRoot,
"maildirScanSeconds": settings.MaildirScanSeconds,
"sessionTtlHours": settings.SessionTTLHours,
"allowInsecureHttp": settings.AllowInsecureHTTP,
"openRegistration": settings.OpenRegistration,
"twoFactorEnabled": settings.TwoFactorEnabled,
"turnstileEnabled": settings.TurnstileEnabled,
"turnstileSiteKey": settings.TurnstileSiteKey,
"turnstileSecretKey": "",
"catchAllEnabled": settings.CatchAllEnabled,
"mailAutoRefresh": settings.MailAutoRefresh,
"mailRefreshSeconds": settings.MailRefreshSeconds,
"userMailboxApplyEnabled": settings.UserMailboxApplyEnabled,
"userMailboxDomainIds": settings.UserMailboxDomainIDs,
"reservedMailboxPrefixes": settings.ReservedMailboxPrefixes,
}
}
func TestAuthAdminAndLocalDeliveryFlow(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("login code=%d body=%v", code, login)
}
var domainList = struct {
Items []Domain `json:"items"`
}{}
if code := admin.do("GET", "/api/admin/domains", nil, &domainList); code != http.StatusOK || len(domainList.Items) == 0 {
t.Fatalf("list domains code=%d items=%+v", code, domainList.Items)
}
domainID := domainList.Items[0].ID
mb1 := createTestMailbox(t, admin, domainID, "alice", "Alice", "Password123!", nil)
mb2 := createTestMailbox(t, admin, domainID, "bob", "Bob", "Password123!", nil)
var alias Alias
if code := admin.do("POST", "/api/admin/aliases", map[string]any{"domainId": domainID, "source": "sales", "destination": mb1.Address}, &alias); code != http.StatusCreated {
t.Fatalf("alias code=%d alias=%+v", code, alias)
}
alice := &testClient{t: t, server: ts}
if code := alice.do("POST", "/api/auth/login", map[string]string{"email": mb1.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("alice login=%d", code)
}
payload := map[string]any{
"to": []string{mb2.Address},
"subject": "hello bob",
"html": "<p>Hello <strong>Bob</strong></p><script>alert(1)</script>",
"attachments": []map[string]string{{"filename": "note.txt", "contentType": "text/plain", "contentBase64": base64.StdEncoding.EncodeToString([]byte("hi"))}},
}
var sent MailMessage
if code := alice.do("POST", "/api/mail/send", payload, &sent); code != http.StatusCreated || !sent.HasAttachments {
t.Fatalf("send code=%d msg=%+v", code, sent)
}
bob := &testClient{t: t, server: ts}
if code := bob.do("POST", "/api/auth/login", map[string]string{"email": mb2.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("bob login=%d", code)
}
var list struct {
Items []MailMessage `json:"items"`
NextCursor string `json:"nextCursor"`
}
if code := bob.do("GET", "/api/mail/messages?folder=Inbox", nil, &list); code != http.StatusOK || len(list.Items) != 1 {
t.Fatalf("bob inbox code=%d items=%d", code, len(list.Items))
}
if strings.Contains(list.Items[0].Snippet, "script") {
t.Fatalf("message was not sanitized: %q", list.Items[0].Snippet)
}
var detail MailMessage
if code := bob.do("GET", "/api/mail/messages/"+list.Items[0].ID, nil, &detail); code != http.StatusOK || len(detail.Attachments) != 1 || !detail.IsRead {
t.Fatalf("detail code=%d detail=%+v", code, detail)
}
if strings.Contains(detail.BodyHTML, "script") {
t.Fatalf("html was not sanitized: %s", detail.BodyHTML)
}
var ok map[string]any
if code := bob.do("POST", "/api/mail/messages/"+detail.ID+"/star", map[string]bool{"starred": true}, &ok); code != http.StatusOK {
t.Fatalf("star code=%d", code)
}
if code := bob.do("POST", "/api/mail/messages/"+detail.ID+"/move", map[string]string{"folder": "Archive"}, &ok); code != http.StatusOK {
t.Fatalf("move code=%d", code)
}
var labelUpdate struct {
Labels []MailLabel `json:"labels"`
}
if code := bob.do("POST", "/api/mail/messages/"+detail.ID+"/labels", map[string]string{"name": "重要"}, &labelUpdate); code != http.StatusOK || len(labelUpdate.Labels) != 1 {
t.Fatalf("add label code=%d labels=%+v", code, labelUpdate.Labels)
}
var labels struct {
Items []MailLabel `json:"items"`
}
if code := bob.do("GET", "/api/mail/labels?mailboxId="+mb2.ID, nil, &labels); code != http.StatusOK || len(labels.Items) != 1 || labels.Items[0].MessageCount != 1 {
t.Fatalf("labels code=%d items=%+v", code, labels.Items)
}
var labeled struct {
Items []MailMessage `json:"items"`
}
if code := bob.do("GET", "/api/mail/messages?mailboxId="+mb2.ID+"&labelId="+labels.Items[0].ID, nil, &labeled); code != http.StatusOK || len(labeled.Items) != 1 || labeled.Items[0].ID != detail.ID {
t.Fatalf("labeled messages code=%d items=%+v", code, labeled.Items)
}
if code := bob.do("DELETE", "/api/mail/messages/"+detail.ID+"/labels/"+labels.Items[0].ID, nil, &labelUpdate); code != http.StatusOK || len(labelUpdate.Labels) != 0 {
t.Fatalf("remove label code=%d labels=%+v", code, labelUpdate.Labels)
}
var starred struct {
Items []MailMessage `json:"items"`
}
if code := bob.do("GET", "/api/mail/starred", nil, &starred); code != http.StatusOK || len(starred.Items) != 1 || starred.Items[0].ID != detail.ID || starred.Items[0].Folder != "Archive" {
t.Fatalf("starred view code=%d items=%+v", code, starred.Items)
}
if code := bob.do("DELETE", "/api/mail/messages/"+detail.ID, nil, &ok); code != http.StatusOK {
t.Fatalf("delete code=%d", code)
}
}
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())
defer ts.Close()
client := &testClient{t: t, server: ts}
var out map[string]any
if code := client.do("POST", "/api/auth/register", map[string]string{"email": "newuser@example.com", "displayName": "New User", "password": "Password123!"}, &out); code != http.StatusForbidden {
t.Fatalf("closed registration code=%d body=%v", code, out)
}
a.cfg.OpenRegistration = true
var registered struct {
User User `json:"user"`
}
if code := client.do("POST", "/api/auth/register", map[string]string{"email": "newuser@example.com", "displayName": "New User", "password": "Password123!"}, &registered); code != http.StatusCreated || registered.User.Email != "newuser@example.com" || registered.User.Role != "user" {
t.Fatalf("register code=%d user=%+v", code, registered.User)
}
var me struct {
User User `json:"user"`
}
if code := client.do("GET", "/api/me", nil, &me); code != http.StatusOK || me.User.Email != "newuser@example.com" {
t.Fatalf("me code=%d user=%+v", code, me.User)
}
var mine struct {
Items []Mailbox `json:"items"`
}
if code := client.do("GET", "/api/mail/mailboxes", nil, &mine); code != http.StatusOK || len(mine.Items) != 1 {
t.Fatalf("registered user should get auto-created mailbox: code=%d items=%+v", code, mine.Items)
}
another := &testClient{t: t, server: ts}
if code := another.do("POST", "/api/auth/login", map[string]string{"email": "newuser@example.com", "password": "Password123!"}, &out); code != http.StatusOK {
t.Fatalf("login registered user code=%d body=%v", code, out)
}
}
func TestLegacyBootstrapMailboxMigrationRemovesImplicitAdminMailbox(t *testing.T) {
dir := t.TempDir()
cfg := Config{
Addr: ":0",
DBPath: filepath.Join(dir, "lanqin.db"),
DataDir: filepath.Join(dir, "data"),
CookieName: "lanqin_test",
SessionTTLHours: 24,
AdminEmail: "lanqinnet@gmail.com",
AdminPassword: "ChangeMe123!",
PublicHostname: "mail.example.test",
PublicBaseURL: "http://localhost:5173",
AllowInsecureHTTP: true,
}
a, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = a.Close() })
ctx := context.Background()
// seed() now creates user + domain gmail.com + mailbox lanqinnet@gmail.com
// with display_name = admin email (not "LanQin Admin").
// Modify the mailbox to look like the old legacy pattern so the migration can find it.
if _, err := a.db.ExecContext(ctx, `UPDATE mailboxes SET display_name='LanQin Admin' WHERE address=?`, cfg.AdminEmail); err != nil {
t.Fatal(err)
}
// Get the domain ID for the verification step
var domainID string
if err := a.db.QueryRowContext(ctx, `SELECT id FROM domains WHERE name=?`, "gmail.com").Scan(&domainID); err != nil {
t.Fatal(err)
}
if err := a.migrateLegacyBootstrapMailbox(ctx); err != nil {
t.Fatal(err)
}
var count int
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE email=? AND role='admin'`, cfg.AdminEmail).Scan(&count); err != nil || count != 1 {
t.Fatalf("admin user count=%d err=%v", count, err)
}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM mailboxes WHERE address=?`, cfg.AdminEmail).Scan(&count); err != nil || count != 0 {
t.Fatalf("legacy mailbox count=%d err=%v", count, err)
}
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM domains WHERE id=?`, domainID).Scan(&count); err != nil || count != 0 {
t.Fatalf("legacy domain count=%d err=%v", count, err)
}
}
func TestUserMailboxApplicationUsesAllowedDomainsAndReservedPrefixes(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 body=%v", code, login)
}
allowedDomain := createTestDomain(t, admin, "a.com")
blockedDomain := createTestDomain(t, admin, "b.com")
var created AdminUser
if code := admin.do("POST", "/api/admin/users", map[string]any{"email": "person@example.net", "displayName": "Person", "role": "user", "password": "Password123!", "disabled": false}, &created); code != http.StatusCreated {
t.Fatalf("create user code=%d user=%+v", code, created)
}
userClient := &testClient{t: t, server: ts}
if code := userClient.do("POST", "/api/auth/login", map[string]string{"email": "person@example.net", "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("user login code=%d", code)
}
var options MailboxApplyOptions
if code := userClient.do("GET", "/api/me/mailbox-apply-options", nil, &options); code != http.StatusOK || options.Enabled || len(options.Domains) != 0 {
t.Fatalf("disabled options code=%d options=%+v", code, options)
}
var settings SystemSettings
if code := admin.do("GET", "/api/admin/settings", nil, &settings); code != http.StatusOK {
t.Fatalf("get settings code=%d", code)
}
update := systemSettingsPayload(settings)
update["userMailboxApplyEnabled"] = true
update["userMailboxDomainIds"] = []string{allowedDomain.ID}
update["reservedMailboxPrefixes"] = "admin\nroot"
if code := admin.do("POST", "/api/admin/settings", update, &settings); code != http.StatusOK || !settings.UserMailboxApplyEnabled || len(settings.UserMailboxDomainIDs) != 1 {
t.Fatalf("enable apply code=%d settings=%+v", code, settings)
}
if code := userClient.do("GET", "/api/me/mailbox-apply-options", nil, &options); code != http.StatusOK || !options.Enabled || len(options.Domains) != 1 || options.Domains[0].ID != allowedDomain.ID {
t.Fatalf("enabled options code=%d options=%+v", code, options)
}
var errBody map[string]any
if code := userClient.do("POST", "/api/me/mailboxes/apply", map[string]string{"domainId": allowedDomain.ID, "localPart": "admin"}, &errBody); code != http.StatusForbidden {
t.Fatalf("reserved prefix code=%d body=%v", code, errBody)
}
if code := userClient.do("POST", "/api/me/mailboxes/apply", map[string]string{"domainId": blockedDomain.ID, "localPart": "alice"}, &errBody); code != http.StatusForbidden {
t.Fatalf("blocked domain code=%d body=%v", code, errBody)
}
var mailbox Mailbox
if code := userClient.do("POST", "/api/me/mailboxes/apply", map[string]string{"domainId": allowedDomain.ID, "localPart": "alice", "displayName": "Alice"}, &mailbox); code != http.StatusCreated || mailbox.Address != "alice@a.com" || mailbox.UserID != created.ID {
t.Fatalf("apply mailbox code=%d mailbox=%+v", code, mailbox)
}
var mine struct {
Items []Mailbox `json:"items"`
}
if code := userClient.do("GET", "/api/mail/mailboxes", nil, &mine); code != http.StatusOK || len(mine.Items) != 1 || mine.Items[0].Address != "alice@a.com" {
t.Fatalf("mine code=%d items=%+v", code, mine.Items)
}
if code := userClient.do("POST", "/api/me/mailboxes/apply", map[string]string{"domainId": allowedDomain.ID, "localPart": "alice"}, &errBody); code != http.StatusConflict {
t.Fatalf("duplicate apply code=%d body=%v", code, errBody)
}
}
func TestUserCanSelectMultipleMailboxes(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("login code=%d body=%v", code, login)
}
// seed() already created domain lanqin.local and mailbox admin@lanqin.local
var domainList = struct {
Items []Domain `json:"items"`
}{}
if code := admin.do("GET", "/api/admin/domains", nil, &domainList); code != http.StatusOK || len(domainList.Items) == 0 {
t.Fatalf("list domains code=%d items=%+v", code, domainList.Items)
}
domainID := domainList.Items[0].ID
primary := createTestMailbox(t, admin, domainID, "multi", "Multi", "Password123!", nil)
secondary := createTestMailbox(t, admin, domainID, "multi-work", "Multi Work", "Password456!", map[string]any{"ownerEmail": primary.Address})
if primary.UserID != secondary.UserID {
t.Fatalf("mailboxes were not bound to one user: primary=%s secondary=%s", primary.UserID, secondary.UserID)
}
userClient := &testClient{t: t, server: ts}
if code := userClient.do("POST", "/api/auth/login", map[string]string{"email": primary.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("user login=%d", code)
}
var mine struct {
Items []Mailbox `json:"items"`
}
if code := userClient.do("GET", "/api/mail/mailboxes", nil, &mine); code != http.StatusOK || len(mine.Items) != 2 {
t.Fatalf("my mailboxes code=%d items=%d", code, len(mine.Items))
}
if code := userClient.do("GET", "/api/mail/folders?mailboxId="+secondary.ID, nil, nil); code != http.StatusOK {
t.Fatalf("folders for selected mailbox code=%d", code)
}
var sent MailMessage
payload := map[string]any{
"mailboxId": secondary.ID,
"to": []string{"admin@lanqin.local"},
"subject": "selected mailbox sender",
"text": "hello from selected mailbox",
}
if code := userClient.do("POST", "/api/mail/send", payload, &sent); code != http.StatusCreated || sent.From != secondary.Address {
t.Fatalf("send with selected mailbox code=%d from=%q want=%q", code, sent.From, secondary.Address)
}
var adminInbox struct {
Items []MailMessage `json:"items"`
}
if code := admin.do("GET", "/api/mail/messages?folder=Inbox&q=selected%20mailbox%20sender", nil, &adminInbox); code != http.StatusOK || len(adminInbox.Items) != 1 || adminInbox.Items[0].From != secondary.Address {
t.Fatalf("admin inbox code=%d items=%d first=%+v", code, len(adminInbox.Items), adminInbox.Items)
}
}
func TestCatchAllStoresUnregisteredMailForAdminOnly(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("login code=%d body=%v", code, login)
}
// seed() already created domain lanqin.local and mailbox admin@lanqin.local
var domainList = struct {
Items []Domain `json:"items"`
}{}
if code := admin.do("GET", "/api/admin/domains", nil, &domainList); code != http.StatusOK || len(domainList.Items) == 0 {
t.Fatalf("list domains code=%d items=%+v", code, domainList.Items)
}
payload := map[string]any{
"to": []string{"ghost@lanqin.local"},
"subject": "should be rejected by default",
"text": "default disabled",
}
var sent MailMessage
if code := admin.do("POST", "/api/mail/send", payload, &sent); code != http.StatusCreated {
t.Fatalf("send disabled catch-all code=%d", code)
}
var list struct {
Items []MailMessage `json:"items"`
}
if code := admin.do("GET", "/api/admin/messages?mailboxId=unregistered&q=should%20be%20rejected", nil, &list); code != http.StatusOK || len(list.Items) != 0 {
t.Fatalf("disabled catch-all should not store unregistered mail: code=%d items=%+v", code, list.Items)
}
var settings SystemSettings
if code := admin.do("GET", "/api/admin/settings", nil, &settings); code != http.StatusOK {
t.Fatalf("get settings code=%d", code)
}
update := map[string]any{
"publicHostname": settings.PublicHostname,
"publicBaseUrl": settings.PublicBaseURL,
"smtpHost": settings.SMTPHost,
"smtpPort": settings.SMTPPort,
"smtpUsername": settings.SMTPUsername,
"smtpPassword": "",
"smtpRequireTls": settings.SMTPRequireTLS,
"maildirRoot": settings.MaildirRoot,
"maildirScanSeconds": settings.MaildirScanSeconds,
"sessionTtlHours": settings.SessionTTLHours,
"allowInsecureHttp": settings.AllowInsecureHTTP,
"openRegistration": settings.OpenRegistration,
"twoFactorEnabled": settings.TwoFactorEnabled,
"turnstileEnabled": settings.TurnstileEnabled,
"turnstileSiteKey": settings.TurnstileSiteKey,
"turnstileSecretKey": "",
"catchAllEnabled": true,
"mailAutoRefresh": settings.MailAutoRefresh,
"mailRefreshSeconds": settings.MailRefreshSeconds,
}
if code := admin.do("POST", "/api/admin/settings", update, &settings); code != http.StatusOK || !settings.CatchAllEnabled {
t.Fatalf("enable catch-all code=%d settings=%+v", code, settings)
}
payload = map[string]any{
"to": []string{"ghost@lanqin.local"},
"subject": "stored for admin only",
"text": "unregistered mailbox content",
}
if code := admin.do("POST", "/api/mail/send", payload, &sent); code != http.StatusCreated {
t.Fatalf("send enabled catch-all code=%d", code)
}
if code := admin.do("GET", "/api/admin/messages?mailboxId=unregistered&q=stored%20for%20admin", nil, &list); code != http.StatusOK || len(list.Items) != 1 {
t.Fatalf("enabled catch-all admin list code=%d items=%+v", code, list.Items)
}
if got := list.Items[0].RecipientAddr; got != "ghost@lanqin.local" {
t.Fatalf("recipientAddress=%q", got)
}
}
func TestMailSendReturnsSMTPFailure(t *testing.T) {
a := newTestApp(t)
a.cfg.SMTPHost = "127.0.0.1"
a.cfg.SMTPPort = "1"
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("login code=%d body=%v", code, login)
}
payload := map[string]any{
"to": []string{"person@example.com"},
"subject": "smtp failure should surface",
"text": "hello",
}
var errBody map[string]any
if code := admin.do("POST", "/api/mail/send", payload, &errBody); code != http.StatusBadGateway {
t.Fatalf("smtp failure code=%d body=%v", code, errBody)
}
if got, _ := errBody["error"].(string); !strings.Contains(got, "smtp delivery failed") {
t.Fatalf("error=%q", got)
}
}
func TestAdminSMTPTestEndpoint(t *testing.T) {
a := newTestApp(t)
host, port, received := startFakeSMTP(t)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
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("login code=%d body=%v", code, login)
}
var out map[string]any
var templates struct {
Items []MailTemplate `json:"items"`
}
if code := admin.do("GET", "/api/admin/mail-templates", nil, &templates); code != http.StatusOK || len(templates.Items) == 0 {
t.Fatalf("templates code=%d items=%d", code, len(templates.Items))
}
var updated MailTemplate
if code := admin.do("POST", "/api/admin/mail-templates/smtp_test", map[string]string{
"subject": "自定义 SMTP 测试",
"bodyText": "hello {{to}} from {{from}}",
"bodyHtml": "<p>hello {{to}} from {{from}}</p>",
}, &updated); code != http.StatusOK || updated.Subject != "自定义 SMTP 测试" {
t.Fatalf("update template code=%d template=%+v", code, updated)
}
if code := admin.do("POST", "/api/admin/settings/test-smtp", map[string]string{"to": "test@example.com"}, &out); code != http.StatusOK {
t.Fatalf("smtp test code=%d body=%v", code, out)
}
select {
case body := <-received:
if !strings.Contains(body, "From: admin@lanqin.local") || !strings.Contains(body, "To: test@example.com") || !strings.Contains(body, "=?utf-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89_SMTP_=E6=B5=8B=E8=AF=95?=") {
t.Fatalf("unexpected smtp body: %s", body)
}
case <-time.After(2 * time.Second):
t.Fatal("smtp test message not received")
}
}
func TestProfileAndPasswordUpdate(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
client := &testClient{t: t, server: ts}
var login map[string]any
if code := client.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("login code=%d body=%v", code, login)
}
var profile struct {
User User `json:"user"`
}
if code := client.do("POST", "/api/me/profile", map[string]string{"displayName": "蓝钦管理员"}, &profile); code != http.StatusOK || profile.User.DisplayName != "蓝钦管理员" {
t.Fatalf("profile code=%d user=%+v", code, profile.User)
}
var ok map[string]any
if code := client.do("POST", "/api/me/password", map[string]string{"currentPassword": "wrong", "newPassword": "NewPassword123!"}, &ok); code != http.StatusUnauthorized {
t.Fatalf("wrong password change code=%d", code)
}
if code := client.do("POST", "/api/me/password", map[string]string{"currentPassword": "ChangeMe123!", "newPassword": "NewPassword123!"}, &ok); code != http.StatusOK {
t.Fatalf("password change code=%d body=%v", code, ok)
}
fresh := &testClient{t: t, server: ts}
if code := fresh.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, nil); code != http.StatusUnauthorized {
t.Fatalf("old password login code=%d", code)
}
if code := fresh.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "NewPassword123!"}, &login); code != http.StatusOK {
t.Fatalf("new password login code=%d", code)
}
}
func TestUserMailSignaturesDefaultResolution(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 body=%v", code, login)
}
domainID := mustDefaultDomainID(t, a)
mb1 := createTestMailbox(t, admin, domainID, "signer", "Signer", "Password123!", nil)
mb2 := createTestMailbox(t, admin, domainID, "second", "Second", "Password123!", map[string]any{"ownerEmail": mb1.Address})
user := &testClient{t: t, server: ts}
if code := user.do("POST", "/api/auth/login", map[string]string{"email": mb1.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("user login code=%d", code)
}
var global MailSignature
if code := user.do("POST", "/api/me/signatures", map[string]any{"name": "全局签名", "content": "Global Sig", "isDefault": true}, &global); code != http.StatusCreated || !global.IsDefault || global.MailboxID != "" {
t.Fatalf("create global signature code=%d sig=%+v", code, global)
}
var bound MailSignature
if code := user.do("POST", "/api/me/signatures", map[string]any{"mailboxId": mb1.ID, "name": "邮箱签名", "content": "Mailbox Sig", "isDefault": true}, &bound); code != http.StatusCreated || !bound.IsDefault || bound.MailboxID != mb1.ID {
t.Fatalf("create bound signature code=%d sig=%+v", code, bound)
}
var defaultResp struct {
Signature *MailSignature `json:"signature"`
}
if code := user.do("GET", "/api/me/signatures/default?mailboxId="+mb1.ID, nil, &defaultResp); code != http.StatusOK || defaultResp.Signature == nil || defaultResp.Signature.ID != bound.ID {
t.Fatalf("bound default code=%d resp=%+v", code, defaultResp)
}
if code := user.do("GET", "/api/me/signatures/default?mailboxId="+mb2.ID, nil, &defaultResp); code != http.StatusOK || defaultResp.Signature == nil || defaultResp.Signature.ID != global.ID {
t.Fatalf("global fallback code=%d resp=%+v", code, defaultResp)
}
var updated MailSignature
if code := user.do("POST", "/api/me/signatures/"+bound.ID, map[string]any{"mailboxId": mb1.ID, "name": "更新签名", "content": "Updated Sig", "isDefault": false}, &updated); code != http.StatusOK || updated.IsDefault || updated.Content != "Updated Sig" {
t.Fatalf("update signature code=%d sig=%+v", code, updated)
}
if code := user.do("GET", "/api/me/signatures/default?mailboxId="+mb1.ID, nil, &defaultResp); code != http.StatusOK || defaultResp.Signature == nil || defaultResp.Signature.ID != global.ID {
t.Fatalf("fallback after update code=%d resp=%+v", code, defaultResp)
}
var ok map[string]any
if code := user.do("DELETE", "/api/me/signatures/"+global.ID, nil, &ok); code != http.StatusOK {
t.Fatalf("delete signature code=%d body=%v", code, ok)
}
if code := user.do("GET", "/api/me/signatures/default?mailboxId="+mb2.ID, nil, &defaultResp); code != http.StatusOK || defaultResp.Signature != nil {
t.Fatalf("empty default code=%d resp=%+v", code, defaultResp)
}
}
func TestUserTwoFactorSetupAndLogin(t *testing.T) {
a := newTestApp(t)
a.cfg.TwoFactorEnabled = true
ts := httptest.NewServer(a.Router())
defer ts.Close()
client := &testClient{t: t, server: ts}
var login map[string]any
if code := client.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("login code=%d body=%v", code, login)
}
var setup struct {
Secret string `json:"secret"`
OtpauthURL string `json:"otpauthUrl"`
}
if code := client.do("POST", "/api/me/2fa/setup", map[string]string{}, &setup); code != http.StatusOK || setup.Secret == "" || !strings.HasPrefix(setup.OtpauthURL, "otpauth://totp/") {
t.Fatalf("setup code=%d setup=%+v", code, setup)
}
var out map[string]any
if code := client.do("POST", "/api/me/2fa/enable", map[string]string{"code": "000000"}, &out); code != http.StatusUnauthorized {
t.Fatalf("wrong enable code=%d body=%v", code, out)
}
code, err := generateTOTP(setup.Secret, a.now().UTC())
if err != nil {
t.Fatal(err)
}
var enabled struct {
User User `json:"user"`
}
if status := client.do("POST", "/api/me/2fa/enable", map[string]string{"code": code}, &enabled); status != http.StatusOK || !enabled.User.TwoFactorEnabled {
t.Fatalf("enable status=%d user=%+v", status, enabled.User)
}
fresh := &testClient{t: t, server: ts}
var challenge struct {
TwoFactorRequired bool `json:"twoFactorRequired"`
ChallengeToken string `json:"challengeToken"`
}
if status := fresh.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &challenge); status != http.StatusOK || !challenge.TwoFactorRequired || challenge.ChallengeToken == "" || fresh.cookie != nil {
t.Fatalf("challenge status=%d challenge=%+v cookie=%v", status, challenge, fresh.cookie)
}
if status := fresh.do("POST", "/api/auth/login", map[string]string{"challengeToken": challenge.ChallengeToken, "twoFactorCode": "000000"}, &out); status != http.StatusUnauthorized {
t.Fatalf("wrong challenge status=%d body=%v", status, out)
}
code, err = generateTOTP(setup.Secret, a.now().UTC())
if err != nil {
t.Fatal(err)
}
if status := fresh.do("POST", "/api/auth/login", map[string]string{"challengeToken": challenge.ChallengeToken, "twoFactorCode": code}, &login); status != http.StatusOK || fresh.cookie == nil {
t.Fatalf("2fa login status=%d body=%v cookie=%v", status, login, fresh.cookie)
}
if status := fresh.do("POST", "/api/me/2fa/disable", map[string]string{"code": code}, &enabled); status != http.StatusOK || enabled.User.TwoFactorEnabled {
t.Fatalf("disable status=%d user=%+v", status, enabled.User)
}
}
func TestDNSRecords(t *testing.T) {
a := newTestApp(t)
var domainID string
if err := a.db.QueryRowContext(context.Background(), `SELECT id FROM domains WHERE name=?`, "lanqin.local").Scan(&domainID); err != nil {
t.Fatal(err)
}
d, err := a.domainByID(context.Background(), domainID)
if err != nil {
t.Fatal(err)
}
records := a.dnsRecordsFor(d)
if len(records) != 4 {
t.Fatalf("records=%d", len(records))
}
if records[0].Type != "MX" || !strings.Contains(records[2].Value, "v=DKIM1") {
t.Fatalf("unexpected records: %+v", records)
}
}
func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(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 body=%v", code, login)
}
var groups struct {
Items []PermissionGroup `json:"items"`
}
if code := admin.do("GET", "/api/admin/permission-groups", nil, &groups); code != http.StatusOK || len(groups.Items) != len(defaultPermissionGroups()) {
t.Fatalf("fixed permission groups code=%d groups=%+v", code, groups.Items)
}
groupByID := map[string]PermissionGroup{}
for _, group := range groups.Items {
groupByID[group.ID] = group
}
for _, group := range defaultPermissionGroups() {
if _, ok := groupByID[group.ID]; !ok {
t.Fatalf("missing fixed permission group %s in %+v", group.ID, groups.Items)
}
}
if groups.Items[0].ID != PermissionGroupSuperAdmin || groups.Items[1].ID != PermissionGroupRegular {
t.Fatalf("unexpected fixed permission groups: %+v", groups.Items)
}
var errBody map[string]any
var users struct {
Items []AdminUser `json:"items"`
}
var customGroup PermissionGroup
if code := admin.do("POST", "/api/admin/permission-groups", map[string]any{
"name": "Mailbox Viewers",
"description": "Can view mailboxes only",
"permissions": []string{PermissionAdminOverview, PermissionMailboxesView},
}, &customGroup); code != http.StatusCreated {
t.Fatalf("custom permission group creation code=%d group=%+v", code, customGroup)
}
if customGroup.System || customGroup.ID == "" || !userHasPermission(&User{Role: "user", Permissions: customGroup.Permissions}, PermissionMailboxesView) || userHasPermission(&User{Role: "user", Permissions: customGroup.Permissions}, PermissionMailboxesCreate) {
t.Fatalf("custom permission group permissions=%+v", customGroup)
}
if code := admin.do("POST", "/api/admin/permission-groups/"+PermissionGroupSuperAdmin, map[string]any{
"name": "Changed",
"description": "Should not change",
"permissions": []string{PermissionMailboxesView},
}, &errBody); code != http.StatusForbidden {
t.Fatalf("system permission group update should be forbidden code=%d body=%v", code, errBody)
}
regularGroup := updateRegularPermissionGroup(t, admin, []string{PermissionAdminOverview})
if !regularGroup.System || !userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionAdminOverview) {
t.Fatalf("regular group update did not persist permissions=%+v", regularGroup)
}
if code := admin.do("DELETE", "/api/admin/permission-groups/"+PermissionGroupSuperAdmin, nil, &errBody); code != http.StatusForbidden {
t.Fatalf("system permission group delete should be forbidden code=%d body=%v", code, errBody)
}
if code := admin.do("DELETE", "/api/admin/permission-groups/"+PermissionGroupRegular, nil, &errBody); code != http.StatusForbidden {
t.Fatalf("regular user group delete should be forbidden code=%d body=%v", code, errBody)
}
if code := admin.do("POST", "/api/admin/users", map[string]any{
"email": "invalid-group@lanqin.local",
"displayName": "Invalid Group",
"role": "user",
"password": "Password123!",
"disabled": false,
"permissionGroupIds": []string{PermissionGroupSuperAdmin},
}, &errBody); code != http.StatusBadRequest {
t.Fatalf("assigning super admin group should be rejected code=%d body=%v", code, errBody)
}
var mailboxAdminGroup PermissionGroup
if code := admin.do("POST", "/api/admin/permission-groups", map[string]any{
"name": "Mailbox Admins",
"description": "Can manage mailboxes",
"permissions": []string{
PermissionAdminOverview,
PermissionUsersView,
PermissionDomainsView,
PermissionMailboxesView,
PermissionMailboxesCreate,
PermissionMailboxesUpdate,
PermissionMailboxesDelete,
},
}, &mailboxAdminGroup); code != http.StatusCreated {
t.Fatalf("create mailbox admin group code=%d group=%+v", code, mailboxAdminGroup)
}
var userAdminGroup PermissionGroup
if code := admin.do("POST", "/api/admin/permission-groups", map[string]any{
"name": "User Admins",
"description": "Can manage users",
"permissions": []string{
PermissionAdminOverview,
PermissionUsersView,
PermissionUsersCreate,
PermissionUsersUpdate,
PermissionUsersDelete,
PermissionUsersResetPassword,
PermissionGroupsView,
},
}, &userAdminGroup); code != http.StatusCreated {
t.Fatalf("create user admin group code=%d group=%+v", code, userAdminGroup)
}
var mailboxUser AdminUser
if code := admin.do("POST", "/api/admin/users", map[string]any{
"email": "mailbox-admin@lanqin.local",
"displayName": "Mailbox Admin",
"role": "user",
"password": "Password123!",
"disabled": false,
"permissionGroupIds": []string{mailboxAdminGroup.ID},
}, &mailboxUser); code != http.StatusCreated {
t.Fatalf("create mailbox admin user code=%d user=%+v", code, mailboxUser)
}
if mailboxUser.Role != "user" || !containsString(mailboxUser.PermissionGroupIDs, PermissionGroupRegular) || !containsString(mailboxUser.PermissionGroupIDs, mailboxAdminGroup.ID) || !userHasPermission(&mailboxUser.User, PermissionMailboxesManage) || userHasPermission(&mailboxUser.User, PermissionSystemSettings) {
t.Fatalf("mailbox admin authorization=%+v", mailboxUser.User)
}
var plainUser AdminUser
if code := admin.do("POST", "/api/admin/users", map[string]any{
"email": "plain-user@lanqin.local",
"displayName": "Plain User",
"role": "user",
"password": "Password123!",
"disabled": false,
"permissionGroupIds": []string{},
}, &plainUser); code != http.StatusCreated {
t.Fatalf("create plain user code=%d user=%+v", code, plainUser)
}
if len(plainUser.PermissionGroupIDs) != 1 || plainUser.PermissionGroupIDs[0] != PermissionGroupRegular || !userHasPermission(&plainUser.User, PermissionAdminOverview) {
t.Fatalf("plain user should inherit regular permissions: %+v", plainUser.User)
}
var customUser AdminUser
if code := admin.do("POST", "/api/admin/users", map[string]any{
"email": "mailbox-viewer@lanqin.local",
"displayName": "Mailbox Viewer",
"role": "user",
"password": "Password123!",
"disabled": false,
"permissionGroupIds": []string{customGroup.ID},
}, &customUser); code != http.StatusCreated {
t.Fatalf("create custom group user code=%d user=%+v", code, customUser)
}
if !userHasPermission(&customUser.User, PermissionMailboxesView) || userHasPermission(&customUser.User, PermissionMailboxesCreate) {
t.Fatalf("custom group user authorization=%+v", customUser.User)
}
if code := admin.do("DELETE", "/api/admin/permission-groups/"+customGroup.ID, nil, &errBody); code != http.StatusBadRequest {
t.Fatalf("assigned custom permission group delete should be rejected code=%d body=%v", code, errBody)
}
mailboxAdmin := &testClient{t: t, server: ts}
if code := mailboxAdmin.do("POST", "/api/auth/login", map[string]string{"email": "mailbox-admin@lanqin.local", "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("mailbox admin login code=%d", code)
}
var mailboxList struct {
Items []Mailbox `json:"items"`
}
if code := mailboxAdmin.do("GET", "/api/admin/mailboxes", nil, &mailboxList); code != http.StatusOK {
t.Fatalf("mailbox admin should access mailboxes code=%d", code)
}
if code := mailboxAdmin.do("GET", "/api/admin/settings", nil, &errBody); code != http.StatusForbidden {
t.Fatalf("mailbox admin settings should be forbidden code=%d body=%v", code, errBody)
}
if code := mailboxAdmin.do("GET", "/api/admin/users", nil, &errBody); code != http.StatusOK {
t.Fatalf("mailbox admin should read users for mailbox ownership code=%d body=%v", code, errBody)
}
viewer := &testClient{t: t, server: ts}
if code := viewer.do("POST", "/api/auth/login", map[string]string{"email": "mailbox-viewer@lanqin.local", "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("mailbox viewer login code=%d", code)
}
if code := viewer.do("GET", "/api/admin/mailboxes", nil, &mailboxList); code != http.StatusOK {
t.Fatalf("mailbox viewer should read mailboxes code=%d", code)
}
if code := viewer.do("POST", "/api/admin/mailboxes", map[string]any{
"domainId": mustDefaultDomainID(t, a),
"localPart": "blocked-create",
"displayName": "Blocked Create",
"password": "Password123!",
"quotaMb": 1024,
"role": "user",
}, &errBody); code != http.StatusForbidden {
t.Fatalf("mailbox viewer should not create mailboxes code=%d body=%v", code, errBody)
}
if code := mailboxAdmin.do("POST", "/api/admin/users", map[string]any{
"email": "blocked-by-mailbox-admin@lanqin.local",
"displayName": "Blocked",
"role": "user",
"password": "Password123!",
"disabled": false,
"permissionGroupIds": []string{mailboxAdminGroup.ID},
}, &errBody); code != http.StatusForbidden {
t.Fatalf("mailbox admin should not create users code=%d body=%v", code, errBody)
}
var userManager AdminUser
if code := admin.do("POST", "/api/admin/users", map[string]any{
"email": "user-admin@lanqin.local",
"displayName": "User Admin",
"role": "user",
"password": "Password123!",
"disabled": false,
"permissionGroupIds": []string{userAdminGroup.ID},
}, &userManager); code != http.StatusCreated {
t.Fatalf("create user admin code=%d user=%+v", code, userManager)
}
userAdmin := &testClient{t: t, server: ts}
if code := userAdmin.do("POST", "/api/auth/login", map[string]string{"email": "user-admin@lanqin.local", "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("user admin login code=%d", code)
}
if code := userAdmin.do("GET", "/api/admin/users", nil, &users); code != http.StatusOK {
t.Fatalf("user admin users code=%d body=%v", code, users)
}
if code := userAdmin.do("POST", "/api/admin/users", map[string]any{
"email": "delegated-mailbox@lanqin.local",
"displayName": "Delegated Mailbox",
"role": "user",
"password": "Password123!",
"disabled": false,
"permissionGroupIds": []string{mailboxAdminGroup.ID},
}, &errBody); code != http.StatusBadRequest {
t.Fatalf("user admin should not assign mailbox admin group code=%d body=%v", code, errBody)
}
var regularUser AdminUser
if code := userAdmin.do("POST", "/api/admin/users", map[string]any{
"email": "delegated-user@lanqin.local",
"displayName": "Delegated User",
"role": "user",
"password": "Password123!",
"disabled": false,
"permissionGroupIds": []string{userAdminGroup.ID},
}, &regularUser); code != http.StatusCreated {
t.Fatalf("user admin should assign own group code=%d user=%+v", code, regularUser)
}
if code := userAdmin.do("POST", "/api/admin/users", map[string]any{
"email": "delegated-super@lanqin.local",
"displayName": "Delegated Super",
"role": "admin",
"password": "Password123!",
"disabled": false,
"permissionGroupIds": []string{},
}, &errBody); code != http.StatusForbidden {
t.Fatalf("user admin should not create super admin code=%d body=%v", code, errBody)
}
if code := admin.do("GET", "/api/admin/users", nil, &users); code != http.StatusOK || len(users.Items) == 0 {
t.Fatalf("admin users code=%d items=%d", code, len(users.Items))
}
var defaultAdmin AdminUser
for _, user := range users.Items {
if user.Email == "admin@lanqin.local" {
defaultAdmin = user
break
}
}
if defaultAdmin.ID == "" || !defaultAdmin.Protected || defaultAdmin.Role != "admin" {
t.Fatalf("default admin should be protected super admin: %+v", defaultAdmin.User)
}
if code := admin.do("POST", "/api/admin/users/"+defaultAdmin.ID, map[string]any{
"displayName": "LanQin Admin",
"role": "user",
"disabled": false,
}, &errBody); code != http.StatusBadRequest {
t.Fatalf("default admin downgrade should be rejected code=%d body=%v", code, errBody)
}
if code := admin.do("POST", "/api/admin/users/"+defaultAdmin.ID, map[string]any{
"displayName": "LanQin Admin",
"role": "admin",
"disabled": true,
}, &errBody); code != http.StatusBadRequest {
t.Fatalf("default admin disable should be rejected code=%d body=%v", code, errBody)
}
if code := admin.do("DELETE", "/api/admin/users/"+defaultAdmin.ID, nil, &errBody); code != http.StatusBadRequest {
t.Fatalf("default admin delete should be rejected code=%d body=%v", code, errBody)
}
}
func TestLegacySystemPermissionGroupsAreCleanedUp(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
now := time.Now().UTC().Format(time.RFC3339Nano)
legacyIDs := []string{
"pg_permission_manager",
"pg_user_manager",
"pg_system_operator",
"pg_mail_operator",
}
for _, groupID := range legacyIDs {
if _, err := a.db.ExecContext(ctx, `INSERT INTO permission_groups(id,name,description,permissions_json,system,created_at,updated_at)
VALUES(?,?,?,?,1,?,?)`, groupID, "Legacy "+groupID, "", "[]", now, now); err != nil {
t.Fatal(err)
}
}
if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
t.Fatal(err)
}
for _, groupID := range legacyIDs {
var count int
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM permission_groups WHERE id=?`, groupID).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("legacy permission group %s was not removed", groupID)
}
}
}
func TestRegularUserMailPermissionsAreEnforced(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 body=%v", code, login)
}
mb := createTestMailbox(t, admin, mustDefaultDomainID(t, a), "front-perm", "Front Permissions", "Password123!", nil)
user := &testClient{t: t, server: ts}
if code := user.do("POST", "/api/auth/login", map[string]string{"email": mb.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("user login code=%d", code)
}
var mine struct {
Items []Mailbox `json:"items"`
}
if code := user.do("GET", "/api/mail/mailboxes", nil, &mine); code != http.StatusOK || len(mine.Items) != 1 || mine.Items[0].ID != mb.ID {
t.Fatalf("regular user should access mail front code=%d items=%+v", code, mine.Items)
}
var errBody map[string]any
if code := user.do("GET", "/api/admin/overview", nil, &errBody); code != http.StatusForbidden {
t.Fatalf("regular mail permissions should not grant admin access code=%d body=%v", code, errBody)
}
updateRegularPermissionGroup(t, admin, withoutPermissions(regularUserDefaultPermissions(), PermissionMailAccess))
noAccess := &testClient{t: t, server: ts}
if code := noAccess.do("POST", "/api/auth/login", map[string]string{"email": mb.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("no access login code=%d", code)
}
if code := noAccess.do("GET", "/api/mail/mailboxes", nil, &errBody); code != http.StatusForbidden {
t.Fatalf("missing mail access should block mailbox list code=%d body=%v", code, errBody)
}
updateRegularPermissionGroup(t, admin, withoutPermissions(regularUserDefaultPermissions(), PermissionMailSend))
noSend := &testClient{t: t, server: ts}
if code := noSend.do("POST", "/api/auth/login", map[string]string{"email": mb.Address, "password": "Password123!"}, &login); code != http.StatusOK {
t.Fatalf("no send login code=%d", code)
}
sendPayload := map[string]any{
"mailboxId": mb.ID,
"to": []string{"someone@example.test"},
"subject": "blocked send",
"text": "body",
"html": "<p>body</p>",
}
if code := noSend.do("POST", "/api/mail/send", sendPayload, &errBody); code != http.StatusForbidden {
t.Fatalf("missing send permission should block send code=%d body=%v", code, errBody)
}
schedulePayload := map[string]any{
"mailboxId": mb.ID,
"to": []string{"someone@example.test"},
"subject": "blocked schedule",
"text": "body",
"html": "<p>body</p>",
"sendAt": time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339Nano),
}
if code := noSend.do("POST", "/api/mail/schedule-send", schedulePayload, &errBody); code != http.StatusForbidden {
t.Fatalf("missing send permission should block scheduled send creation code=%d body=%v", code, errBody)
}
if code := noSend.do("GET", "/api/mail/scheduled-sends?mailboxId="+mb.ID, nil, &struct {
Items []ScheduledSend `json:"items"`
}{}); code != http.StatusOK {
t.Fatalf("schedule management permission should remain usable code=%d", code)
}
}
func TestMaildirSyncImportsRFC822(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
root := t.TempDir()
a.cfg.MaildirRoot = root
var domainID string
if err := a.db.QueryRowContext(ctx, `SELECT id FROM domains WHERE name=?`, "lanqin.local").Scan(&domainID); err != nil {
t.Fatal(err)
}
adminUser, _, err := a.userByEmail(ctx, "admin@lanqin.local")
if err != nil {
t.Fatal(err)
}
// seed() already created mailbox admin@lanqin.local
var mailboxID string
if err := a.db.QueryRowContext(ctx, `SELECT id FROM mailboxes WHERE user_id=? AND address=?`, adminUser.ID, "admin@lanqin.local").Scan(&mailboxID); err != nil {
t.Fatal(err)
}
if _, err := a.db.ExecContext(ctx, `DELETE FROM messages WHERE mailbox_id=?`, mailboxID); err != nil {
t.Fatal(err)
}
mailboxes, err := a.maildirMailboxes(ctx)
if err != nil {
t.Fatal(err)
}
var admin maildirMailbox
for _, mb := range mailboxes {
if mb.Address == "admin@lanqin.local" {
admin = mb
break
}
}
if admin.ID == "" {
t.Fatal("admin mailbox not found")
}
dir := filepath.Join(root, admin.Domain, admin.LocalPart, "Maildir", "new")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
raw := strings.Join([]string{
"From: sender@example.test",
"To: admin@lanqin.local",
"Subject: Maildir import test",
"Message-Id: <maildir-import@example.test>",
"Date: Sat, 13 Jun 2026 13:00:00 +0000",
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=utf-8",
"",
"hello from maildir",
}, "\r\n")
if err := os.WriteFile(filepath.Join(dir, "1749819600.M1P1.test"), []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
count, err := a.syncMaildirOnce(ctx)
if err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("imported=%d, want 1", count)
}
count, err = a.syncMaildirOnce(ctx)
if err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("second import=%d, want duplicate skip", count)
}
var subject, body string
err = a.db.QueryRow(`SELECT subject, body_text FROM messages WHERE mailbox_id=? AND message_id='<maildir-import@example.test>'`, admin.ID).Scan(&subject, &body)
if err != nil {
t.Fatal(err)
}
if subject != "Maildir import test" || !strings.Contains(body, "hello from maildir") {
t.Fatalf("unexpected imported message subject=%q body=%q", subject, body)
}
}
func TestMaildirSyncImportsSentFolder(t *testing.T) {
a := newTestApp(t)
ctx := context.Background()
root := t.TempDir()
a.cfg.MaildirRoot = root
adminUser, _, err := a.userByEmail(ctx, "admin@lanqin.local")
if err != nil {
t.Fatal(err)
}
var mailboxID string
if err := a.db.QueryRowContext(ctx, `SELECT id FROM mailboxes WHERE user_id=? AND address=?`, adminUser.ID, "admin@lanqin.local").Scan(&mailboxID); err != nil {
t.Fatal(err)
}
if _, err := a.db.ExecContext(ctx, `DELETE FROM messages WHERE mailbox_id=?`, mailboxID); err != nil {
t.Fatal(err)
}
sentFolderID, err := a.ensureFolder(ctx, mailboxID, "Sent")
if err != nil {
t.Fatal(err)
}
mailboxes, err := a.maildirMailboxes(ctx)
if err != nil {
t.Fatal(err)
}
var admin maildirMailbox
for _, mb := range mailboxes {
if mb.Address == "admin@lanqin.local" {
admin = mb
break
}
}
if admin.ID == "" {
t.Fatal("admin mailbox not found")
}
dir := filepath.Join(root, admin.Domain, admin.LocalPart, "Maildir", ".Sent", "new")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
raw := strings.Join([]string{
"From: admin@lanqin.local",
"To: recipient@example.test",
"Subject: SMTP sent archive",
"Message-Id: <smtp-sent-archive@example.test>",
"Date: Sat, 13 Jun 2026 14:00:00 +0000",
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=utf-8",
"",
"archived from smtp client",
}, "\r\n")
if err := os.WriteFile(filepath.Join(dir, "1749823200.M1P1.sent"), []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
count, err := a.syncMaildirOnce(ctx)
if err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("imported=%d, want 1", count)
}
var folderID, subject string
var read int
err = a.db.QueryRow(`SELECT folder_id, subject, is_read FROM messages WHERE mailbox_id=? AND message_id='<smtp-sent-archive@example.test>'`, admin.ID).Scan(&folderID, &subject, &read)
if err != nil {
t.Fatal(err)
}
if folderID != sentFolderID || subject != "SMTP sent archive" || read != 1 {
t.Fatalf("unexpected sent import folder=%q want=%q subject=%q read=%d", folderID, sentFolderID, subject, read)
}
}
func mustDefaultDomainID(t *testing.T, a *App) string {
t.Helper()
var id string
if err := a.db.QueryRow(`SELECT id FROM domains LIMIT 1`).Scan(&id); err != nil {
t.Fatal(err)
}
return id
}
func containsString(items []string, needle string) bool {
for _, item := range items {
if item == needle {
return true
}
}
return false
}
func withoutPermissions(items []string, removed ...string) []string {
removedSet := map[string]bool{}
for _, item := range removed {
removedSet[item] = true
}
out := make([]string, 0, len(items))
for _, item := range items {
if !removedSet[item] {
out = append(out, item)
}
}
return out
}