diff --git a/CLAUDE.md b/CLAUDE.md index 46b8d36..f3f441c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ gofmt -l . # список неотформатированных файл - `POST /api/import` — создание ящиков, **потоковый NDJSON** (по строке результата на ящик, с `flusher.Flush()`). Три конвейерных этапа обработки строки CSV, каждый меняет `Row.Status` -(`create`→`warn`→`exists`→`error`) и флаг `Importable`: +(`create`→`warn`→`exists`→`skip`→`error`) и флаг `Importable`: 1. `parseCSV` → `validateRow` — структурная проверка без сети (email, пароль, дубликаты в файле). 2. `enrichRow` — сверка с живым mailcow: несуществующий домен → `error`, существующий ящик → `exists` (пропуск). Работает только если заданы креды. @@ -60,6 +60,21 @@ gofmt -l . # список неотформатированных файл на практике используется `;`), снятие BOM, `LazyQuotes`, RU-алиасы заголовков (`headerAliases` + `normHeader` игнорирует регистр/пробелы/подчёркивания). `email` можно заменить парой `localpart`+`domain`. +- **Формат Kerio** (`isKerioHeader` + `kerioColumns`, `parseCSV` возвращает `format`): + экспорт Kerio Connect распознаётся по набору заголовков `kerioMarkers` + (`Name`+`FullName`+`MailAddress`+`DataSource`+`Authentication`) и разбирается + **отдельной таблицей колонок**, а не через `headerAliases`: у Kerio `Name` — это + логин, а `FullName` — отображаемое имя, т.е. ровно наоборот к алиасам. Маппинг: + `MailAddress`→логин (`localpart`, приоритет), `Name`→запасной логин (`email`), + `FullName`→`name`, **`Description`→`password`** (Kerio кладёт пароль туда), + `Enable`→`active`, `DiskSizeLimit (kB)`→квота с делением на 1024 (kB→МиБ), + `Role`→пропуск служебных учёток. Логины в экспорте **без домена**, поэтому + Kerio без выбранного домена в UI отклоняется с явной ошибкой. +- **Статус `skip`** — строка сознательно не мигрируется (`Importable=false`): + Kerio-роль содержит `admin` (консольная учётка, не почтовый ящик). `enrichRow` + такие строки не трогает, как и `error`. Отличается от `exists` (ящик уже в mailcow). +- `normActive` сводит `Yes/No`, `true/false`, `да/нет`, `1/0` к mailcow-флагу `active`; + пустое значение → `1`. Применяется к обоим форматам. - **Режим override-домена** (`parseCSV(..., overrideDomain)`): если домен выбран в UI и передан form-полем `domain`, колонка логина (`email`/`login`/`localpart`) трактуется как localpart без домена, email собирается как `localpart@overrideDomain`. @@ -79,7 +94,12 @@ gofmt -l . # список неотформатированных файл `importResults` — идемпотентно, т.к. упавшие ящики не создались, успешные не трогаются. При добавлении полей ящика править **три места синхронно**: заголовки/алиасы CSV -(`headerAliases`), структуру `Row` + `validateRow`, и `payload` в `handleImport`. +(`headerAliases`, при необходимости `kerioColumns`), структуру `Row` + `validateRow`, +и `payload` в `handleImport`. + +При добавлении нового статуса `Row.Status` править `index.html` в четырёх местах: +CSS `.pill.`/`.st.`, `labels` и список ключей в `renderPreview`, `statusText`. +Ключи `summary` в `handlePreview` должны совпадать со списком в `renderPreview`. ## Требования на стороне mailcow diff --git a/README.md b/README.md index a9a5cd0..4b88916 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ - **Проверка подключения** к API (домены/ящики читаются, чтобы подтвердить ключ и allow-list). - **Выбор домена**: после проверки подключения можно выбрать домен из списка mailcow — тогда в CSV достаточно логина без `@домена` (email собирается как `логин@выбранный_домен`). +- **Экспорт Kerio Connect** распознаётся автоматически — файл выгрузки пользователей + из старого сервера скармливается как есть, без ручной конвертации (см. «Формат CSV»). - **Предпросмотр**: разбор CSV, структурная валидация (email, пароль, дубликаты), плюс — если задан ключ — сверка с живым mailcow: пропуск существующих ящиков и отсев несуществующих доменов. @@ -47,7 +49,27 @@ petrov@amega.kz;An0therPass!;Пётр Петров | `password` | да | <8 символов → предупреждение | | `name` | нет | ФИО; по умолчанию = localpart | | `quota` | нет | МиБ; иначе «квота по умолчанию» | -| `active` | нет | `1`/`0`, по умолчанию `1` | +| `active` | нет | `1`/`0` (`yes`/`no`, `да`/`нет`), по умолчанию `1` | + +### Экспорт из Kerio Connect + +Файл выгрузки пользователей Kerio (`Name;FullName;Description;Enable;...`) +распознаётся автоматически по заголовкам — конвертировать его вручную не нужно. +При разборе такого файла предпросмотр пишет «формат: экспорт Kerio». + +Kerio экспортирует логины **без домена**, поэтому домен нужно выбрать в списке UI — +иначе файл будет отклонён с соответствующей ошибкой. Соответствие колонок: + +| колонка Kerio | → mailcow | примечание | +|-----------------------|--------------|--------------------------------------------------| +| `MailAddress` | логин | запасной вариант — `Name` | +| `Description` | `password` | Kerio хранит пароль именно здесь | +| `FullName` | `name` | пусто → mailcow подставит логин | +| `Enable` | `active` | `No` → ящик создаётся выключенным | +| `DiskSizeLimit (kB)` | `quota` | kB пересчитываются в МиБ; пусто → квота по умолчанию | +| `Role` | — | учётки с ролью `*admin` помечаются «служебная» и не импортируются | + +Остальные колонки выгрузки (`ConsumedSize`, `LastLogin`, `Groups` и прочие) игнорируются. ## Запуск diff --git a/index.html b/index.html index d42221b..9fb98ff 100644 --- a/index.html +++ b/index.html @@ -72,6 +72,7 @@ .pill .c{width:8px;height:8px;border-radius:50%} .pill.create .c{background:var(--create)} .pill.warn .c{background:var(--warn)} .pill.exists .c{background:var(--exists)} .pill.error .c{background:var(--error)} + .pill.skip .c{background:var(--exists)} .tablewrap{max-height:340px;overflow:auto;border:1px solid var(--border);border-radius:9px;margin-top:4px} table{width:100%;border-collapse:collapse;font-family:var(--mono);font-size:12px} thead th{position:sticky;top:0;background:var(--panel2);text-align:left;color:var(--muted); @@ -83,6 +84,7 @@ .st.create{color:var(--create)} .st.create::before{background:var(--create)} .st.warn{color:var(--warn)} .st.warn::before{background:var(--warn)} .st.exists{color:var(--exists)} .st.exists::before{background:var(--exists)} + .st.skip{color:var(--exists)} .st.skip::before{background:var(--exists)} .st.error{color:var(--error)} .st.error::before{background:var(--error)} .note{color:var(--muted);font-size:11.5px} .counts{display:flex;gap:20px;flex-wrap:wrap;margin-bottom:14px;font-family:var(--mono);font-size:13px} @@ -287,15 +289,16 @@ async function runPreview(){ previewRows = j.rows || []; renderPreview(j); const note = j.enriched ? 'проверено против mailcow (домены + дубликаты + квоты)' : (j.enrichNote||''); - setStatus($('#previewStatus'), j.enriched?'ok':'info', (j.enriched?'● ':'')+ 'строк: '+j.total+' · '+note); + const fmt = j.format === 'kerio' ? 'формат: экспорт Kerio (логин ← Name, пароль ← Description) · ' : ''; + setStatus($('#previewStatus'), j.enriched?'ok':'info', (j.enriched?'● ':'')+ 'строк: '+j.total+' · '+fmt+note); }catch(e){ setStatus($('#previewStatus'),'err','ошибка: '+e.message); } b.disabled = false; } function renderPreview(j){ const s = j.summary||{}; - const labels = {create:'к созданию', warn:'с предупреждением', exists:'уже есть', error:'ошибки'}; - $('#summary').innerHTML = ['create','warn','exists','error'].map(k=> + const labels = {create:'к созданию', warn:'с предупреждением', exists:'уже есть', skip:'служебные', error:'ошибки'}; + $('#summary').innerHTML = ['create','warn','exists','skip','error'].map(k=> `${labels[k]}: ${s[k]||0}`).join(''); const body = $('#prevBody'); body.innerHTML=''; previewRows.forEach(r=>{ @@ -335,12 +338,12 @@ function renderPreview(j){ $('#importHint').textContent = blocked ? 'создание заблокировано — сначала увеличьте квоту домена (см. предупреждение выше)' : importable - ? `будет создано ${importable} ящик(ов); строки "уже есть"/"ошибки" пропускаются` + ? `будет создано ${importable} ящик(ов); строки "уже есть"/"служебная"/"ошибки" пропускаются` : 'нет строк, пригодных к созданию'; $('#btnImport').textContent = importable ? `Создать ${importable} ящик(ов)` : 'Создать ящики'; } const gb = mib => mib >= 1024 ? (mib/1024).toFixed(mib%1024?1:0)+' ГБ' : mib+' МиБ'; -const statusText = s => ({create:'создать',warn:'создать (!)',exists:'пропуск',error:'ошибка'}[s]||s); +const statusText = s => ({create:'создать',warn:'создать (!)',exists:'пропуск',skip:'служебная',error:'ошибка'}[s]||s); const esc = s => String(s==null?'':s).replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])); // --- 03 import (streamed NDJSON) --- diff --git a/main.go b/main.go index 84a065e..c76c21b 100644 --- a/main.go +++ b/main.go @@ -161,9 +161,9 @@ func handlePreview(w http.ResponseWriter, r *http.Request) { defaultQuota = defaultQuotaMiB } overrideDomain := strings.ToLower(strings.TrimSpace(r.FormValue("domain"))) - rows, err := parseCSV(raw, defaultQuota, overrideDomain) + rows, format, err := parseCSV(raw, defaultQuota, overrideDomain) if err != nil { - writeJSON(w, 400, map[string]any{"error": err.Error()}) + writeJSON(w, 400, map[string]any{"error": err.Error(), "format": format}) return } @@ -194,7 +194,7 @@ func handlePreview(w http.ResponseWriter, r *http.Request) { enrichNote = "API-ключ не задан — показана только структурная валидация без проверки дублей и доменов" } - sum := map[string]int{"create": 0, "warn": 0, "exists": 0, "error": 0} + sum := map[string]int{"create": 0, "warn": 0, "exists": 0, "skip": 0, "error": 0} for _, rw := range rows { sum[rw.Status]++ } @@ -202,6 +202,7 @@ func handlePreview(w http.ResponseWriter, r *http.Request) { "rows": rows, "summary": sum, "total": len(rows), + "format": format, "enriched": enriched, "enrichNote": enrichNote, "quotaWarnings": quotaWarnings, @@ -307,25 +308,71 @@ var headerAliases = map[string]string{ "localpart": "localpart", "local": "localpart", } -func normHeader(s string) string { +// rawHeader normalizes a header for lookup without applying any aliasing. +func rawHeader(s string) string { s = strings.ToLower(strings.TrimSpace(s)) - s = strings.NewReplacer(" ", "", "_", "", "-", "").Replace(s) + return strings.NewReplacer(" ", "", "_", "", "-", "").Replace(s) +} + +func normHeader(s string) string { + s = rawHeader(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) { +// kerioMarkers — headers that together identify a Kerio Connect user export. +var kerioMarkers = []string{"name", "fullname", "mailaddress", "datasource", "authentication"} + +// kerioColumns maps raw Kerio export headers onto internal field keys. Kerio's +// "Name" is the login while "FullName" is the display name — the exact opposite +// of headerAliases — so the Kerio layout needs its own mapping instead of aliases. +// MailAddress lands on "localpart" and Name on "email" because parseCSV reads the +// login as firstNonEmpty(localpart, email): the real address wins, the account +// login is the fallback. Neither carries a domain — it comes from the UI selector. +var kerioColumns = map[string]string{ + "mailaddress": "localpart", + "name": "email", + "fullname": "name", + "description": "password", + "enable": "active", + "role": "kerioRole", + "disksizelimit(kb)": "kerioQuotaKB", +} + +func isKerioHeader(header []string) bool { + have := map[string]bool{} + for _, h := range header { + have[rawHeader(h)] = true + } + for _, m := range kerioMarkers { + if !have[m] { + return false + } + } + return true +} + +// normActive maps assorted truthy/falsy spellings (incl. Kerio's Yes/No) onto +// mailcow's active flag. +func normActive(s string) string { + switch strings.ToLower(strings.TrimSpace(s)) { + case "0", "no", "false", "n", "нет", "off", "disabled": + return "0" + } + return "1" +} + +// parseCSV разбирает CSV и возвращает распознанный формат ("generic" | "kerio"). +// Если overrideDomain задан, колонка логина (email/localpart) трактуется как +// localpart без домена, а домен берётся из overrideDomain — email собирается +// как localpart@overrideDomain. +func parseCSV(raw []byte, defaultQuota, overrideDomain string) ([]Row, string, error) { + format := "generic" raw = bytes.TrimPrefix(raw, []byte("\xef\xbb\xbf")) // strip UTF-8 BOM if len(bytes.TrimSpace(raw)) == 0 { - return nil, fmt.Errorf("файл пуст") + return nil, format, fmt.Errorf("файл пуст") } rd := csv.NewReader(bytes.NewReader(raw)) rd.Comma = sniffDelimiter(raw) @@ -335,30 +382,47 @@ func parseCSV(raw []byte, defaultQuota, overrideDomain string) ([]Row, error) { records, err := rd.ReadAll() if err != nil { - return nil, fmt.Errorf("ошибка разбора CSV: %v", err) + return nil, format, fmt.Errorf("ошибка разбора CSV: %v", err) } if len(records) < 2 { - return nil, fmt.Errorf("нужны строка заголовка и хотя бы одна строка данных") + return nil, format, fmt.Errorf("нужны строка заголовка и хотя бы одна строка данных") } idx := map[string]int{} - for i, h := range records[0] { - idx[normHeader(h)] = i + if isKerioHeader(records[0]) { + format = "kerio" + for i, h := range records[0] { + if key, ok := kerioColumns[rawHeader(h)]; ok { + idx[key] = i + } + } + // Kerio exports logins without a domain (DomainRestriction is contextual), + // so the target domain can only come from the UI selector. + if overrideDomain == "" { + return nil, format, fmt.Errorf("распознан экспорт Kerio: адреса в нём без домена — выберите домен в списке выше и повторите проверку") + } + } else { + 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], ", ")) + return nil, format, 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], ", ")) + return nil, format, fmt.Errorf("не найдена колонка email (или пара localpart+domain). Заголовки: %s", strings.Join(records[0], ", ")) } } if _, ok := idx["password"]; !ok { - return nil, fmt.Errorf("не найдена колонка password") + if format == "kerio" { + return nil, format, fmt.Errorf("распознан экспорт Kerio, но нет колонки Description — паролей в файле нет") + } + return nil, format, fmt.Errorf("не найдена колонка password") } get := func(rec []string, key string) string { @@ -374,7 +438,7 @@ func parseCSV(raw []byte, defaultQuota, overrideDomain string) ([]Row, error) { 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")} + r := Row{Line: n + 2, Notes: []string{}, Active: normActive(get(rec, "active"))} var email string if overrideDomain != "" { login := firstNonEmpty(get(rec, "localpart"), get(rec, "email")) @@ -398,15 +462,28 @@ func parseCSV(raw []byte, defaultQuota, overrideDomain string) ([]Row, error) { r.Name = get(rec, "name") r.Password = get(rec, "password") r.Quota = firstNonEmpty(get(rec, "quota"), defaultQuota) + if format == "kerio" { + if kb, err := strconv.Atoi(get(rec, "kerioQuotaKB")); err == nil && kb > 0 { + r.Quota = strconv.Itoa(kb / 1024) // Kerio exports kB, mailcow wants MiB + } + } validateRow(&r, seen) + if format == "kerio" && r.Active == "0" { + r.Notes = append(r.Notes, "учётка отключена в Kerio (Enable=No) — ящик будет создан неактивным") + } + // Kerio's admin accounts are console logins, not mailboxes worth migrating. + if role := get(rec, "kerioRole"); strings.Contains(strings.ToLower(role), "admin") { + r.Status, r.Importable = "skip", false + r.Notes = append(r.Notes, "служебная учётка Kerio (роль: "+role+") — пропущена") + } seen[r.Email] = true rows = append(rows, r) } if len(rows) == 0 { - return nil, fmt.Errorf("нет строк с данными") + return nil, format, fmt.Errorf("нет строк с данными") } - return rows, nil + return rows, format, nil } func validateRow(r *Row, seen map[string]bool) { @@ -447,8 +524,8 @@ func validateRow(r *Row, seen map[string]bool) { } func enrichRow(r *Row, domains, mailboxes map[string]bool) { - if r.Status == "error" { - return // structural error already blocks it + if r.Status == "error" || r.Status == "skip" { + return // structural error / deliberate skip already blocks it } if r.Domain != "" && !domains[r.Domain] { r.Status = "error"