This commit is contained in:
2026-07-14 13:21:45 +07:00
commit 4333d69125
8 changed files with 1375 additions and 0 deletions
+716
View File
@@ -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))
}
})
}