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

141 lines
4.2 KiB
Go

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 "<uid>-<part-index>" (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)
}