add kerio format support
This commit is contained in:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user