commit 4333d6912512026be3643c95147800fa924b1425 Author: Vassiliy Yegorov Date: Tue Jul 14 13:21:45 2026 +0700 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5e7302c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +.env +*.csv \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0ae414f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,87 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Что это + +`mcimport` — однобинарный инструмент (Go, только stdlib) для массового создания +почтовых ящиков в mailcow из CSV через его REST API. Веб-морда встроена в бинарь +(`//go:embed index.html`). Пайплайн пользователя: загрузка CSV → предпросмотр с +валидацией → потоковый импорт с журналом. + +## Команды + +```bash +# Сборка статического бинаря +CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o mcimport . + +# Локальный запуск (UI на http://localhost:8080) +MAILCOW_URL=https://mail.dom.ru ./mcimport + +# Docker (порт публикуется только на 127.0.0.1 — ходить через SSH-туннель) +docker compose up -d --build + +go vet ./... +gofmt -l . # список неотформатированных файлов; -w для правки +``` + +Тестов в проекте нет. `go.mod` не имеет внешних зависимостей — только stdlib, +поэтому `go mod tidy`/`go get` при обычной работе не требуются. + +## Архитектура + +Весь backend — один файл `main.go`, весь frontend — один `index.html` (встроен). +Состояние на сервере не хранится: инструмент **stateless**, API-ключ и пароли +не пишутся на диск и не логируются. + +Четыре HTTP-эндпоинта (`net/http` ServeMux с method-роутингом Go 1.22): + +- `GET /api/config` — отдаёт дефолты в UI (base URL, есть ли ключ в env, дефолт-квота). +- `GET /api/example` — отдаёт образец CSV (`exampleCSV`) как вложение для скачивания. +- `POST /api/test` — проверка связи: читает домены и ящики, подтверждает ключ и allow-list. +- `POST /api/preview` — разбор CSV (multipart), валидация, обогащение против живого mailcow. Form-поле `domain` включает режим override (см. ниже). В ответе — `quotaWarnings` (проверка доменной квоты). +- `POST /api/import` — создание ящиков, **потоковый NDJSON** (по строке результата на ящик, с `flusher.Flush()`). + +Три конвейерных этапа обработки строки CSV, каждый меняет `Row.Status` +(`create`→`warn`→`exists`→`error`) и флаг `Importable`: + +1. `parseCSV` → `validateRow` — структурная проверка без сети (email, пароль, дубликаты в файле). +2. `enrichRow` — сверка с живым mailcow: несуществующий домен → `error`, существующий ящик → `exists` (пропуск). Работает только если заданы креды. +3. `checkDomainQuota` — воспроизводит проверку mailcow: свободная квота домена = `max_quota_for_domain` − Σ(выделенных квот существующих ящиков). Если Σ квот импортируемых строк домена превышает свободную — возвращает `quotaWarn` (в МиБ). UI показывает предупреждение и **блокирует** импорт до подъёма квоты домена (домены с квотой 0 = безлимит пропускаются). +4. `handleImport` — POST на `/api/v1/add/mailbox`, ответ разбирается `parseMailcowMsg`. Используется **только** `add` — существующие ящики никогда не модифицируются (идемпотентность). + +### Ключевые соглашения + +- **Разрешение кредов** — `resolveCreds`: значения из тела запроса приоритетнее env + (`MAILCOW_URL`/`MAILCOW_API_KEY`). Ключ можно задать через env и не вводить в UI. +- **Ответы API mailcow** имеют форму `[{"type": "...", "msg": ...}]`; `type != "success"` + считается провалом. `msg` бывает строкой или массивом — разворачивается в `flattenMsg`. +- **CSV-парсинг** терпимый: авто-определение разделителя (`sniffDelimiter`: `,`/`;`/таб; + на практике используется `;`), снятие BOM, `LazyQuotes`, RU-алиасы заголовков + (`headerAliases` + `normHeader` игнорирует регистр/пробелы/подчёркивания). + `email` можно заменить парой `localpart`+`domain`. +- **Режим override-домена** (`parseCSV(..., overrideDomain)`): если домен выбран в UI + и передан form-полем `domain`, колонка логина (`email`/`login`/`localpart`) + трактуется как localpart без домена, email собирается как `localpart@overrideDomain`. + Полный email в значении терпится — берётся часть до `@` с пометкой в `Notes`. + Домен нормализуется в lowercase в `handlePreview`. +- Ошибки пользователю — **на русском**; комментарии в коде — на английском. Держать этот сплит. +- `INSECURE_SKIP_VERIFY=true` отключает проверку TLS (для внутреннего CA mailcow). + +- Дефолт квоты — константа `defaultQuotaMiB` (10240 МиБ). Одна точка правды; + дублируется в `docker-compose.yml`, `README.md` и `value` поля `#quota` в `index.html`. +- **Единицы квот**: `fetchDomains`/`fetchMailboxes` возвращают квоты в **байтах** + (как отдаёт mailcow), `Row.Quota` — в **МиБ** (строка). `checkDomainQuota` сводит + всё к байтам, `quotaWarn` наружу — в МиБ. UI форматирует МиБ→ГБ (`gb()`). +- **Повтор в UI без перезагрузки файла** (`index.html`): `previewRows`/`importResults` + держатся в памяти. «Повторить проверку» = повторный `runPreview()` (файл в `fileInput` + сохраняется). «Повторить неудавшиеся» = `runImport()` по строкам с `ok:false` из + `importResults` — идемпотентно, т.к. упавшие ящики не создались, успешные не трогаются. + +При добавлении полей ящика править **три места синхронно**: заголовки/алиасы CSV +(`headerAliases`), структуру `Row` + `validateRow`, и `payload` в `handleImport`. + +## Требования на стороне mailcow + +- API-ключ с правами **Read-Write** (`Configuration → Access → API`). +- IP, с которого ходит mcimport, добавлен в `API_ALLOW_FROM` — иначе отказ доступа (401/403). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6c5db6e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +# ---- build stage ---- +FROM golang:1.22-alpine AS build +RUN apk add --no-cache ca-certificates +WORKDIR /src +COPY go.mod ./ +COPY main.go index.html ./ +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /mcimport . + +# ---- runtime stage ---- +FROM scratch +# CA bundle so HTTPS to mailcow (Let's Encrypt) validates +COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=build /mcimport /mcimport +EXPOSE 8080 +USER 65534:65534 +ENTRYPOINT ["/mcimport"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..88eeee1 --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ +# mcimport — массовое создание ящиков mailcow из CSV + +Однобинарный инструмент (stdlib-only Go, embedded веб-морда) для провижининга +почтовых ящиков mailcow через API. Импорт файла → предпросмотр с валидацией → +потоковый журнал создания. + +## Возможности + +- **Проверка подключения** к API (домены/ящики читаются, чтобы подтвердить ключ и allow-list). +- **Выбор домена**: после проверки подключения можно выбрать домен из списка mailcow — + тогда в CSV достаточно логина без `@домена` (email собирается как `логин@выбранный_домен`). +- **Предпросмотр**: разбор CSV, структурная валидация (email, пароль, дубликаты), + плюс — если задан ключ — сверка с живым mailcow: пропуск существующих ящиков и + отсев несуществующих доменов. +- **Импорт** с потоковым (NDJSON) журналом в реальном времени: по строке на ящик, + разбор ответа mailcow (`success`/`danger`), счётчики, выгрузка лога в CSV. + +## Формат CSV + +Заголовок обязателен. Разделитель — точка с запятой (`;`); также распознаются +`,` и таб. BOM снимается, кодировка — UTF-8. Готовый образец можно скачать в UI +(ссылка «Скачать пример CSV») или по `GET /api/example`. + +С выбранным в UI доменом достаточно логина: + +```csv +login;password;name +ivanov;S3cretPass!;Иван Иванов +petrov;An0therPass!;Пётр Петров +``` + +Без выбора домена — полный email в каждой строке: + +```csv +email;password;name +ivanov@bildme.ru;S3cretPass!;Иван Иванов +petrov@amega.kz;An0therPass!;Пётр Петров +``` + +Колонки (регистр/пробелы/подчёркивания не важны, есть RU-алиасы): + +| колонка | обяз. | примечание | +|----------------------|-------|--------------------------------------------------------| +| `email` / `login` | да* | при выбранном домене — логин без `@`; иначе полный email или пара `localpart` + `domain` | +| `password` | да | <8 символов → предупреждение | +| `name` | нет | ФИО; по умолчанию = localpart | +| `quota` | нет | МиБ; иначе «квота по умолчанию» | +| `active` | нет | `1`/`0`, по умолчанию `1` | + +## Запуск + +### Бинарь +```bash +CGO_ENABLED=0 go build -ldflags="-s -w" -o mcimport . +MAILCOW_URL=https://mail.dom.ru ./mcimport +# открыть http://localhost:8080 +``` + +### Docker +```bash +docker compose up -d --build +# морда на 127.0.0.1:8080 — ходите через SSH-туннель: +# ssh -L 8080:127.0.0.1:8080 user@host +``` + +## Переменные окружения + +| переменная | по умолчанию | назначение | +|------------------------|-------------------------|---------------------------------------------------| +| `MAILCOW_URL` | — | базовый URL (можно ввести в UI) | +| `MAILCOW_API_KEY` | — | Read-Write ключ; если задан — не вводится в UI | +| `DEFAULT_QUOTA` | `10240` | квота по умолчанию, МиБ | +| `LISTEN` | `:8080` | адрес прослушивания | +| `INSECURE_SKIP_VERIFY` | `false` | отключить проверку TLS (внутренний CA) | + +## Что нужно на стороне mailcow + +1. UI → `Configuration → Access → Edit administrator details → API`: + создать ключ с правами **Read-Write**. +2. В `API_ALLOW_FROM` добавить IP, с которого mcimport ходит в API + (egress-IP контейнера/хоста). Иначе — отказ доступа. +3. Убедиться, что лимит «Максимум почтовых ящиков» у домена и квоты домена + покрывают импортируемую пачку. + +## Общая квота домена (частая ошибка `mailbox_quota_left_exceeded`) + +mailcow не даст создать ящик, если `Σ выделенных квот ящиков домена` превысит +**Domain quota** (общую квоту домена). Это не про квоту одного ящика — ставить +её в `0` (безлимит) не нужно. Предпросмотр (при заданном ключе) заранее считает +нехватку и **останавливает** импорт с подсказкой, до какого значения поднять +Domain quota. + +Поднять квоту домена: mailcow UI → домен → «Domain quota», либо один вызов API: + +```bash +curl -sk -X POST https://mail.dom.ru/api/v1/edit/domain \ + -H "X-API-Key: $KEY" -H "Content-Type: application/json" \ + -d '{"items":["a27.kz"],"attr":{"quota":"307200"}}' # 300 ГБ (МиБ) +``` + +После правки — в UI кнопка **«Повторить проверку»** (перепроверить без загрузки +файла) или **«Повторить неудавшиеся»** в журнале (досоздать только упавшие ящики). + +## Замечания по безопасности + +- Инструмент **stateless**: ключ и пароли нигде не пишутся на диск и не логируются. + Пароли из CSV во время предпросмотра проходят через браузер (внутренний тул) — + запускайте только в доверенной сети / за туннелем, не публикуйте наружу. +- Публикация порта в примере — только на `127.0.0.1`. +- Слабые пароли mailcow отклонит по политике; такие строки видны в предпросмотре + как предупреждение и в журнале как `danger: password_complexity`. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6d60d9f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +services: + mcimport: + build: . + # image: mcimport:latest # или свой pushed-образ + container_name: mcimport + restart: unless-stopped + ports: + - "8080:8080" # доступ только с localhost; ходите через SSH-туннель + environment: + MAILCOW_URL: "https://amail.amega.kz" + # Ключ можно задать здесь и не вводить в UI (поле останется с плейсхолдером): + MAILCOW_API_KEY: ${MAILCOW_API_KEY} + DEFAULT_QUOTA: "10240" + # INSECURE_SKIP_VERIFY: "true" # только если у mailcow самоподписанный/внутренний CA + LISTEN: ":8080" + + # Вариант «внутри сети mailcow» — тогда MAILCOW_URL можно указать + # http://nginx-mailcow:8080 и добавить IP этого контейнера в API_ALLOW_FROM. + # networks: + # - mailcowdockerized_mailcow-network + +# networks: +# mailcowdockerized_mailcow-network: +# external: true diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f002fe2 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module mcimport + +go 1.22 diff --git a/index.html b/index.html new file mode 100644 index 0000000..d42221b --- /dev/null +++ b/index.html @@ -0,0 +1,415 @@ + + + + + +mailcow · массовое создание ящиков + + + +
+
+

mailcow · bulk provisioning

+

импорт почтовых ящиков из CSV через API

+
+ + +
+
01

Подключение к API

+
+
+ + +
+
+ + +
+
+
+
+
+
Ключ создаётся в UI: Configuration → Access → Edit administrator details → API. Не забудьте добавить IP этого сервиса в allow-list.
+
+
+
+ + +
+
02

Файл и предпросмотр

+
+ Перетащите CSV сюда или нажмите для выбора +
колонки: login, password, name (опц. quota, active) — разделитель ; (также , или tab)
+
+
+
Не знаете формат? Скачать пример CSV и заполнить своими данными.
+
+
+ + +
Выберите домен → в файле указывайте только логин без @домена. Список доменов появится после проверки подключения.
+
+
+ +
+
+ + +
+
+ +
+
+
+
+ +
+
+
+
+ + + +
#emailимяквотастатуспримечание
+
+
+ + +
+
+
+ + +
+
03

Журнал провижининга

+
+
0создано
+
0ошибок
+
0из всего
+
+
+
+
+ +
+ + +
+
+
+
+ + + + diff --git a/main.go b/main.go new file mode 100644 index 0000000..2e91fea --- /dev/null +++ b/main.go @@ -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)) + } + }) +}