feat(webmail,auth): leitor estilo Outlook, responder a todos, importantes e isolamento do admin de dominio
Webmail - Leitor de mensagem em componente proprio, com barra de comandos fixa, cabecalho compacto, faixa de imagens bloqueadas e chips de anexo. - Divisor arrastavel entre lista e leitor (mouse, setas, duplo clique), largura persistida e limites que preservam os dois paineis. - Destinatarios em chips (Para, Cc, Cco): aceita nomes com virgula, colagem de listas, validacao e bloqueio de envio com endereco invalido. - Responder a todos, sem o proprio endereco e sem repetidos; In-Reply-To e References nas respostas; prefixo Re:/Fwd: sem repeticao. - Marcar como importante (estrela, filtro, linha da lista) e enviar com alta importancia (Importance/X-Priority), com etiqueta no leitor. - Sanitizador: a regexp de URI customizada descartava todo atributo que nao fosse link (width, colspan, bgcolor...), o que desmontava tabelas de e-mail; imagens de fundo agora sao bloqueadas de forma reversivel. Protocols - POST /api/v1/mail/flagged (\Flagged) com bump de modseq para IMAP. - Renderizacao expoe message_id, in_reply_to e important. - Cabecalhos de resposta validados contra injecao. Auth - As views do admin de dominio eram globais: dashboard, fila de saida, DMARC, preflight e diagnostico mostravam dados de todos os dominios. Agora todas respeitam o escopo da sessao; super admin segue vendo tudo. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b5baf33a63
commit
50a67531ba
30 files changed
+1810
-348
No files matched your search
@@ -62,6 +62,9 @@ type WebmailRepository interface {
|
||||
GetMessageBlob(ctx context.Context, mailboxID, folderName string, uid uint32) (uuid.UUID, error)
|
||||
// MarkSeen records that the user opened the message.
|
||||
MarkSeen(ctx context.Context, mailboxID, folderName string, uid uint32, seen bool) error
|
||||
// MarkFlagged sets or clears the \Flagged flag, which is what the webmail
|
||||
// calls "important" and every IMAP client calls starred.
|
||||
MarkFlagged(ctx context.Context, mailboxID, folderName string, uid uint32, flagged bool) error
|
||||
// Expunge soft-deletes one message, keeping the folder counters in step.
|
||||
Expunge(ctx context.Context, mailboxID, folderName string, uid uint32) error
|
||||
// MoveToFolder relocates one message into a named folder and returns its
|
||||
|
||||
@@ -34,6 +34,15 @@ type RenderedMessage struct {
|
||||
Text string `json:"text"`
|
||||
HTML string `json:"html"`
|
||||
Attachments []string `json:"attachments"`
|
||||
// MessageID is the Message-ID header, so a reply can thread under this
|
||||
// message. Empty when the message has none.
|
||||
MessageID string `json:"message_id"`
|
||||
// InReplyTo is the Message-ID this message answers, so reopening a draft
|
||||
// keeps the reply threaded.
|
||||
InReplyTo string `json:"in_reply_to"`
|
||||
// Important is true when the sender asked for priority: Importance: high,
|
||||
// or X-Priority 1 or 2.
|
||||
Important bool `json:"important"`
|
||||
// InlineImages maps a part's Content-ID - without the angle brackets, the
|
||||
// way the HTML body spells it in src="cid:..." - to a self-contained
|
||||
// data: URI.
|
||||
@@ -79,6 +88,9 @@ func RenderMessage(raw []byte, uid uint32) RenderedMessage {
|
||||
}
|
||||
}
|
||||
}
|
||||
out.MessageID = strings.TrimSpace(header.Get("Message-Id"))
|
||||
out.InReplyTo = strings.TrimSpace(header.Get("In-Reply-To"))
|
||||
out.Important = isHighPriority(header.Get("Importance"), header.Get("X-Priority"))
|
||||
// A missing Date header parses without error into the zero time, which
|
||||
// would render as the year 0001 in the reader. Left empty, the UI falls
|
||||
// back to the received timestamp it already has.
|
||||
@@ -214,3 +226,14 @@ func ExtractAttachment(entity *message.Entity, targetName string) ([]byte, strin
|
||||
}
|
||||
return nil, ""
|
||||
}
|
||||
|
||||
// isHighPriority reads the two headers clients use to mark urgent mail.
|
||||
// X-Priority is "1 (Highest)" or "2 (High)"; anything else, or nothing, is
|
||||
// ordinary.
|
||||
func isHighPriority(importance, xPriority string) bool {
|
||||
if strings.EqualFold(strings.TrimSpace(importance), "high") {
|
||||
return true
|
||||
}
|
||||
fields := strings.Fields(xPriority)
|
||||
return len(fields) > 0 && (fields[0] == "1" || fields[0] == "2")
|
||||
}
|
||||
@@ -169,3 +169,25 @@ func TestExtractAttachment_FindsInlineNamedParts(t *testing.T) {
|
||||
t.Errorf("ExtractAttachment = %q/%q", data, ctype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMessage_ReadsPriorityAndMessageID(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"Importance: high\r\n": true,
|
||||
"Importance: High\r\n": true,
|
||||
"X-Priority: 1 (Highest)\r\n": true,
|
||||
"X-Priority: 2 (High)\r\n": true,
|
||||
"X-Priority: 3 (Normal)\r\n": false,
|
||||
"Importance: normal\r\n": false,
|
||||
"": false,
|
||||
}
|
||||
for header, want := range cases {
|
||||
raw := "From: a@b.test\r\nMessage-ID: <m1@b.test>\r\n" + header + "Subject: s\r\n\r\nbody\r\n"
|
||||
got := RenderMessage([]byte(raw), 1)
|
||||
if got.Important != want {
|
||||
t.Errorf("%q: Important = %v, want %v", header, got.Important, want)
|
||||
}
|
||||
if got.MessageID != "<m1@b.test>" {
|
||||
t.Errorf("MessageID = %q", got.MessageID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,9 @@ func (s *stubWebmailRepo) GetMessageBlob(context.Context, string, string, uint32
|
||||
return uuid.Nil, nil
|
||||
}
|
||||
func (s *stubWebmailRepo) MarkSeen(context.Context, string, string, uint32, bool) error { return nil }
|
||||
func (s *stubWebmailRepo) MarkFlagged(context.Context, string, string, uint32, bool) error {
|
||||
return nil
|
||||
}
|
||||
func (s *stubWebmailRepo) Expunge(_ context.Context, _, _ string, uid uint32) error {
|
||||
s.expunged = append(s.expunged, uid)
|
||||
return s.expungeErr
|
||||
@@ -472,3 +475,43 @@ func TestMoveRejectsAnEmptyTarget(t *testing.T) {
|
||||
t.Error("an empty target should be refused")
|
||||
}
|
||||
}
|
||||
|
||||
// High importance is written as both headers clients read, and only on request.
|
||||
func TestImportantMessageCarriesPriorityHeaders(t *testing.T) {
|
||||
in := ComposeInput{From: "a@x.test", To: []string{"b@y.test"}, Subject: "s", Text: "t", Important: true}
|
||||
got := string(buildMimeMessage(in, []string{"b@y.test"}, "x.test"))
|
||||
for _, h := range []string{"Importance: high\r\n", "X-Priority: 1\r\n"} {
|
||||
if !strings.Contains(got, h) {
|
||||
t.Errorf("missing %q in\n%s", h, got)
|
||||
}
|
||||
}
|
||||
in.Important = false
|
||||
if got := string(buildMimeMessage(in, []string{"b@y.test"}, "x.test")); strings.Contains(got, "Importance") || strings.Contains(got, "X-Priority") {
|
||||
t.Error("an ordinary message was marked with a priority")
|
||||
}
|
||||
}
|
||||
|
||||
// A reply threads under the original through In-Reply-To and References.
|
||||
func TestReplyCarriesThreadingHeaders(t *testing.T) {
|
||||
in := ComposeInput{From: "a@x.test", To: []string{"b@y.test"}, Subject: "Re: s", Text: "t", InReplyTo: "<orig-1@y.test>"}
|
||||
got := string(buildMimeMessage(in, []string{"b@y.test"}, "x.test"))
|
||||
for _, h := range []string{"In-Reply-To: <orig-1@y.test>\r\n", "References: <orig-1@y.test>\r\n"} {
|
||||
if !strings.Contains(got, h) {
|
||||
t.Errorf("missing %q", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The reply id comes from the request and lands in a header, so anything that
|
||||
// could start a new header must be dropped, not written.
|
||||
func TestInReplyToCannotInjectHeaders(t *testing.T) {
|
||||
for _, bad := range []string{
|
||||
"<a@b>\r\nBcc: evil@x.test", "<a@b>\nX: y", "no-brackets@b", "<a b@c>", "<a@b><c@d>", "", "<>",
|
||||
} {
|
||||
in := ComposeInput{From: "a@x.test", To: []string{"b@y.test"}, Subject: "s", Text: "t", InReplyTo: bad}
|
||||
got := string(buildMimeMessage(in, []string{"b@y.test"}, "x.test"))
|
||||
if strings.Contains(got, "In-Reply-To") || strings.Contains(got, "evil@x.test") {
|
||||
t.Errorf("accepted %q:\n%s", bad, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,15 @@ func (uc *WebmailUseCase) SetSeen(ctx context.Context, address, folder string, u
|
||||
return uc.repo.MarkSeen(ctx, id, folder, uid, seen)
|
||||
}
|
||||
|
||||
// SetFlagged marks a message important, or takes the mark off.
|
||||
func (uc *WebmailUseCase) SetFlagged(ctx context.Context, address, folder string, uid uint32, flagged bool) error {
|
||||
id, err := uc.mailboxID(ctx, address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return uc.repo.MarkFlagged(ctx, id, folder, uid, flagged)
|
||||
}
|
||||
|
||||
// ComposeInput is one message written in the webmail.
|
||||
type ComposeInput struct {
|
||||
From string
|
||||
@@ -145,6 +154,12 @@ type ComposeInput struct {
|
||||
// went out and its draft stayed in Drafts: a duplicate of mail already on
|
||||
// its way, which no screen in the webmail could then delete.
|
||||
DraftUID uint32
|
||||
// Important sends the message with high priority, in the two spellings
|
||||
// mail clients read: Importance (RFC 4021) and the older X-Priority.
|
||||
Important bool
|
||||
// InReplyTo is the Message-ID of the message being answered. It is what
|
||||
// lets the recipient's client thread the reply under the original.
|
||||
InReplyTo string
|
||||
}
|
||||
|
||||
// ErrNoRecipients rejects a submission with nobody to deliver to, before it
|
||||
@@ -418,6 +433,13 @@ func buildMimeMessage(input ComposeInput, recipients []string, localHost string)
|
||||
if len(input.Cc) > 0 {
|
||||
b.WriteString("Cc: " + strings.Join(input.Cc, ", ") + "\r\n")
|
||||
}
|
||||
if id := cleanMessageID(input.InReplyTo); id != "" {
|
||||
b.WriteString("In-Reply-To: " + id + "\r\n")
|
||||
b.WriteString("References: " + id + "\r\n")
|
||||
}
|
||||
if input.Important {
|
||||
b.WriteString("Importance: high\r\nX-Priority: 1\r\n")
|
||||
}
|
||||
b.WriteString("Subject: " + encodeHeaderValue(input.Subject) + "\r\n")
|
||||
b.WriteString("Date: " + time.Now().Format(time.RFC1123Z) + "\r\n")
|
||||
b.WriteString(fmt.Sprintf("Message-ID: <%s@%s>\r\n", uuid.NewString(), localHost))
|
||||
@@ -517,3 +539,19 @@ func mimeEncode(value string) string {
|
||||
clean := strings.NewReplacer("\r", " ", "\n", " ").Replace(value)
|
||||
return mime.BEncoding.Encode("utf-8", clean)
|
||||
}
|
||||
|
||||
// cleanMessageID returns id only when it is a single, well-formed <...>
|
||||
// Message-ID. The value comes from the request and is written into a header,
|
||||
// so anything with a line break - which would start a new header - or
|
||||
// anything that is not an angle-bracketed token is dropped rather than
|
||||
// repaired.
|
||||
func cleanMessageID(id string) string {
|
||||
id = strings.TrimSpace(id)
|
||||
if len(id) < 3 || len(id) > 998 || id[0] != '<' || id[len(id)-1] != '>' {
|
||||
return ""
|
||||
}
|
||||
if strings.ContainsAny(id, "\r\n\x00 \t,;") || strings.Count(id, "<") != 1 || strings.Count(id, ">") != 1 {
|
||||
return ""
|
||||
}
|
||||
return id
|
||||
}
|
||||
@@ -223,6 +223,63 @@ func (r *WebmailRepository) MarkSeen(
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// MarkFlagged sets or clears \Flagged. Unlike \Seen it moves no counter, but a
|
||||
// real change still bumps the folder's modseq: that is how an IMAP client
|
||||
// holding the folder open learns the flag changed without a full resync.
|
||||
func (r *WebmailRepository) MarkFlagged(
|
||||
ctx context.Context, mailboxID, folderName string, uid uint32, flagged bool,
|
||||
) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var messageID uuid.UUID
|
||||
var receivedAt any
|
||||
var folderID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT e.id, e.received_at, f.id::text FROM email_messages e
|
||||
JOIN folders f ON f.id = e.folder_id
|
||||
WHERE e.mailbox_id = $1 AND `+folderCondition+`
|
||||
AND e.uid = $3 AND e.expunged_at IS NULL
|
||||
`, mailboxID, folderName, int64(uid)).Scan(&messageID, &receivedAt, &folderID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return ErrMessageNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var changed int64
|
||||
if flagged {
|
||||
tag, err := tx.Exec(ctx, `
|
||||
INSERT INTO message_flags (message_id, received_at, flag)
|
||||
VALUES ($1, $2, '\Flagged') ON CONFLICT DO NOTHING
|
||||
`, messageID, receivedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changed = tag.RowsAffected()
|
||||
} else {
|
||||
tag, err := tx.Exec(ctx, `
|
||||
DELETE FROM message_flags WHERE message_id = $1 AND received_at = $2 AND flag = '\Flagged'
|
||||
`, messageID, receivedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changed = tag.RowsAffected()
|
||||
}
|
||||
|
||||
if changed > 0 {
|
||||
if _, err := tx.Exec(ctx,
|
||||
`UPDATE folders SET highest_modseq = highest_modseq + 1 WHERE id = $1`, folderID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// Expunge marks one message deleted and keeps the folder counters in step.
|
||||
//
|
||||
// Soft delete, matching the expunged_at column the rest of the schema uses: the
|
||||
|
||||
@@ -209,3 +209,44 @@ func TestDeleteMovesToTrashThenExpungesFromTrash(t *testing.T) {
|
||||
t.Errorf("the message survived being emptied from Trash: %d rows", len(trashRows))
|
||||
}
|
||||
}
|
||||
|
||||
// Marking important round-trips through the list, can be undone, and cannot
|
||||
// reach a message the mailbox does not own.
|
||||
func TestMarkFlaggedRoundTripsAndStaysInsideTheMailbox(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
repo := NewWebmailRepository(pool)
|
||||
mailboxID := seedMailbox(t, pool)
|
||||
ctx := context.Background()
|
||||
|
||||
uid := deliver(t, pool, mailboxID, "INBOX", "important one", false)
|
||||
|
||||
flaggedNow := func() bool {
|
||||
msgs, err := repo.ListMessages(ctx, mailboxID, "inbox", "", 50, 0)
|
||||
if err != nil || len(msgs) == 0 {
|
||||
t.Fatalf("list: %v (%d messages)", err, len(msgs))
|
||||
}
|
||||
return msgs[0].Flagged
|
||||
}
|
||||
|
||||
if err := repo.MarkFlagged(ctx, mailboxID, "inbox", uid, true); err != nil {
|
||||
t.Fatalf("flag: %v", err)
|
||||
}
|
||||
if !flaggedNow() {
|
||||
t.Error("message is not flagged after MarkFlagged(true)")
|
||||
}
|
||||
// Flagging twice is not an error and does not duplicate the flag.
|
||||
if err := repo.MarkFlagged(ctx, mailboxID, "inbox", uid, true); err != nil {
|
||||
t.Errorf("flagging twice: %v", err)
|
||||
}
|
||||
if err := repo.MarkFlagged(ctx, mailboxID, "inbox", uid, false); err != nil {
|
||||
t.Fatalf("unflag: %v", err)
|
||||
}
|
||||
if flaggedNow() {
|
||||
t.Error("message is still flagged after MarkFlagged(false)")
|
||||
}
|
||||
|
||||
other := seedMailbox(t, pool)
|
||||
if err := repo.MarkFlagged(ctx, other, "inbox", uid, true); err != ErrMessageNotFound {
|
||||
t.Errorf("another mailbox flagging this UID got %v, want ErrMessageNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -245,6 +245,32 @@ func (m *mailAPI) handleSeen(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (m *mailAPI) handleFlagged(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := m.authenticate(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Folder string `json:"folder"`
|
||||
UID uint32 `json:"uid"`
|
||||
Flagged bool `json:"flagged"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid JSON")
|
||||
return
|
||||
}
|
||||
if body.Folder == "" {
|
||||
body.Folder = "inbox"
|
||||
}
|
||||
|
||||
if err := m.useCase.SetFlagged(r.Context(), session.Email, body.Folder, body.UID, body.Flagged); err != nil {
|
||||
m.fail(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (m *mailAPI) handleSend(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := m.authenticate(w, r)
|
||||
if !ok {
|
||||
@@ -411,6 +437,9 @@ func (m *mailAPI) decodeCompose(
|
||||
// DraftUID is the autosaved draft this message came from, so sending
|
||||
// can clear it. Without it the draft outlived the message it became.
|
||||
DraftUID uint32 `json:"draft_uid"`
|
||||
// Important sends with high priority; InReplyTo threads a reply.
|
||||
Important bool `json:"important"`
|
||||
InReplyTo string `json:"in_reply_to"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxComposeBytes)).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid JSON")
|
||||
@@ -426,14 +455,16 @@ func (m *mailAPI) decodeCompose(
|
||||
// letting the body choose From is how a webmail becomes an open relay for
|
||||
// spoofed mail.
|
||||
return usecase.ComposeInput{
|
||||
From: sender,
|
||||
To: body.To,
|
||||
Cc: body.Cc,
|
||||
Bcc: body.Bcc,
|
||||
Subject: body.Subject,
|
||||
Text: text,
|
||||
HTML: body.HTML,
|
||||
DraftUID: body.DraftUID,
|
||||
From: sender,
|
||||
To: body.To,
|
||||
Cc: body.Cc,
|
||||
Bcc: body.Bcc,
|
||||
Subject: body.Subject,
|
||||
Text: text,
|
||||
HTML: body.HTML,
|
||||
DraftUID: body.DraftUID,
|
||||
Important: body.Important,
|
||||
InReplyTo: body.InReplyTo,
|
||||
}, true
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -34,6 +35,7 @@ type fakeMailStore struct {
|
||||
addresses map[string]string // address -> mailbox ID
|
||||
|
||||
seenCalls []string
|
||||
flaggedCalls []string
|
||||
deletedCalls []string
|
||||
movedCalls []string
|
||||
expungeErr error
|
||||
@@ -80,6 +82,11 @@ func (f *fakeMailStore) MarkSeen(_ context.Context, mailboxID, folder string, ui
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeMailStore) MarkFlagged(_ context.Context, mailboxID, folder string, uid uint32, flagged bool) error {
|
||||
f.flaggedCalls = append(f.flaggedCalls, fmt.Sprintf("%s/%s/%d/%t", mailboxID, folder, uid, flagged))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeMailStore) Expunge(_ context.Context, mailboxID, folder string, uid uint32) error {
|
||||
if f.expungeErr != nil {
|
||||
return f.expungeErr
|
||||
@@ -308,6 +315,43 @@ func TestMessageListDefaultsToTheInbox(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- flagging ------------------------------------------------------------
|
||||
|
||||
// Marking important is scoped to the session's own mailbox, never to an
|
||||
// identifier the request supplies.
|
||||
func TestFlaggingActsOnTheSessionsMailbox(t *testing.T) {
|
||||
store := storeWithMail()
|
||||
api := newMailAPI(t, store, nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
api.handleFlagged(rec, request(http.MethodPost, "/api/v1/mail/flagged", `{"uid":1,"flagged":true}`))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("answered %d", rec.Code)
|
||||
}
|
||||
if len(store.flaggedCalls) != 1 || store.flaggedCalls[0] != "mailbox-1/inbox/1/true" {
|
||||
t.Errorf("flag call = %v, want [mailbox-1/inbox/1/true]", store.flaggedCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlaggingRefusesARequestWithNoSession(t *testing.T) {
|
||||
api := newMailAPI(t, storeWithMail(), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
api.handleFlagged(rec, httptest.NewRequest(http.MethodPost, "/api/v1/mail/flagged", strings.NewReader(`{"uid":1}`)))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("answered %d without a session, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlaggingRejectsMalformedJson(t *testing.T) {
|
||||
api := newMailAPI(t, storeWithMail(), nil)
|
||||
rec := httptest.NewRecorder()
|
||||
api.handleFlagged(rec, request(http.MethodPost, "/api/v1/mail/flagged", `{`))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("answered %d, want 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- deleting ------------------------------------------------------------
|
||||
|
||||
func TestDeleteMovesToTrashFromAnOrdinaryFolder(t *testing.T) {
|
||||
|
||||
@@ -94,6 +94,7 @@ func (r *Router) registerRoutes() {
|
||||
r.mux.HandleFunc("/api/v1/mail/messages", r.handleMailMessages)
|
||||
r.mux.HandleFunc("/api/v1/mail/message/", r.handleMailMessage)
|
||||
r.mux.HandleFunc("/api/v1/mail/seen", r.handleMailSeen)
|
||||
r.mux.HandleFunc("/api/v1/mail/flagged", r.handleMailFlagged)
|
||||
r.mux.HandleFunc("/api/v1/mail/send", r.handleMailSend)
|
||||
r.mux.HandleFunc("/api/v1/mail/draft", r.handleMailDraft)
|
||||
r.mux.HandleFunc("/api/v1/mail/delete", r.handleMailDelete)
|
||||
@@ -303,6 +304,16 @@ func (r *Router) handleMailSeen(w http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) handleMailFlagged(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "POST required")
|
||||
return
|
||||
}
|
||||
if r.mailReady(w) {
|
||||
r.mail.handleFlagged(w, req)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) handleMailSend(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "POST required")
|
||||
|
||||
Reference in new issue
Block a user