Files
mailgoserver/internal/jmap/email_test.go
T
2026-08-22 06:45:05 +01:00

401 lines
13 KiB
Go

package jmap_test
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"testing"
"mailgoserver/internal/jmap"
)
func TestJMAPEmailGetQueryChanges(t *testing.T) {
srv, _, store, email, password, mailboxID := newTestJMAPServer(t)
raw1 := []byte("From: Alice <alice@example.com>\r\nSubject: First\r\n\r\nBody one.")
if _, err := store.StoreMessage(mailboxID, "INBOX", raw1, "<one@example.com>", "Alice <alice@example.com>", "First"); err != nil {
t.Fatal(err)
}
stateResp := doJMAP(t, srv, email, password, jmap.Request{
MethodCalls: []jmap.Invocation{{Name: "Email/query", Args: json.RawMessage(`{"filter":{"inMailbox":"1"}}`), ID: "c1"}},
})
var q1 struct {
QueryState string `json:"queryState"`
IDs []string `json:"ids"`
Total int `json:"total"`
}
if err := json.Unmarshal(stateResp.MethodResponses[0].Args, &q1); err != nil {
t.Fatal(err)
}
if q1.Total != 1 || len(q1.IDs) != 1 {
t.Fatalf("expected 1 message from Email/query, got %+v", q1)
}
raw2 := []byte("From: Bob <bob@example.com>\r\nSubject: Second\r\n\r\nBody two.")
if _, err := store.StoreMessage(mailboxID, "INBOX", raw2, "<two@example.com>", "Bob <bob@example.com>", "Second"); err != nil {
t.Fatal(err)
}
getResp := doJMAP(t, srv, email, password, jmap.Request{
MethodCalls: []jmap.Invocation{{Name: "Email/get", Args: json.RawMessage(`{"ids":["` + q1.IDs[0] + `"]}`), ID: "c2"}},
})
var get struct {
List []struct {
ID string `json:"id"`
Subject string `json:"subject"`
From []struct {
Email string `json:"email"`
} `json:"from"`
} `json:"list"`
}
if err := json.Unmarshal(getResp.MethodResponses[0].Args, &get); err != nil {
t.Fatal(err)
}
if len(get.List) != 1 || get.List[0].Subject != "First" {
t.Fatalf("expected Email/get to return the first message, got %+v", get.List)
}
if len(get.List[0].From) != 1 || get.List[0].From[0].Email != "alice@example.com" {
t.Fatalf("expected From address alice@example.com, got %+v", get.List[0].From)
}
changesResp := doJMAP(t, srv, email, password, jmap.Request{
MethodCalls: []jmap.Invocation{{Name: "Email/changes", Args: json.RawMessage(`{"sinceState":"` + q1.QueryState + `"}`), ID: "c3"}},
})
var changes struct {
Created []string `json:"created"`
}
if err := json.Unmarshal(changesResp.MethodResponses[0].Args, &changes); err != nil {
t.Fatal(err)
}
if len(changes.Created) != 1 {
t.Fatalf("expected Email/changes to report exactly 1 new message since the query state, got %+v", changes.Created)
}
}
func TestJMAPEmailGetFullBodyOnRequest(t *testing.T) {
srv, _, store, email, password, mailboxID := newTestJMAPServer(t)
raw := []byte("From: Alice <alice@example.com>\r\nSubject: Hi\r\n\r\nPlain body here.")
uid, err := store.StoreMessage(mailboxID, "INBOX", raw, "<hi@example.com>", "Alice <alice@example.com>", "Hi")
if err != nil {
t.Fatal(err)
}
resp := doJMAP(t, srv, email, password, jmap.Request{
MethodCalls: []jmap.Invocation{{
Name: "Email/get",
Args: json.RawMessage(`{"ids":["` + strconv.FormatInt(uid, 10) + `"],"fetchTextBodyValues":true}`),
ID: "c1",
}},
})
var get struct {
List []struct {
TextBody []struct {
PartID string `json:"partId"`
} `json:"textBody"`
BodyValues map[string]struct {
Value string `json:"value"`
} `json:"bodyValues"`
} `json:"list"`
}
if err := json.Unmarshal(resp.MethodResponses[0].Args, &get); err != nil {
t.Fatal(err)
}
if len(get.List) != 1 || len(get.List[0].TextBody) != 1 {
t.Fatalf("expected fetchTextBodyValues to populate textBody, got %+v", get.List)
}
partID := get.List[0].TextBody[0].PartID
if got := get.List[0].BodyValues[partID].Value; got != "Plain body here." {
t.Errorf("expected the plain body text, got %q", got)
}
}
func TestJMAPThreadingGroupsRepliesByReferences(t *testing.T) {
_, database, store, _, _, mailboxID := newTestJMAPServer(t)
root := []byte("From: Alice <alice@example.com>\r\nSubject: Thread root\r\n\r\nHi.")
rootUID, err := store.StoreMessage(mailboxID, "INBOX", root, "root@example.com", "Alice <alice@example.com>", "Thread root")
if err != nil {
t.Fatal(err)
}
reply := []byte("From: Bob <bob@example.com>\r\nSubject: Re: Thread root\r\nIn-Reply-To: <root@example.com>\r\nReferences: <root@example.com>\r\n\r\nReplying.")
replyUID, err := store.StoreMessage(mailboxID, "INBOX", reply, "reply@example.com", "Bob <bob@example.com>", "Re: Thread root")
if err != nil {
t.Fatal(err)
}
rootMsg, err := database.GetMessageByUID(mailboxID, rootUID)
if err != nil || rootMsg == nil {
t.Fatal(err)
}
replyMsg, err := database.GetMessageByUID(mailboxID, replyUID)
if err != nil || replyMsg == nil {
t.Fatal(err)
}
if rootMsg.ThreadID == 0 {
t.Fatal("expected the root message to have a non-zero thread_id")
}
if replyMsg.ThreadID != rootMsg.ThreadID {
t.Errorf("expected the reply to join the root's thread (%d), got %d", rootMsg.ThreadID, replyMsg.ThreadID)
}
unrelated := []byte("From: Carol <carol@example.com>\r\nSubject: Unrelated\r\n\r\nSomething else entirely.")
unrelatedUID, err := store.StoreMessage(mailboxID, "INBOX", unrelated, "unrelated@example.com", "Carol <carol@example.com>", "Unrelated")
if err != nil {
t.Fatal(err)
}
unrelatedMsg, err := database.GetMessageByUID(mailboxID, unrelatedUID)
if err != nil || unrelatedMsg == nil {
t.Fatal(err)
}
if unrelatedMsg.ThreadID != unrelatedUID {
t.Errorf("expected an unrelated message to start its own singleton thread (== its own id %d), got %d", unrelatedUID, unrelatedMsg.ThreadID)
}
}
func TestJMAPEmailSetMoveAndFlags(t *testing.T) {
srv, database, store, email, password, mailboxID := newTestJMAPServer(t)
raw := []byte("From: Alice <alice@example.com>\r\nSubject: Hi\r\n\r\nBody.")
uid, err := store.StoreMessage(mailboxID, "INBOX", raw, "<hi@example.com>", "Alice <alice@example.com>", "Hi")
if err != nil {
t.Fatal(err)
}
trashID, err := database.EnsureFolderRow(mailboxID, "Trash")
if err != nil {
t.Fatal(err)
}
resp := doJMAP(t, srv, email, password, jmap.Request{
MethodCalls: []jmap.Invocation{{
Name: "Email/set",
Args: json.RawMessage(`{"update":{"` + strconv.FormatInt(uid, 10) + `":{"keywords":{"$seen":true,"$flagged":true},"mailboxIds":{"` + strconv.FormatInt(trashID, 10) + `":true}}}}`),
ID: "c1",
}},
})
var result struct {
Updated map[string]any `json:"updated"`
NotUpdated map[string]any `json:"notUpdated"`
}
if err := json.Unmarshal(resp.MethodResponses[0].Args, &result); err != nil {
t.Fatal(err)
}
if len(result.NotUpdated) != 0 {
t.Fatalf("expected the update to succeed, got notUpdated %+v", result.NotUpdated)
}
m, err := database.GetMessageByUID(mailboxID, uid)
if err != nil || m == nil {
t.Fatal(err)
}
if m.Folder != "Trash" {
t.Errorf("expected the message moved to Trash, got folder %q", m.Folder)
}
if !strings.Contains(m.Flags, `\Seen`) || !strings.Contains(m.Flags, `\Flagged`) {
t.Errorf("expected both $seen and $flagged applied, got flags %q", m.Flags)
}
}
func TestJMAPEmailSetKeywordPatch(t *testing.T) {
srv, database, store, email, password, mailboxID := newTestJMAPServer(t)
raw := []byte("From: Alice <alice@example.com>\r\nSubject: Hi\r\n\r\nBody.")
uid, err := store.StoreMessage(mailboxID, "INBOX", raw, "<hi@example.com>", "Alice <alice@example.com>", "Hi")
if err != nil {
t.Fatal(err)
}
doJMAP(t, srv, email, password, jmap.Request{
MethodCalls: []jmap.Invocation{{
Name: "Email/set",
Args: json.RawMessage(`{"update":{"` + strconv.FormatInt(uid, 10) + `":{"keywords/$seen":true}}}`),
ID: "c1",
}},
})
m, err := database.GetMessageByUID(mailboxID, uid)
if err != nil || m == nil {
t.Fatal(err)
}
if !strings.Contains(m.Flags, `\Seen`) {
t.Fatalf("expected keywords/$seen patch to add \\Seen, got flags %q", m.Flags)
}
}
func TestJMAPEmailSetDestroyCreatesTombstone(t *testing.T) {
srv, database, store, email, password, mailboxID := newTestJMAPServer(t)
raw := []byte("From: Alice <alice@example.com>\r\nSubject: Hi\r\n\r\nBody.")
uid, err := store.StoreMessage(mailboxID, "INBOX", raw, "<hi@example.com>", "Alice <alice@example.com>", "Hi")
if err != nil {
t.Fatal(err)
}
state, err := database.MessagesState(mailboxID)
if err != nil {
t.Fatal(err)
}
resp := doJMAP(t, srv, email, password, jmap.Request{
MethodCalls: []jmap.Invocation{{
Name: "Email/set",
Args: json.RawMessage(`{"destroy":["` + strconv.FormatInt(uid, 10) + `"]}`),
ID: "c1",
}},
})
var result struct {
Destroyed []string `json:"destroyed"`
NotDestroyed map[string]any `json:"notDestroyed"`
}
if err := json.Unmarshal(resp.MethodResponses[0].Args, &result); err != nil {
t.Fatal(err)
}
if len(result.NotDestroyed) != 0 || len(result.Destroyed) != 1 {
t.Fatalf("expected the destroy to succeed, got %+v / notDestroyed %+v", result.Destroyed, result.NotDestroyed)
}
m, err := database.GetMessageByUID(mailboxID, uid)
if err != nil {
t.Fatal(err)
}
if m != nil {
t.Fatal("expected the message row to be gone after destroy")
}
changesResp := doJMAP(t, srv, email, password, jmap.Request{
MethodCalls: []jmap.Invocation{{Name: "Email/changes", Args: json.RawMessage(`{"sinceState":"` + state + `"}`), ID: "c2"}},
})
var changes struct {
Destroyed []string `json:"destroyed"`
}
if err := json.Unmarshal(changesResp.MethodResponses[0].Args, &changes); err != nil {
t.Fatal(err)
}
found := false
for _, id := range changes.Destroyed {
if id == strconv.FormatInt(uid, 10) {
found = true
}
}
if !found {
t.Fatalf("expected Email/changes to report %d as destroyed, got %+v", uid, changes.Destroyed)
}
}
func TestJMAPEmailCopy(t *testing.T) {
srv, database, store, email, password, mailboxID := newTestJMAPServer(t)
raw := []byte("From: Alice <alice@example.com>\r\nSubject: Hi\r\n\r\nBody.")
uid, err := store.StoreMessage(mailboxID, "INBOX", raw, "<hi@example.com>", "Alice <alice@example.com>", "Hi")
if err != nil {
t.Fatal(err)
}
trashID, err := database.EnsureFolderRow(mailboxID, "Trash")
if err != nil {
t.Fatal(err)
}
resp := doJMAP(t, srv, email, password, jmap.Request{
MethodCalls: []jmap.Invocation{{
Name: "Email/copy",
Args: json.RawMessage(`{"fromAccountId":"` + strconv.FormatInt(mailboxID, 10) + `","create":{"c1":{"id":"` + strconv.FormatInt(uid, 10) + `","mailboxIds":{"` + strconv.FormatInt(trashID, 10) + `":true}}}}`),
ID: "c1",
}},
})
var result struct {
Created map[string]struct{ ID string } `json:"created"`
NotCreated map[string]any `json:"notCreated"`
}
if err := json.Unmarshal(resp.MethodResponses[0].Args, &result); err != nil {
t.Fatal(err)
}
if len(result.NotCreated) != 0 {
t.Fatalf("expected the copy to succeed, got notCreated %+v", result.NotCreated)
}
newID, ok := result.Created["c1"]
if !ok {
t.Fatal("expected a created entry for c1")
}
newUID, err := strconv.ParseInt(newID.ID, 10, 64)
if err != nil {
t.Fatal(err)
}
if newUID == uid {
t.Fatal("expected the copy to have a distinct uid from the original")
}
orig, err := database.GetMessageByUID(mailboxID, uid)
if err != nil || orig == nil {
t.Fatal("expected the original to still exist after copy")
}
if orig.Folder != "INBOX" {
t.Errorf("expected the original to remain in INBOX, got %q", orig.Folder)
}
copyMsg, err := database.GetMessageByUID(mailboxID, newUID)
if err != nil || copyMsg == nil {
t.Fatal("expected the copy to exist")
}
if copyMsg.Folder != "Trash" {
t.Errorf("expected the copy in Trash, got %q", copyMsg.Folder)
}
}
func TestJMAPEmailImport(t *testing.T) {
srv, database, _, email, password, mailboxID := newTestJMAPServer(t)
raw := "From: Alice <alice@example.com>\r\nSubject: Imported\r\n\r\nImported body."
uploadReq, err := http.NewRequest(http.MethodPost, srv.URL+"/jmap/upload/"+strconv.FormatInt(mailboxID, 10), strings.NewReader(raw))
if err != nil {
t.Fatal(err)
}
uploadReq.SetBasicAuth(email, password)
uploadResp, err := http.DefaultClient.Do(uploadReq)
if err != nil {
t.Fatal(err)
}
defer uploadResp.Body.Close()
if uploadResp.StatusCode != http.StatusOK {
t.Fatalf("expected upload to succeed, got %d", uploadResp.StatusCode)
}
var uploaded struct {
BlobID string `json:"blobId"`
}
if err := json.NewDecoder(uploadResp.Body).Decode(&uploaded); err != nil {
t.Fatal(err)
}
if uploaded.BlobID == "" {
t.Fatal("expected a non-empty blobId from upload")
}
inboxID, err := database.EnsureFolderRow(mailboxID, "INBOX")
if err != nil {
t.Fatal(err)
}
importResp := doJMAP(t, srv, email, password, jmap.Request{
MethodCalls: []jmap.Invocation{{
Name: "Email/import",
Args: json.RawMessage(`{"emails":{"c1":{"blobId":"` + uploaded.BlobID + `","mailboxIds":{"` + strconv.FormatInt(inboxID, 10) + `":true}}}}`),
ID: "c1",
}},
})
var result struct {
Created map[string]struct {
ID string `json:"id"`
Subject string `json:"subject"`
} `json:"created"`
NotCreated map[string]any `json:"notCreated"`
}
if err := json.Unmarshal(importResp.MethodResponses[0].Args, &result); err != nil {
t.Fatal(err)
}
if len(result.NotCreated) != 0 {
t.Fatalf("expected the import to succeed, got notCreated %+v", result.NotCreated)
}
created, ok := result.Created["c1"]
if !ok || created.Subject != "Imported" {
t.Fatalf("expected the imported message with subject 'Imported', got %+v", result.Created)
}
}