add kerio format support
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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=>({'&':'&','<':'<','>':'>'}[c]));
|
const esc = s => String(s==null?'':s).replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]));
|
||||||
|
|
||||||
// --- 03 import (streamed NDJSON) ---
|
// --- 03 import (streamed NDJSON) ---
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user