add kerio format support

This commit is contained in:
2026-07-17 11:42:12 +07:00
parent ccd8e8eb1b
commit 11175b4e31
4 changed files with 155 additions and 33 deletions
+22 -2
View File
@@ -43,7 +43,7 @@ gofmt -l . # список неотформатированных файл
- `POST /api/import` — создание ящиков, **потоковый NDJSON** (по строке результата на ящик, с `flusher.Flush()`). - `POST /api/import` — создание ящиков, **потоковый NDJSON** (по строке результата на ящик, с `flusher.Flush()`).
Три конвейерных этапа обработки строки CSV, каждый меняет `Row.Status` Три конвейерных этапа обработки строки CSV, каждый меняет `Row.Status`
(`create``warn``exists``error`) и флаг `Importable`: (`create``warn``exists``skip``error`) и флаг `Importable`:
1. `parseCSV``validateRow` — структурная проверка без сети (email, пароль, дубликаты в файле). 1. `parseCSV``validateRow` — структурная проверка без сети (email, пароль, дубликаты в файле).
2. `enrichRow` — сверка с живым mailcow: несуществующий домен → `error`, существующий ящик → `exists` (пропуск). Работает только если заданы креды. 2. `enrichRow` — сверка с живым mailcow: несуществующий домен → `error`, существующий ящик → `exists` (пропуск). Работает только если заданы креды.
@@ -60,6 +60,21 @@ gofmt -l . # список неотформатированных файл
на практике используется `;`), снятие BOM, `LazyQuotes`, RU-алиасы заголовков на практике используется `;`), снятие BOM, `LazyQuotes`, RU-алиасы заголовков
(`headerAliases` + `normHeader` игнорирует регистр/пробелы/подчёркивания). (`headerAliases` + `normHeader` игнорирует регистр/пробелы/подчёркивания).
`email` можно заменить парой `localpart`+`domain`. `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 - **Режим override-домена** (`parseCSV(..., overrideDomain)`): если домен выбран в UI
и передан form-полем `domain`, колонка логина (`email`/`login`/`localpart`) и передан form-полем `domain`, колонка логина (`email`/`login`/`localpart`)
трактуется как localpart без домена, email собирается как `localpart@overrideDomain`. трактуется как localpart без домена, email собирается как `localpart@overrideDomain`.
@@ -79,7 +94,12 @@ gofmt -l . # список неотформатированных файл
`importResults` — идемпотентно, т.к. упавшие ящики не создались, успешные не трогаются. `importResults` — идемпотентно, т.к. упавшие ящики не создались, успешные не трогаются.
При добавлении полей ящика править **три места синхронно**: заголовки/алиасы CSV При добавлении полей ящика править **три места синхронно**: заголовки/алиасы CSV
(`headerAliases`), структуру `Row` + `validateRow`, и `payload` в `handleImport`. (`headerAliases`, при необходимости `kerioColumns`), структуру `Row` + `validateRow`,
и `payload` в `handleImport`.
При добавлении нового статуса `Row.Status` править `index.html` в четырёх местах:
CSS `.pill.<st>`/`.st.<st>`, `labels` и список ключей в `renderPreview`, `statusText`.
Ключи `summary` в `handlePreview` должны совпадать со списком в `renderPreview`.
## Требования на стороне mailcow ## Требования на стороне mailcow
+23 -1
View File
@@ -11,6 +11,8 @@
- **Проверка подключения** к API (домены/ящики читаются, чтобы подтвердить ключ и allow-list). - **Проверка подключения** к API (домены/ящики читаются, чтобы подтвердить ключ и allow-list).
- **Выбор домена**: после проверки подключения можно выбрать домен из списка mailcow — - **Выбор домена**: после проверки подключения можно выбрать домен из списка mailcow —
тогда в CSV достаточно логина без `@домена` (email собирается как `логин@выбранный_домен`). тогда в CSV достаточно логина без `@домена` (email собирается как `логин@выбранный_домен`).
- **Экспорт Kerio Connect** распознаётся автоматически — файл выгрузки пользователей
из старого сервера скармливается как есть, без ручной конвертации (см. «Формат CSV»).
- **Предпросмотр**: разбор CSV, структурная валидация (email, пароль, дубликаты), - **Предпросмотр**: разбор CSV, структурная валидация (email, пароль, дубликаты),
плюс — если задан ключ — сверка с живым mailcow: пропуск существующих ящиков и плюс — если задан ключ — сверка с живым mailcow: пропуск существующих ящиков и
отсев несуществующих доменов. отсев несуществующих доменов.
@@ -47,7 +49,27 @@ petrov@amega.kz;An0therPass!;Пётр Петров
| `password` | да | <8 символов → предупреждение | | `password` | да | <8 символов → предупреждение |
| `name` | нет | ФИО; по умолчанию = localpart | | `name` | нет | ФИО; по умолчанию = localpart |
| `quota` | нет | МиБ; иначе «квота по умолчанию» | | `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` и прочие) игнорируются.
## Запуск ## Запуск
+8 -5
View File
@@ -72,6 +72,7 @@
.pill .c{width:8px;height:8px;border-radius:50%} .pill .c{width:8px;height:8px;border-radius:50%}
.pill.create .c{background:var(--create)} .pill.warn .c{background:var(--warn)} .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.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} .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} 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); 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.create{color:var(--create)} .st.create::before{background:var(--create)}
.st.warn{color:var(--warn)} .st.warn::before{background:var(--warn)} .st.warn{color:var(--warn)} .st.warn::before{background:var(--warn)}
.st.exists{color:var(--exists)} .st.exists::before{background:var(--exists)} .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)} .st.error{color:var(--error)} .st.error::before{background:var(--error)}
.note{color:var(--muted);font-size:11.5px} .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} .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 || []; previewRows = j.rows || [];
renderPreview(j); renderPreview(j);
const note = j.enriched ? 'проверено против mailcow (домены + дубликаты + квоты)' : (j.enrichNote||''); 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); } }catch(e){ setStatus($('#previewStatus'),'err','ошибка: '+e.message); }
b.disabled = false; b.disabled = false;
} }
function renderPreview(j){ function renderPreview(j){
const s = j.summary||{}; const s = j.summary||{};
const labels = {create:'к созданию', warn:'с предупреждением', exists:'уже есть', error:'ошибки'}; const labels = {create:'к созданию', warn:'с предупреждением', exists:'уже есть', skip:'служебные', error:'ошибки'};
$('#summary').innerHTML = ['create','warn','exists','error'].map(k=> $('#summary').innerHTML = ['create','warn','exists','skip','error'].map(k=>
`<span class="pill ${k}"><span class="c"></span>${labels[k]}: ${s[k]||0}</span>`).join(''); `<span class="pill ${k}"><span class="c"></span>${labels[k]}: ${s[k]||0}</span>`).join('');
const body = $('#prevBody'); body.innerHTML=''; const body = $('#prevBody'); body.innerHTML='';
previewRows.forEach(r=>{ previewRows.forEach(r=>{
@@ -335,12 +338,12 @@ function renderPreview(j){
$('#importHint').textContent = blocked $('#importHint').textContent = blocked
? 'создание заблокировано — сначала увеличьте квоту домена (см. предупреждение выше)' ? 'создание заблокировано — сначала увеличьте квоту домена (см. предупреждение выше)'
: importable : importable
? `будет создано ${importable} ящик(ов); строки "уже есть"/"ошибки" пропускаются` ? `будет создано ${importable} ящик(ов); строки "уже есть"/"служебная"/"ошибки" пропускаются`
: 'нет строк, пригодных к созданию'; : 'нет строк, пригодных к созданию';
$('#btnImport').textContent = importable ? `Создать ${importable} ящик(ов)` : 'Создать ящики'; $('#btnImport').textContent = importable ? `Создать ${importable} ящик(ов)` : 'Создать ящики';
} }
const gb = mib => mib >= 1024 ? (mib/1024).toFixed(mib%1024?1:0)+' ГБ' : mib+' МиБ'; 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=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])); const esc = s => String(s==null?'':s).replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
// --- 03 import (streamed NDJSON) --- // --- 03 import (streamed NDJSON) ---
+100 -23
View File
@@ -161,9 +161,9 @@ func handlePreview(w http.ResponseWriter, r *http.Request) {
defaultQuota = defaultQuotaMiB defaultQuota = defaultQuotaMiB
} }
overrideDomain := strings.ToLower(strings.TrimSpace(r.FormValue("domain"))) overrideDomain := strings.ToLower(strings.TrimSpace(r.FormValue("domain")))
rows, err := parseCSV(raw, defaultQuota, overrideDomain) rows, format, err := parseCSV(raw, defaultQuota, overrideDomain)
if err != nil { if err != nil {
writeJSON(w, 400, map[string]any{"error": err.Error()}) writeJSON(w, 400, map[string]any{"error": err.Error(), "format": format})
return return
} }
@@ -194,7 +194,7 @@ func handlePreview(w http.ResponseWriter, r *http.Request) {
enrichNote = "API-ключ не задан — показана только структурная валидация без проверки дублей и доменов" 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 { for _, rw := range rows {
sum[rw.Status]++ sum[rw.Status]++
} }
@@ -202,6 +202,7 @@ func handlePreview(w http.ResponseWriter, r *http.Request) {
"rows": rows, "rows": rows,
"summary": sum, "summary": sum,
"total": len(rows), "total": len(rows),
"format": format,
"enriched": enriched, "enriched": enriched,
"enrichNote": enrichNote, "enrichNote": enrichNote,
"quotaWarnings": quotaWarnings, "quotaWarnings": quotaWarnings,
@@ -307,25 +308,71 @@ var headerAliases = map[string]string{
"localpart": "localpart", "local": "localpart", "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.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 { if v, ok := headerAliases[s]; ok {
return v return v
} }
if v, ok := headerAliases[strings.TrimSpace(strings.ToLower(s))]; ok {
return v
}
return s return s
} }
// parseCSV разбирает CSV. Если overrideDomain задан, колонка логина // kerioMarkers — headers that together identify a Kerio Connect user export.
// (email/localpart) трактуется как localpart без домена, а домен берётся var kerioMarkers = []string{"name", "fullname", "mailaddress", "datasource", "authentication"}
// из overrideDomain — email собирается как localpart@overrideDomain.
func parseCSV(raw []byte, defaultQuota, overrideDomain string) ([]Row, error) { // 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 raw = bytes.TrimPrefix(raw, []byte("\xef\xbb\xbf")) // strip UTF-8 BOM
if len(bytes.TrimSpace(raw)) == 0 { if len(bytes.TrimSpace(raw)) == 0 {
return nil, fmt.Errorf("файл пуст") return nil, format, fmt.Errorf("файл пуст")
} }
rd := csv.NewReader(bytes.NewReader(raw)) rd := csv.NewReader(bytes.NewReader(raw))
rd.Comma = sniffDelimiter(raw) rd.Comma = sniffDelimiter(raw)
@@ -335,30 +382,47 @@ func parseCSV(raw []byte, defaultQuota, overrideDomain string) ([]Row, error) {
records, err := rd.ReadAll() records, err := rd.ReadAll()
if err != nil { if err != nil {
return nil, fmt.Errorf("ошибка разбора CSV: %v", err) return nil, format, fmt.Errorf("ошибка разбора CSV: %v", err)
} }
if len(records) < 2 { if len(records) < 2 {
return nil, fmt.Errorf("нужны строка заголовка и хотя бы одна строка данных") return nil, format, fmt.Errorf("нужны строка заголовка и хотя бы одна строка данных")
} }
idx := map[string]int{} idx := map[string]int{}
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] { for i, h := range records[0] {
idx[normHeader(h)] = i idx[normHeader(h)] = i
} }
}
_, hasEmail := idx["email"] _, hasEmail := idx["email"]
_, hasLocal := idx["localpart"] _, hasLocal := idx["localpart"]
if overrideDomain != "" { if overrideDomain != "" {
if !hasEmail && !hasLocal { 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 { } else if !hasEmail {
_, hasDomain := idx["domain"] _, hasDomain := idx["domain"]
if !(hasLocal && hasDomain) { 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 { 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 { 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, "")) == "" { if len(rec) == 0 || strings.TrimSpace(strings.Join(rec, "")) == "" {
continue // skip blank lines 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 var email string
if overrideDomain != "" { if overrideDomain != "" {
login := firstNonEmpty(get(rec, "localpart"), get(rec, "email")) 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.Name = get(rec, "name")
r.Password = get(rec, "password") r.Password = get(rec, "password")
r.Quota = firstNonEmpty(get(rec, "quota"), defaultQuota) 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) 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 seen[r.Email] = true
rows = append(rows, r) rows = append(rows, r)
} }
if len(rows) == 0 { 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) { 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) { func enrichRow(r *Row, domains, mailboxes map[string]bool) {
if r.Status == "error" { if r.Status == "error" || r.Status == "skip" {
return // structural error already blocks it return // structural error / deliberate skip already blocks it
} }
if r.Domain != "" && !domains[r.Domain] { if r.Domain != "" && !domains[r.Domain] {
r.Status = "error" r.Status = "error"