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
+102 -25
View File
@@ -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"