init
This commit is contained in:
@@ -0,0 +1,716 @@
|
||||
// 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))
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user