141 lines
4.2 KiB
Go
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)
|
|
}
|