717 lines
22 KiB
Go
717 lines
22 KiB
Go
// mcimport — bulk mailbox provisioning for mailcow from a CSV file.
|
||
//
|
||
// Single static binary, stdlib only, embedded web UI.
|
||
// Endpoints:
|
||
//
|
||
// GET / -> embedded UI
|
||
// GET /api/config -> defaults (base URL, whether an env API key is set)
|
||
// POST /api/test -> verify API connectivity and read domains/mailboxes
|
||
// POST /api/preview -> parse CSV, validate, enrich against mailcow state
|
||
// POST /api/import -> create mailboxes, streaming NDJSON per-row results
|
||
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/tls"
|
||
"embed"
|
||
"encoding/csv"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"os"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
//go:embed index.html
|
||
var uiFS embed.FS
|
||
|
||
var httpClient = &http.Client{Timeout: 25 * time.Second}
|
||
|
||
// defaultQuotaMiB — квота по умолчанию в МиБ, если не задана в env/CSV/UI.
|
||
const defaultQuotaMiB = "10240"
|
||
|
||
// exampleCSV — образец заполнения, отдаётся по GET /api/example.
|
||
// Разделитель — точка с запятой; колонка login используется без домена
|
||
// (домен выбирается в UI). Полный email тоже допустим, если домен не выбран.
|
||
const exampleCSV = "login;password;name;quota;active\r\n" +
|
||
"ivanov;S3cretPass!;Иван Иванов;10240;1\r\n" +
|
||
"petrov;An0therPass!;Пётр Петров;;1\r\n"
|
||
|
||
func main() {
|
||
if os.Getenv("INSECURE_SKIP_VERIFY") == "true" {
|
||
httpClient.Transport = &http.Transport{
|
||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||
}
|
||
log.Println("WARNING: TLS verification disabled (INSECURE_SKIP_VERIFY=true)")
|
||
}
|
||
|
||
mux := http.NewServeMux()
|
||
mux.HandleFunc("GET /", handleIndex)
|
||
mux.HandleFunc("GET /api/config", handleConfig)
|
||
mux.HandleFunc("GET /api/example", handleExample)
|
||
mux.HandleFunc("POST /api/test", handleTest)
|
||
mux.HandleFunc("POST /api/preview", handlePreview)
|
||
mux.HandleFunc("POST /api/import", handleImport)
|
||
|
||
addr := envOr("LISTEN", ":8080")
|
||
log.Printf("mcimport listening on %s", addr)
|
||
log.Fatal(http.ListenAndServe(addr, logRequests(mux)))
|
||
}
|
||
|
||
// ---------- handlers ----------
|
||
|
||
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
||
b, _ := uiFS.ReadFile("index.html")
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
w.Write(b)
|
||
}
|
||
|
||
func handleConfig(w http.ResponseWriter, r *http.Request) {
|
||
writeJSON(w, 200, map[string]any{
|
||
"baseUrl": os.Getenv("MAILCOW_URL"),
|
||
"hasEnvKey": os.Getenv("MAILCOW_API_KEY") != "",
|
||
"defaultQuota": envOr("DEFAULT_QUOTA", defaultQuotaMiB),
|
||
})
|
||
}
|
||
|
||
func handleExample(w http.ResponseWriter, r *http.Request) {
|
||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||
w.Header().Set("Content-Disposition", `attachment; filename="mcimport-example.csv"`)
|
||
io.WriteString(w, "\xef\xbb\xbf"+exampleCSV) // BOM — чтобы Excel открыл кириллицу в UTF-8
|
||
}
|
||
|
||
type credReq struct {
|
||
BaseURL string `json:"baseUrl"`
|
||
APIKey string `json:"apiKey"`
|
||
}
|
||
|
||
func handleTest(w http.ResponseWriter, r *http.Request) {
|
||
var req credReq
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeJSON(w, 400, map[string]any{"ok": false, "message": "некорректный запрос"})
|
||
return
|
||
}
|
||
base, key := resolveCreds(req.BaseURL, req.APIKey)
|
||
if base == "" || key == "" {
|
||
writeJSON(w, 200, map[string]any{"ok": false, "message": "не заданы URL или API-ключ"})
|
||
return
|
||
}
|
||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||
defer cancel()
|
||
|
||
domains, err := fetchDomains(ctx, base, key)
|
||
if err != nil {
|
||
writeJSON(w, 200, map[string]any{"ok": false, "message": err.Error()})
|
||
return
|
||
}
|
||
mbx, _ := fetchMailboxes(ctx, base, key)
|
||
writeJSON(w, 200, map[string]any{
|
||
"ok": true,
|
||
"message": fmt.Sprintf("подключение успешно — доменов: %d, ящиков: %d", len(domains), len(mbx)),
|
||
"domains": domainNames(domains),
|
||
"mailboxes": len(mbx),
|
||
})
|
||
}
|
||
|
||
// Row is one normalized CSV record after validation/enrichment.
|
||
type Row struct {
|
||
Line int `json:"line"`
|
||
Email string `json:"email"`
|
||
LocalPart string `json:"localPart"`
|
||
Domain string `json:"domain"`
|
||
Name string `json:"name"`
|
||
Password string `json:"password"`
|
||
Quota string `json:"quota"`
|
||
Active string `json:"active"`
|
||
Status string `json:"status"` // create | warn | exists | error
|
||
Notes []string `json:"notes"`
|
||
Importable bool `json:"importable"`
|
||
}
|
||
|
||
// quotaWarn — предупреждение о нехватке общей квоты домена (все значения в МиБ).
|
||
type quotaWarn struct {
|
||
Domain string `json:"domain"`
|
||
DomainMiB int64 `json:"domainMiB"` // общая квота домена
|
||
UsedMiB int64 `json:"usedMiB"` // выделено существующим ящикам
|
||
FreeMiB int64 `json:"freeMiB"` // свободно = domain − used
|
||
NeededMiB int64 `json:"neededMiB"` // нужно для импортируемых строк
|
||
SuggestMiB int64 `json:"suggestMiB"` // до какого значения поднять квоту домена
|
||
}
|
||
|
||
func handlePreview(w http.ResponseWriter, r *http.Request) {
|
||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||
writeJSON(w, 400, map[string]any{"error": "не удалось прочитать форму"})
|
||
return
|
||
}
|
||
file, _, err := r.FormFile("file")
|
||
if err != nil {
|
||
writeJSON(w, 400, map[string]any{"error": "файл не передан"})
|
||
return
|
||
}
|
||
defer file.Close()
|
||
raw, _ := io.ReadAll(io.LimitReader(file, 32<<20))
|
||
|
||
defaultQuota := strings.TrimSpace(r.FormValue("defaultQuota"))
|
||
if defaultQuota == "" {
|
||
defaultQuota = defaultQuotaMiB
|
||
}
|
||
overrideDomain := strings.ToLower(strings.TrimSpace(r.FormValue("domain")))
|
||
rows, err := parseCSV(raw, defaultQuota, overrideDomain)
|
||
if err != nil {
|
||
writeJSON(w, 400, map[string]any{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
// Enrich against live mailcow state when credentials are available.
|
||
enriched, enrichNote := false, ""
|
||
quotaWarnings := []quotaWarn{}
|
||
base, key := resolveCreds(r.FormValue("baseUrl"), r.FormValue("apiKey"))
|
||
if base != "" && key != "" {
|
||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||
defer cancel()
|
||
domains, derr := fetchDomains(ctx, base, key)
|
||
mbx, merr := fetchMailboxes(ctx, base, key)
|
||
if derr == nil && merr == nil {
|
||
enriched = true
|
||
domSet := toSet(domainNames(domains))
|
||
mbxSet := map[string]bool{}
|
||
for _, m := range mbx {
|
||
mbxSet[m.username] = true
|
||
}
|
||
for i := range rows {
|
||
enrichRow(&rows[i], domSet, mbxSet)
|
||
}
|
||
quotaWarnings = checkDomainQuota(rows, domains, mbx)
|
||
} else {
|
||
enrichNote = "проверка против mailcow пропущена (нет связи с API) — только структурная валидация"
|
||
}
|
||
} else {
|
||
enrichNote = "API-ключ не задан — показана только структурная валидация без проверки дублей и доменов"
|
||
}
|
||
|
||
sum := map[string]int{"create": 0, "warn": 0, "exists": 0, "error": 0}
|
||
for _, rw := range rows {
|
||
sum[rw.Status]++
|
||
}
|
||
writeJSON(w, 200, map[string]any{
|
||
"rows": rows,
|
||
"summary": sum,
|
||
"total": len(rows),
|
||
"enriched": enriched,
|
||
"enrichNote": enrichNote,
|
||
"quotaWarnings": quotaWarnings,
|
||
})
|
||
}
|
||
|
||
type importReq struct {
|
||
BaseURL string `json:"baseUrl"`
|
||
APIKey string `json:"apiKey"`
|
||
ForcePwUpdate bool `json:"forcePwUpdate"`
|
||
Rows []Row `json:"rows"`
|
||
}
|
||
|
||
// result is one streamed NDJSON line during import.
|
||
type result struct {
|
||
Index int `json:"index"`
|
||
Total int `json:"total"`
|
||
Email string `json:"email"`
|
||
OK bool `json:"ok"`
|
||
HTTPCode int `json:"httpCode"`
|
||
Message string `json:"message"`
|
||
TS string `json:"ts"`
|
||
}
|
||
|
||
func handleImport(w http.ResponseWriter, r *http.Request) {
|
||
var req importReq
|
||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||
writeJSON(w, 400, map[string]any{"error": "некорректный запрос"})
|
||
return
|
||
}
|
||
base, key := resolveCreds(req.BaseURL, req.APIKey)
|
||
if base == "" || key == "" {
|
||
writeJSON(w, 400, map[string]any{"error": "не заданы URL или API-ключ"})
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||
w.Header().Set("Cache-Control", "no-store")
|
||
flusher, _ := w.(http.Flusher)
|
||
enc := json.NewEncoder(w)
|
||
|
||
force := "0"
|
||
if req.ForcePwUpdate {
|
||
force = "1"
|
||
}
|
||
total := len(req.Rows)
|
||
for i, rw := range req.Rows {
|
||
active := rw.Active
|
||
if active == "" {
|
||
active = "1"
|
||
}
|
||
payload := map[string]string{
|
||
"active": active,
|
||
"domain": rw.Domain,
|
||
"local_part": rw.LocalPart,
|
||
"name": firstNonEmpty(rw.Name, rw.LocalPart),
|
||
"password": rw.Password,
|
||
"password2": rw.Password,
|
||
"quota": rw.Quota,
|
||
"force_pw_update": force,
|
||
"tls_enforce_in": "0",
|
||
"tls_enforce_out": "0",
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||
code, body, err := mcDo(ctx, base, key, "POST", "/api/v1/add/mailbox", payload)
|
||
cancel()
|
||
|
||
res := result{Index: i + 1, Total: total, Email: rw.Email, HTTPCode: code, TS: time.Now().Format("15:04:05")}
|
||
if err != nil {
|
||
res.OK, res.Message = false, "ошибка запроса: "+err.Error()
|
||
} else {
|
||
ok, msg := parseMailcowMsg(body)
|
||
res.OK, res.Message = ok, msg
|
||
}
|
||
enc.Encode(res)
|
||
if flusher != nil {
|
||
flusher.Flush()
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------- CSV parsing ----------
|
||
|
||
var headerAliases = map[string]string{
|
||
"email": "email", "e-mail": "email", "mail": "email", "почта": "email",
|
||
"адрес": "email", "username": "email", "user": "email",
|
||
"login": "email", "логин": "email",
|
||
"password": "password", "pass": "password", "пароль": "password", "pwd": "password",
|
||
"name": "name", "fullname": "name", "displayname": "name", "имя": "name", "фио": "name",
|
||
"quota": "quota", "квота": "quota",
|
||
"active": "active", "активен": "active",
|
||
"domain": "domain", "домен": "domain",
|
||
"localpart": "localpart", "local": "localpart",
|
||
}
|
||
|
||
func normHeader(s string) string {
|
||
s = strings.ToLower(strings.TrimSpace(s))
|
||
s = strings.NewReplacer(" ", "", "_", "", "-", "").Replace(s)
|
||
if v, ok := headerAliases[s]; ok {
|
||
return v
|
||
}
|
||
if v, ok := headerAliases[strings.TrimSpace(strings.ToLower(s))]; ok {
|
||
return v
|
||
}
|
||
return s
|
||
}
|
||
|
||
// parseCSV разбирает CSV. Если overrideDomain задан, колонка логина
|
||
// (email/localpart) трактуется как localpart без домена, а домен берётся
|
||
// из overrideDomain — email собирается как localpart@overrideDomain.
|
||
func parseCSV(raw []byte, defaultQuota, overrideDomain string) ([]Row, error) {
|
||
raw = bytes.TrimPrefix(raw, []byte("\xef\xbb\xbf")) // strip UTF-8 BOM
|
||
if len(bytes.TrimSpace(raw)) == 0 {
|
||
return nil, fmt.Errorf("файл пуст")
|
||
}
|
||
rd := csv.NewReader(bytes.NewReader(raw))
|
||
rd.Comma = sniffDelimiter(raw)
|
||
rd.FieldsPerRecord = -1
|
||
rd.LazyQuotes = true
|
||
rd.TrimLeadingSpace = true
|
||
|
||
records, err := rd.ReadAll()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("ошибка разбора CSV: %v", err)
|
||
}
|
||
if len(records) < 2 {
|
||
return nil, fmt.Errorf("нужны строка заголовка и хотя бы одна строка данных")
|
||
}
|
||
|
||
idx := map[string]int{}
|
||
for i, h := range records[0] {
|
||
idx[normHeader(h)] = i
|
||
}
|
||
_, hasEmail := idx["email"]
|
||
_, hasLocal := idx["localpart"]
|
||
if overrideDomain != "" {
|
||
if !hasEmail && !hasLocal {
|
||
return nil, fmt.Errorf("не найдена колонка с логином (email/login или localpart). Заголовки: %s", strings.Join(records[0], ", "))
|
||
}
|
||
} else if !hasEmail {
|
||
_, hasDomain := idx["domain"]
|
||
if !(hasLocal && hasDomain) {
|
||
return nil, fmt.Errorf("не найдена колонка email (или пара localpart+domain). Заголовки: %s", strings.Join(records[0], ", "))
|
||
}
|
||
}
|
||
if _, ok := idx["password"]; !ok {
|
||
return nil, fmt.Errorf("не найдена колонка password")
|
||
}
|
||
|
||
get := func(rec []string, key string) string {
|
||
if i, ok := idx[key]; ok && i < len(rec) {
|
||
return strings.TrimSpace(rec[i])
|
||
}
|
||
return ""
|
||
}
|
||
|
||
var rows []Row
|
||
seen := map[string]bool{}
|
||
for n, rec := range records[1:] {
|
||
if len(rec) == 0 || strings.TrimSpace(strings.Join(rec, "")) == "" {
|
||
continue // skip blank lines
|
||
}
|
||
r := Row{Line: n + 2, Notes: []string{}, Active: firstNonEmpty(get(rec, "active"), "1")}
|
||
var email string
|
||
if overrideDomain != "" {
|
||
login := firstNonEmpty(get(rec, "localpart"), get(rec, "email"))
|
||
if i := strings.IndexByte(login, '@'); i >= 0 {
|
||
login = login[:i] // терпим полный email — берём часть до @, домен из выбранного
|
||
r.Notes = append(r.Notes, "домен из значения проигнорирован — используется выбранный")
|
||
}
|
||
if login != "" {
|
||
email = login + "@" + overrideDomain
|
||
}
|
||
} else {
|
||
email = get(rec, "email")
|
||
if email == "" {
|
||
lp, dm := get(rec, "localpart"), get(rec, "domain")
|
||
if lp != "" && dm != "" {
|
||
email = lp + "@" + dm
|
||
}
|
||
}
|
||
}
|
||
r.Email = strings.ToLower(email)
|
||
r.Name = get(rec, "name")
|
||
r.Password = get(rec, "password")
|
||
r.Quota = firstNonEmpty(get(rec, "quota"), defaultQuota)
|
||
|
||
validateRow(&r, seen)
|
||
seen[r.Email] = true
|
||
rows = append(rows, r)
|
||
}
|
||
if len(rows) == 0 {
|
||
return nil, fmt.Errorf("нет строк с данными")
|
||
}
|
||
return rows, nil
|
||
}
|
||
|
||
func validateRow(r *Row, seen map[string]bool) {
|
||
r.Status = "create"
|
||
addErr := func(msg string) { r.Status = "error"; r.Notes = append(r.Notes, msg) }
|
||
addWarn := func(msg string) {
|
||
if r.Status == "create" {
|
||
r.Status = "warn"
|
||
}
|
||
r.Notes = append(r.Notes, msg)
|
||
}
|
||
|
||
at := strings.Count(r.Email, "@")
|
||
if r.Email == "" || at != 1 {
|
||
addErr("некорректный email")
|
||
} else {
|
||
parts := strings.SplitN(r.Email, "@", 2)
|
||
r.LocalPart, r.Domain = parts[0], parts[1]
|
||
if r.LocalPart == "" || r.Domain == "" {
|
||
addErr("некорректный email")
|
||
}
|
||
}
|
||
if seen[r.Email] && r.Email != "" {
|
||
addErr("дубликат в файле")
|
||
}
|
||
switch {
|
||
case r.Password == "":
|
||
addErr("пустой пароль")
|
||
case len(r.Password) < 8:
|
||
addWarn("короткий пароль — может отклонить политика mailcow")
|
||
}
|
||
if r.Quota != "" {
|
||
if _, err := strconv.Atoi(r.Quota); err != nil {
|
||
addWarn("квота не число — будет применён дефолт")
|
||
}
|
||
}
|
||
r.Importable = r.Status == "create" || r.Status == "warn"
|
||
}
|
||
|
||
func enrichRow(r *Row, domains, mailboxes map[string]bool) {
|
||
if r.Status == "error" {
|
||
return // structural error already blocks it
|
||
}
|
||
if r.Domain != "" && !domains[r.Domain] {
|
||
r.Status = "error"
|
||
r.Notes = append(r.Notes, "домен отсутствует в mailcow")
|
||
}
|
||
if mailboxes[r.Email] {
|
||
r.Status = "exists"
|
||
r.Notes = append(r.Notes, "ящик уже существует — будет пропущен")
|
||
}
|
||
r.Importable = r.Status == "create" || r.Status == "warn"
|
||
}
|
||
|
||
func sniffDelimiter(raw []byte) rune {
|
||
line := raw
|
||
if i := bytes.IndexByte(raw, '\n'); i >= 0 {
|
||
line = raw[:i]
|
||
}
|
||
counts := map[rune]int{',': bytes.Count(line, []byte(",")), ';': bytes.Count(line, []byte(";")), '\t': bytes.Count(line, []byte("\t"))}
|
||
best, bestN := ',', -1
|
||
for d, n := range counts {
|
||
if n > bestN {
|
||
best, bestN = d, n
|
||
}
|
||
}
|
||
return best
|
||
}
|
||
|
||
// ---------- mailcow API ----------
|
||
|
||
func mcDo(ctx context.Context, base, key, method, path string, body any) (int, []byte, error) {
|
||
var rdr io.Reader
|
||
if body != nil {
|
||
b, _ := json.Marshal(body)
|
||
rdr = bytes.NewReader(b)
|
||
}
|
||
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(base, "/")+path, rdr)
|
||
if err != nil {
|
||
return 0, nil, err
|
||
}
|
||
req.Header.Set("X-API-Key", key)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Accept", "application/json")
|
||
resp, err := httpClient.Do(req)
|
||
if err != nil {
|
||
return 0, nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||
return resp.StatusCode, rb, nil
|
||
}
|
||
|
||
// domainRec — домен и его общая квота (max_quota_for_domain), в байтах; 0 = безлимит.
|
||
type domainRec struct {
|
||
name string
|
||
quota int64
|
||
}
|
||
|
||
// mailboxRec — существующий ящик с выделенной квотой (байты) и доменом.
|
||
type mailboxRec struct {
|
||
username string
|
||
domain string
|
||
quota int64
|
||
}
|
||
|
||
func fetchDomains(ctx context.Context, base, key string) ([]domainRec, error) {
|
||
code, body, err := mcDo(ctx, base, key, "GET", "/api/v1/get/domain/all", nil)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("нет связи с API: %v", err)
|
||
}
|
||
if code == 401 || code == 403 {
|
||
return nil, fmt.Errorf("отказ доступа (код %d) — проверьте API-ключ и allow-list IP", code)
|
||
}
|
||
if code != 200 {
|
||
return nil, fmt.Errorf("неожиданный ответ API (код %d)", code)
|
||
}
|
||
var arr []struct {
|
||
DomainName string `json:"domain_name"`
|
||
MaxQuotaDomain int64 `json:"max_quota_for_domain"` // байты
|
||
}
|
||
if err := json.Unmarshal(body, &arr); err != nil {
|
||
return nil, fmt.Errorf("не удалось разобрать список доменов (ключ без прав на чтение?)")
|
||
}
|
||
out := make([]domainRec, 0, len(arr))
|
||
for _, d := range arr {
|
||
if d.DomainName != "" {
|
||
out = append(out, domainRec{name: strings.ToLower(d.DomainName), quota: d.MaxQuotaDomain})
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func fetchMailboxes(ctx context.Context, base, key string) ([]mailboxRec, error) {
|
||
code, body, err := mcDo(ctx, base, key, "GET", "/api/v1/get/mailbox/all", nil)
|
||
if err != nil || code != 200 {
|
||
return nil, fmt.Errorf("не удалось получить ящики (код %d)", code)
|
||
}
|
||
var arr []struct {
|
||
Username string `json:"username"`
|
||
Domain string `json:"domain"`
|
||
Quota int64 `json:"quota"` // байты, выделенная квота
|
||
}
|
||
if err := json.Unmarshal(body, &arr); err != nil {
|
||
return nil, err
|
||
}
|
||
out := make([]mailboxRec, 0, len(arr))
|
||
for _, m := range arr {
|
||
if m.Username != "" {
|
||
out = append(out, mailboxRec{
|
||
username: strings.ToLower(m.Username),
|
||
domain: strings.ToLower(m.Domain),
|
||
quota: m.Quota,
|
||
})
|
||
}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// domainNames возвращает только имена доменов (для UI: chips + селект).
|
||
func domainNames(d []domainRec) []string {
|
||
out := make([]string, len(d))
|
||
for i, x := range d {
|
||
out[i] = x.name
|
||
}
|
||
return out
|
||
}
|
||
|
||
// checkDomainQuota воспроизводит проверку mailcow: свободная доменная квота =
|
||
// max_quota_for_domain − Σ(выделенных квот существующих ящиков домена). Если
|
||
// суммарная квота импортируемых строк домена превышает свободную — возвращает
|
||
// предупреждение. Домены с квотой 0 (безлимит) пропускаются.
|
||
func checkDomainQuota(rows []Row, domains []domainRec, mbx []mailboxRec) []quotaWarn {
|
||
domMax := map[string]int64{}
|
||
for _, d := range domains {
|
||
domMax[d.name] = d.quota
|
||
}
|
||
domUsed := map[string]int64{}
|
||
for _, m := range mbx {
|
||
domUsed[m.domain] += m.quota
|
||
}
|
||
domNeed := map[string]int64{}
|
||
for i := range rows {
|
||
if !rows[i].Importable {
|
||
continue
|
||
}
|
||
q, err := strconv.Atoi(rows[i].Quota)
|
||
if err != nil {
|
||
continue
|
||
}
|
||
domNeed[rows[i].Domain] += int64(q) << 20 // МиБ → байты
|
||
}
|
||
out := []quotaWarn{}
|
||
for dom, need := range domNeed {
|
||
max := domMax[dom]
|
||
if max <= 0 {
|
||
continue // безлимитный домен
|
||
}
|
||
used := domUsed[dom]
|
||
free := max - used
|
||
if free < 0 {
|
||
free = 0
|
||
}
|
||
if need > free {
|
||
out = append(out, quotaWarn{
|
||
Domain: dom,
|
||
DomainMiB: max >> 20,
|
||
UsedMiB: used >> 20,
|
||
FreeMiB: free >> 20,
|
||
NeededMiB: need >> 20,
|
||
SuggestMiB: (used + need) >> 20,
|
||
})
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// parseMailcowMsg decodes mailcow's [{ "type": "...", "msg": ... }] response.
|
||
func parseMailcowMsg(body []byte) (bool, string) {
|
||
var arr []struct {
|
||
Type string `json:"type"`
|
||
Msg json.RawMessage `json:"msg"`
|
||
}
|
||
if err := json.Unmarshal(body, &arr); err != nil || len(arr) == 0 {
|
||
s := strings.TrimSpace(string(body))
|
||
if s == "" {
|
||
s = "пустой ответ API"
|
||
}
|
||
return false, s
|
||
}
|
||
ok := true
|
||
var parts []string
|
||
for _, e := range arr {
|
||
if e.Type != "success" {
|
||
ok = false
|
||
}
|
||
parts = append(parts, e.Type+": "+flattenMsg(e.Msg))
|
||
}
|
||
return ok, strings.Join(parts, "; ")
|
||
}
|
||
|
||
func flattenMsg(raw json.RawMessage) string {
|
||
if len(raw) == 0 {
|
||
return ""
|
||
}
|
||
var s string
|
||
if json.Unmarshal(raw, &s) == nil {
|
||
return s
|
||
}
|
||
var arr []json.RawMessage
|
||
if json.Unmarshal(raw, &arr) == nil {
|
||
var out []string
|
||
for _, e := range arr {
|
||
var es string
|
||
if json.Unmarshal(e, &es) == nil {
|
||
out = append(out, es)
|
||
} else {
|
||
out = append(out, strings.Trim(string(e), `"`))
|
||
}
|
||
}
|
||
return strings.Join(out, " ")
|
||
}
|
||
return string(raw)
|
||
}
|
||
|
||
// ---------- helpers ----------
|
||
|
||
func resolveCreds(base, key string) (string, string) {
|
||
if strings.TrimSpace(base) == "" {
|
||
base = os.Getenv("MAILCOW_URL")
|
||
}
|
||
if strings.TrimSpace(key) == "" {
|
||
key = os.Getenv("MAILCOW_API_KEY")
|
||
}
|
||
return strings.TrimSpace(base), strings.TrimSpace(key)
|
||
}
|
||
|
||
func toSet(items []string) map[string]bool {
|
||
m := make(map[string]bool, len(items))
|
||
for _, s := range items {
|
||
m[s] = true
|
||
}
|
||
return m
|
||
}
|
||
|
||
func firstNonEmpty(vals ...string) string {
|
||
for _, v := range vals {
|
||
if strings.TrimSpace(v) != "" {
|
||
return v
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func envOr(k, def string) string {
|
||
if v := os.Getenv(k); v != "" {
|
||
return v
|
||
}
|
||
return def
|
||
}
|
||
|
||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
w.WriteHeader(code)
|
||
json.NewEncoder(w).Encode(v)
|
||
}
|
||
|
||
func logRequests(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
start := time.Now()
|
||
next.ServeHTTP(w, r)
|
||
if r.URL.Path != "/" {
|
||
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
|
||
}
|
||
})
|
||
}
|