feat: add managed installation and system updates
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxMailImportBytes int64 = 256 << 20
|
||||
|
||||
var exportFilenameUnsafe = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
|
||||
|
||||
func (a *App) handleExportMail(w http.ResponseWriter, r *http.Request) {
|
||||
ids, err := a.exportMessageIDs(r)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
respondError(w, http.StatusNotFound, "mailbox or label not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, errSystemAdminRequired) {
|
||||
respondError(w, http.StatusForbidden, "system admin required")
|
||||
return
|
||||
}
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("mail-export-%s.zip", a.now().UTC().Format("20060102-150405"))
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
zw := zip.NewWriter(w)
|
||||
usedNames := make(map[string]int, len(ids))
|
||||
for index, id := range ids {
|
||||
raw, subject, err := a.rawMessageForExport(r.Context(), id)
|
||||
if err != nil {
|
||||
_ = zw.Close()
|
||||
return
|
||||
}
|
||||
entryName := uniqueExportFilename(exportMessageFilename(subject, id, index), usedNames)
|
||||
entry, err := zw.CreateHeader(&zip.FileHeader{Name: entryName, Method: zip.Deflate})
|
||||
if err != nil {
|
||||
_ = zw.Close()
|
||||
return
|
||||
}
|
||||
if _, err := entry.Write(raw); err != nil {
|
||||
_ = zw.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = zw.Close()
|
||||
}
|
||||
|
||||
var errSystemAdminRequired = errors.New("system admin required")
|
||||
|
||||
func (a *App) exportMessageIDs(r *http.Request) ([]string, error) {
|
||||
user := currentUser(r)
|
||||
if user == nil {
|
||||
return nil, errors.New("no user")
|
||||
}
|
||||
view := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("view")))
|
||||
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
|
||||
where := []string{}
|
||||
args := []any{}
|
||||
|
||||
if view == "unknown" {
|
||||
if user.Role != "admin" {
|
||||
return nil, errSystemAdminRequired
|
||||
}
|
||||
where = append(where, "m.mailbox_id IS NULL")
|
||||
} else {
|
||||
where = append(where, "EXISTS (SELECT 1 FROM mailboxes owner_mb WHERE owner_mb.id=m.mailbox_id AND owner_mb.user_id=? AND owner_mb.status='active')")
|
||||
args = append(args, user.ID)
|
||||
if mailboxID != "" && !isAllMailboxID(mailboxID) {
|
||||
if _, err := a.mailboxForCurrentUserWithID(r, mailboxID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
where = append(where, "m.mailbox_id=?")
|
||||
args = append(args, mailboxID)
|
||||
}
|
||||
switch view {
|
||||
case "", "folder":
|
||||
folder := strings.TrimSpace(r.URL.Query().Get("folder"))
|
||||
if folder == "" {
|
||||
folder = "Inbox"
|
||||
}
|
||||
normalized, err := normalizeFolderNameForUser(folder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
where = append(where, "f.name=?")
|
||||
args = append(args, normalized)
|
||||
case "starred":
|
||||
where = append(where, "m.is_starred=1")
|
||||
case "label":
|
||||
labelID := strings.TrimSpace(r.URL.Query().Get("labelId"))
|
||||
if labelID == "" || !a.labelBelongsToUser(r.Context(), labelID, user.ID) {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
where = append(where, "EXISTS (SELECT 1 FROM message_labels ml WHERE ml.message_id=m.id AND ml.label_id=?)")
|
||||
args = append(args, labelID)
|
||||
default:
|
||||
return nil, errors.New("unsupported mail view")
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT m.id FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE `+strings.Join(where, " AND ")+` ORDER BY m.received_at DESC,m.id`, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
ids := []string{}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
func (a *App) rawMessageForExport(ctx context.Context, id string) ([]byte, string, error) {
|
||||
msg, err := a.storedMessageByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if msg.RawPath != "" {
|
||||
if ok, pathErr := a.pathIsUnderMaildirRoot(msg.RawPath); pathErr == nil && ok {
|
||||
if raw, readErr := os.ReadFile(msg.RawPath); readErr == nil {
|
||||
return raw, msg.Subject, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
attachments, err := a.attachmentInputsForMessage(ctx, id)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
raw, err := BuildMIME(MIMEMessage{
|
||||
From: msg.From, FromName: msg.FromName, To: msg.To, CC: msg.CC, BCC: msg.BCC,
|
||||
Subject: msg.Subject, Text: msg.BodyText, HTML: msg.BodyHTML, MessageID: msg.MessageID,
|
||||
Date: messageDate(msg), Attachments: attachments,
|
||||
})
|
||||
return raw, msg.Subject, err
|
||||
}
|
||||
|
||||
func exportMessageFilename(subject, id string, index int) string {
|
||||
name := exportFilenameUnsafe.ReplaceAllString(strings.TrimSpace(subject), "-")
|
||||
name = strings.Trim(name, ".-_")
|
||||
if name == "" {
|
||||
name = "message"
|
||||
}
|
||||
if len(name) > 80 {
|
||||
name = name[:80]
|
||||
}
|
||||
return fmt.Sprintf("%04d-%s-%s.eml", index+1, name, id)
|
||||
}
|
||||
|
||||
func uniqueExportFilename(name string, used map[string]int) string {
|
||||
used[name]++
|
||||
if used[name] == 1 {
|
||||
return name
|
||||
}
|
||||
base := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
return fmt.Sprintf("%s-%d%s", base, used[name], filepath.Ext(name))
|
||||
}
|
||||
|
||||
func (a *App) handleImportMail(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxMailImportBytes)
|
||||
if err := r.ParseMultipartForm(maxMailImportBytes); err != nil {
|
||||
respondError(w, http.StatusRequestEntityTooLarge, "import is too large")
|
||||
return
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
}
|
||||
mb, err := a.mailboxForCurrentUserWithID(r, r.FormValue("mailboxId"))
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
folderName := strings.TrimSpace(r.FormValue("folder"))
|
||||
if folderName == "" {
|
||||
folderName = "Inbox"
|
||||
}
|
||||
folderName, err = normalizeFolderNameForUser(folderName)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
folderID, err := a.ensureFolder(r.Context(), mb.ID, folderName)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load folder")
|
||||
return
|
||||
}
|
||||
files := r.MultipartForm.File["files"]
|
||||
if len(files) == 0 {
|
||||
files = r.MultipartForm.File["file"]
|
||||
}
|
||||
if len(files) == 0 {
|
||||
badRequest(w, errors.New("at least one EML or MBOX file is required"))
|
||||
return
|
||||
}
|
||||
|
||||
imported, skipped := 0, 0
|
||||
problems := []string{}
|
||||
maxMessageBytes := int64(a.cfg.SubmissionMaxMessageMB) * 1024 * 1024
|
||||
if maxMessageBytes <= 0 {
|
||||
maxMessageBytes = 35 * 1024 * 1024
|
||||
}
|
||||
for _, header := range files {
|
||||
messages, fileErr := readImportFile(header, maxMessageBytes)
|
||||
if fileErr != nil {
|
||||
skipped++
|
||||
problems = appendImportProblem(problems, fmt.Sprintf("%s: %v", header.Filename, fileErr))
|
||||
continue
|
||||
}
|
||||
for _, raw := range messages {
|
||||
if err := a.importRawMessage(r.Context(), mb, folderID, raw); err != nil {
|
||||
skipped++
|
||||
problems = appendImportProblem(problems, fmt.Sprintf("%s: %v", header.Filename, err))
|
||||
continue
|
||||
}
|
||||
imported++
|
||||
}
|
||||
}
|
||||
if imported == 0 && len(problems) > 0 {
|
||||
badRequest(w, errors.New(problems[0]))
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "imported": imported, "skipped": skipped, "errors": problems})
|
||||
}
|
||||
|
||||
func readImportFile(header *multipart.FileHeader, maxMessageBytes int64) ([][]byte, error) {
|
||||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||||
if ext != ".eml" && ext != ".mbox" {
|
||||
return nil, errors.New("only .eml and .mbox files are supported")
|
||||
}
|
||||
file, err := header.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
if ext == ".eml" {
|
||||
raw, err := io.ReadAll(io.LimitReader(file, maxMessageBytes+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(raw)) > maxMessageBytes {
|
||||
return nil, fmt.Errorf("message exceeds %d MB", maxMessageBytes/(1024*1024))
|
||||
}
|
||||
if len(bytes.TrimSpace(raw)) == 0 {
|
||||
return nil, errors.New("message is empty")
|
||||
}
|
||||
return [][]byte{raw}, nil
|
||||
}
|
||||
return parseMBOX(file, maxMessageBytes)
|
||||
}
|
||||
|
||||
func parseMBOX(reader io.Reader, maxMessageBytes int64) ([][]byte, error) {
|
||||
scanner := bufio.NewScanner(reader)
|
||||
bufferSize := int(maxMessageBytes + 1024)
|
||||
if bufferSize < 64*1024 {
|
||||
bufferSize = 64 * 1024
|
||||
}
|
||||
scanner.Buffer(make([]byte, 64*1024), bufferSize)
|
||||
var current bytes.Buffer
|
||||
messages := [][]byte{}
|
||||
seenSeparator := false
|
||||
flush := func() error {
|
||||
raw := bytes.TrimSpace(current.Bytes())
|
||||
current.Reset()
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
if int64(len(raw)) > maxMessageBytes {
|
||||
return fmt.Errorf("message exceeds %d MB", maxMessageBytes/(1024*1024))
|
||||
}
|
||||
messages = append(messages, append([]byte(nil), raw...))
|
||||
return nil
|
||||
}
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if bytes.HasPrefix(line, []byte("From ")) {
|
||||
if seenSeparator {
|
||||
if err := flush(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
seenSeparator = true
|
||||
continue
|
||||
}
|
||||
if bytes.HasPrefix(line, []byte(">From ")) {
|
||||
line = line[1:]
|
||||
}
|
||||
current.Write(line)
|
||||
current.WriteString("\r\n")
|
||||
if int64(current.Len()) > maxMessageBytes {
|
||||
return nil, fmt.Errorf("message exceeds %d MB", maxMessageBytes/(1024*1024))
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := flush(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return nil, errors.New("MBOX contains no messages")
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func (a *App) importRawMessage(ctx context.Context, mb *Mailbox, folderID string, raw []byte) error {
|
||||
msg, attachments, err := a.parseMaildirMessage(raw, mb.Address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid message: %w", err)
|
||||
}
|
||||
msg.MailboxID = mb.ID
|
||||
msg.FolderID = folderID
|
||||
msg.RecipientAddr = mb.Address
|
||||
msg.RawPath = ""
|
||||
id, err := a.insertMessage(ctx, msg, attachments)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.writeRawMessageToMaildir(ctx, id, raw, false); err != nil {
|
||||
a.deleteMessage(ctx, id)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendImportProblem(items []string, problem string) []string {
|
||||
if len(items) >= 5 {
|
||||
return items
|
||||
}
|
||||
return append(items, problem)
|
||||
}
|
||||
Reference in New Issue
Block a user