package jmap import ( "bytes" "crypto/rand" "encoding/hex" "encoding/json" "io" "net/http" "os" "path/filepath" "strconv" "strings" "mailgoserver/internal/db" "mailgoserver/internal/mailstore" "mailgoserver/internal/mailview" ) // stagingDir is where an uploaded-but-not-yet-imported blob's raw bytes live — // mailbox-scoped, alongside (not inside) that mailbox's real message storage // directories, so it's never mistaken for a real stored message. A blob here is // ephemeral: consumed once by Email/import (blobBytes just reads it — repeat imports // of the same upload are harmless, nothing deletes it) or left to expire; cleanup is // left as a best-effort TTL sweep to add later, not tracked in the DB — matching this // codebase's "no migration framework, no fuss" posture for genuinely transient state // (the same reasoning esrv_relay_queue's rows use). func stagingDir(b *Backend, mbox *db.Mailbox) string { return filepath.Join(b.Mailstore.BasePath, mailstore.SanitizePathSegment(mbox.Email), ".jmap-uploads") } func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(v) } func handleUpload(b *Backend, w http.ResponseWriter, r *http.Request) { mbox := mailboxFromContext(r) dir := stagingDir(b, mbox) if err := os.MkdirAll(dir, 0o755); err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } maxSize := b.Cfg.Section("Mailstore").Key("max_message_bytes").MustInt64(25 * 1024 * 1024) data, err := io.ReadAll(io.LimitReader(r.Body, maxSize+1)) if err != nil { http.Error(w, "read error", http.StatusInternalServerError) return } if int64(len(data)) > maxSize { http.Error(w, "payload too large", http.StatusRequestEntityTooLarge) return } name := make([]byte, 16) rand.Read(name) blobID := hex.EncodeToString(name) if err := os.WriteFile(filepath.Join(dir, blobID), data, 0o600); err != nil { http.Error(w, "internal error", http.StatusInternalServerError) return } contentType := r.Header.Get("Content-Type") if contentType == "" { contentType = "application/octet-stream" } writeJSON(w, map[string]any{ "accountId": strconv.FormatInt(mbox.ID, 10), "blobId": blobID, "type": contentType, "size": len(data), }) } // blobBytes resolves blobID to raw bytes, per Email/import's need: either a // still-staged upload (see handleUpload) or — not applicable here, see // handleDownload's attachment-extraction path for that case — nothing else. Returns // (nil, false) if blobID isn't a staged upload for this mailbox. func blobBytes(b *Backend, mbox *db.Mailbox, blobID string) ([]byte, bool) { if strings.ContainsAny(blobID, "/\\.") { return nil, false } data, err := os.ReadFile(filepath.Join(stagingDir(b, mbox), blobID)) if err != nil { return nil, false } return data, true } // handleDownload serves GET /jmap/download/{accountId}/{blobId}/{name} — either a // still-staged upload (served as-is) or an attachment inside an already-stored // message, blobId shaped "-" (parsed and ownership-checked against // the authenticated mailbox here, never trusting the path alone). func handleDownload(b *Backend, w http.ResponseWriter, r *http.Request) { mbox := mailboxFromContext(r) blobID := r.PathValue("blobId") if data, ok := blobBytes(b, mbox, blobID); ok { w.Header().Set("Content-Type", "application/octet-stream") w.Write(data) return } uidStr, idxStr, found := strings.Cut(blobID, "-") if !found { http.NotFound(w, r) return } uid, err := strconv.ParseInt(uidStr, 10, 64) if err != nil { http.NotFound(w, r) return } idx, err := strconv.Atoi(idxStr) if err != nil || idx < 0 { http.NotFound(w, r) return } msg, err := b.DB.GetMessageByUID(mbox.ID, uid) if err != nil || msg == nil { http.NotFound(w, r) return } raw, err := b.Mailstore.FetchMessage(mbox.ID, uid) if err != nil { http.NotFound(w, r) return } parsed, err := mailview.Parse(bytes.NewReader(raw)) if err != nil || idx >= len(parsed.Attachments) { http.NotFound(w, r) return } att := parsed.Attachments[idx] if att.ContentType != "" { w.Header().Set("Content-Type", att.ContentType) } w.Write(att.Data) }