// 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)) } }) }