Files
2026-07-14 13:21:45 +07:00

717 lines
22 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// mcimport — bulk mailbox provisioning for mailcow from a CSV file.
//
// Single static binary, stdlib only, embedded web UI.
// Endpoints:
//
// GET / -> embedded UI
// GET /api/config -> defaults (base URL, whether an env API key is set)
// POST /api/test -> verify API connectivity and read domains/mailboxes
// POST /api/preview -> parse CSV, validate, enrich against mailcow state
// POST /api/import -> create mailboxes, streaming NDJSON per-row results
package main
import (
"bytes"
"context"
"crypto/tls"
"embed"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
)
//go:embed index.html
var uiFS embed.FS
var httpClient = &http.Client{Timeout: 25 * time.Second}
// defaultQuotaMiB — квота по умолчанию в МиБ, если не задана в env/CSV/UI.
const defaultQuotaMiB = "10240"
// exampleCSV — образец заполнения, отдаётся по GET /api/example.
// Разделитель — точка с запятой; колонка login используется без домена
// (домен выбирается в UI). Полный email тоже допустим, если домен не выбран.
const exampleCSV = "login;password;name;quota;active\r\n" +
"ivanov;S3cretPass!;Иван Иванов;10240;1\r\n" +
"petrov;An0therPass!;Пётр Петров;;1\r\n"
func main() {
if os.Getenv("INSECURE_SKIP_VERIFY") == "true" {
httpClient.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
log.Println("WARNING: TLS verification disabled (INSECURE_SKIP_VERIFY=true)")
}
mux := http.NewServeMux()
mux.HandleFunc("GET /", handleIndex)
mux.HandleFunc("GET /api/config", handleConfig)
mux.HandleFunc("GET /api/example", handleExample)
mux.HandleFunc("POST /api/test", handleTest)
mux.HandleFunc("POST /api/preview", handlePreview)
mux.HandleFunc("POST /api/import", handleImport)
addr := envOr("LISTEN", ":8080")
log.Printf("mcimport listening on %s", addr)
log.Fatal(http.ListenAndServe(addr, logRequests(mux)))
}
// ---------- handlers ----------
func handleIndex(w http.ResponseWriter, r *http.Request) {
b, _ := uiFS.ReadFile("index.html")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(b)
}
func handleConfig(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{
"baseUrl": os.Getenv("MAILCOW_URL"),
"hasEnvKey": os.Getenv("MAILCOW_API_KEY") != "",
"defaultQuota": envOr("DEFAULT_QUOTA", defaultQuotaMiB),
})
}
func handleExample(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="mcimport-example.csv"`)
io.WriteString(w, "\xef\xbb\xbf"+exampleCSV) // BOM — чтобы Excel открыл кириллицу в UTF-8
}
type credReq struct {
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
}
func handleTest(w http.ResponseWriter, r *http.Request) {
var req credReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, 400, map[string]any{"ok": false, "message": "некорректный запрос"})
return
}
base, key := resolveCreds(req.BaseURL, req.APIKey)
if base == "" || key == "" {
writeJSON(w, 200, map[string]any{"ok": false, "message": "не заданы URL или API-ключ"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
domains, err := fetchDomains(ctx, base, key)
if err != nil {
writeJSON(w, 200, map[string]any{"ok": false, "message": err.Error()})
return
}
mbx, _ := fetchMailboxes(ctx, base, key)
writeJSON(w, 200, map[string]any{
"ok": true,
"message": fmt.Sprintf("подключение успешно — доменов: %d, ящиков: %d", len(domains), len(mbx)),
"domains": domainNames(domains),
"mailboxes": len(mbx),
})
}
// Row is one normalized CSV record after validation/enrichment.
type Row struct {
Line int `json:"line"`
Email string `json:"email"`
LocalPart string `json:"localPart"`
Domain string `json:"domain"`
Name string `json:"name"`
Password string `json:"password"`
Quota string `json:"quota"`
Active string `json:"active"`
Status string `json:"status"` // create | warn | exists | error
Notes []string `json:"notes"`
Importable bool `json:"importable"`
}
// quotaWarn — предупреждение о нехватке общей квоты домена (все значения в МиБ).
type quotaWarn struct {
Domain string `json:"domain"`
DomainMiB int64 `json:"domainMiB"` // общая квота домена
UsedMiB int64 `json:"usedMiB"` // выделено существующим ящикам
FreeMiB int64 `json:"freeMiB"` // свободно = domain used
NeededMiB int64 `json:"neededMiB"` // нужно для импортируемых строк
SuggestMiB int64 `json:"suggestMiB"` // до какого значения поднять квоту домена
}
func handlePreview(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(32 << 20); err != nil {
writeJSON(w, 400, map[string]any{"error": "не удалось прочитать форму"})
return
}
file, _, err := r.FormFile("file")
if err != nil {
writeJSON(w, 400, map[string]any{"error": "файл не передан"})
return
}
defer file.Close()
raw, _ := io.ReadAll(io.LimitReader(file, 32<<20))
defaultQuota := strings.TrimSpace(r.FormValue("defaultQuota"))
if defaultQuota == "" {
defaultQuota = defaultQuotaMiB
}
overrideDomain := strings.ToLower(strings.TrimSpace(r.FormValue("domain")))
rows, err := parseCSV(raw, defaultQuota, overrideDomain)
if err != nil {
writeJSON(w, 400, map[string]any{"error": err.Error()})
return
}
// Enrich against live mailcow state when credentials are available.
enriched, enrichNote := false, ""
quotaWarnings := []quotaWarn{}
base, key := resolveCreds(r.FormValue("baseUrl"), r.FormValue("apiKey"))
if base != "" && key != "" {
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
domains, derr := fetchDomains(ctx, base, key)
mbx, merr := fetchMailboxes(ctx, base, key)
if derr == nil && merr == nil {
enriched = true
domSet := toSet(domainNames(domains))
mbxSet := map[string]bool{}
for _, m := range mbx {
mbxSet[m.username] = true
}
for i := range rows {
enrichRow(&rows[i], domSet, mbxSet)
}
quotaWarnings = checkDomainQuota(rows, domains, mbx)
} else {
enrichNote = "проверка против mailcow пропущена (нет связи с API) — только структурная валидация"
}
} else {
enrichNote = "API-ключ не задан — показана только структурная валидация без проверки дублей и доменов"
}
sum := map[string]int{"create": 0, "warn": 0, "exists": 0, "error": 0}
for _, rw := range rows {
sum[rw.Status]++
}
writeJSON(w, 200, map[string]any{
"rows": rows,
"summary": sum,
"total": len(rows),
"enriched": enriched,
"enrichNote": enrichNote,
"quotaWarnings": quotaWarnings,
})
}
type importReq struct {
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
ForcePwUpdate bool `json:"forcePwUpdate"`
Rows []Row `json:"rows"`
}
// result is one streamed NDJSON line during import.
type result struct {
Index int `json:"index"`
Total int `json:"total"`
Email string `json:"email"`
OK bool `json:"ok"`
HTTPCode int `json:"httpCode"`
Message string `json:"message"`
TS string `json:"ts"`
}
func handleImport(w http.ResponseWriter, r *http.Request) {
var req importReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, 400, map[string]any{"error": "некорректный запрос"})
return
}
base, key := resolveCreds(req.BaseURL, req.APIKey)
if base == "" || key == "" {
writeJSON(w, 400, map[string]any{"error": "не заданы URL или API-ключ"})
return
}
w.Header().Set("Content-Type", "application/x-ndjson")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Cache-Control", "no-store")
flusher, _ := w.(http.Flusher)
enc := json.NewEncoder(w)
force := "0"
if req.ForcePwUpdate {
force = "1"
}
total := len(req.Rows)
for i, rw := range req.Rows {
active := rw.Active
if active == "" {
active = "1"
}
payload := map[string]string{
"active": active,
"domain": rw.Domain,
"local_part": rw.LocalPart,
"name": firstNonEmpty(rw.Name, rw.LocalPart),
"password": rw.Password,
"password2": rw.Password,
"quota": rw.Quota,
"force_pw_update": force,
"tls_enforce_in": "0",
"tls_enforce_out": "0",
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
code, body, err := mcDo(ctx, base, key, "POST", "/api/v1/add/mailbox", payload)
cancel()
res := result{Index: i + 1, Total: total, Email: rw.Email, HTTPCode: code, TS: time.Now().Format("15:04:05")}
if err != nil {
res.OK, res.Message = false, "ошибка запроса: "+err.Error()
} else {
ok, msg := parseMailcowMsg(body)
res.OK, res.Message = ok, msg
}
enc.Encode(res)
if flusher != nil {
flusher.Flush()
}
}
}
// ---------- CSV parsing ----------
var headerAliases = map[string]string{
"email": "email", "e-mail": "email", "mail": "email", "почта": "email",
"адрес": "email", "username": "email", "user": "email",
"login": "email", "логин": "email",
"password": "password", "pass": "password", "пароль": "password", "pwd": "password",
"name": "name", "fullname": "name", "displayname": "name", "имя": "name", "фио": "name",
"quota": "quota", "квота": "quota",
"active": "active", "активен": "active",
"domain": "domain", "домен": "domain",
"localpart": "localpart", "local": "localpart",
}
func normHeader(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
s = strings.NewReplacer(" ", "", "_", "", "-", "").Replace(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) {
raw = bytes.TrimPrefix(raw, []byte("\xef\xbb\xbf")) // strip UTF-8 BOM
if len(bytes.TrimSpace(raw)) == 0 {
return nil, fmt.Errorf("файл пуст")
}
rd := csv.NewReader(bytes.NewReader(raw))
rd.Comma = sniffDelimiter(raw)
rd.FieldsPerRecord = -1
rd.LazyQuotes = true
rd.TrimLeadingSpace = true
records, err := rd.ReadAll()
if err != nil {
return nil, fmt.Errorf("ошибка разбора CSV: %v", err)
}
if len(records) < 2 {
return nil, fmt.Errorf("нужны строка заголовка и хотя бы одна строка данных")
}
idx := map[string]int{}
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], ", "))
}
} else if !hasEmail {
_, hasDomain := idx["domain"]
if !(hasLocal && hasDomain) {
return nil, fmt.Errorf("не найдена колонка email (или пара localpart+domain). Заголовки: %s", strings.Join(records[0], ", "))
}
}
if _, ok := idx["password"]; !ok {
return nil, fmt.Errorf("не найдена колонка password")
}
get := func(rec []string, key string) string {
if i, ok := idx[key]; ok && i < len(rec) {
return strings.TrimSpace(rec[i])
}
return ""
}
var rows []Row
seen := map[string]bool{}
for n, rec := range records[1:] {
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")}
var email string
if overrideDomain != "" {
login := firstNonEmpty(get(rec, "localpart"), get(rec, "email"))
if i := strings.IndexByte(login, '@'); i >= 0 {
login = login[:i] // терпим полный email — берём часть до @, домен из выбранного
r.Notes = append(r.Notes, "домен из значения проигнорирован — используется выбранный")
}
if login != "" {
email = login + "@" + overrideDomain
}
} else {
email = get(rec, "email")
if email == "" {
lp, dm := get(rec, "localpart"), get(rec, "domain")
if lp != "" && dm != "" {
email = lp + "@" + dm
}
}
}
r.Email = strings.ToLower(email)
r.Name = get(rec, "name")
r.Password = get(rec, "password")
r.Quota = firstNonEmpty(get(rec, "quota"), defaultQuota)
validateRow(&r, seen)
seen[r.Email] = true
rows = append(rows, r)
}
if len(rows) == 0 {
return nil, fmt.Errorf("нет строк с данными")
}
return rows, nil
}
func validateRow(r *Row, seen map[string]bool) {
r.Status = "create"
addErr := func(msg string) { r.Status = "error"; r.Notes = append(r.Notes, msg) }
addWarn := func(msg string) {
if r.Status == "create" {
r.Status = "warn"
}
r.Notes = append(r.Notes, msg)
}
at := strings.Count(r.Email, "@")
if r.Email == "" || at != 1 {
addErr("некорректный email")
} else {
parts := strings.SplitN(r.Email, "@", 2)
r.LocalPart, r.Domain = parts[0], parts[1]
if r.LocalPart == "" || r.Domain == "" {
addErr("некорректный email")
}
}
if seen[r.Email] && r.Email != "" {
addErr("дубликат в файле")
}
switch {
case r.Password == "":
addErr("пустой пароль")
case len(r.Password) < 8:
addWarn("короткий пароль — может отклонить политика mailcow")
}
if r.Quota != "" {
if _, err := strconv.Atoi(r.Quota); err != nil {
addWarn("квота не число — будет применён дефолт")
}
}
r.Importable = r.Status == "create" || r.Status == "warn"
}
func enrichRow(r *Row, domains, mailboxes map[string]bool) {
if r.Status == "error" {
return // structural error already blocks it
}
if r.Domain != "" && !domains[r.Domain] {
r.Status = "error"
r.Notes = append(r.Notes, "домен отсутствует в mailcow")
}
if mailboxes[r.Email] {
r.Status = "exists"
r.Notes = append(r.Notes, "ящик уже существует — будет пропущен")
}
r.Importable = r.Status == "create" || r.Status == "warn"
}
func sniffDelimiter(raw []byte) rune {
line := raw
if i := bytes.IndexByte(raw, '\n'); i >= 0 {
line = raw[:i]
}
counts := map[rune]int{',': bytes.Count(line, []byte(",")), ';': bytes.Count(line, []byte(";")), '\t': bytes.Count(line, []byte("\t"))}
best, bestN := ',', -1
for d, n := range counts {
if n > bestN {
best, bestN = d, n
}
}
return best
}
// ---------- mailcow API ----------
func mcDo(ctx context.Context, base, key, method, path string, body any) (int, []byte, error) {
var rdr io.Reader
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(base, "/")+path, rdr)
if err != nil {
return 0, nil, err
}
req.Header.Set("X-API-Key", key)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return 0, nil, err
}
defer resp.Body.Close()
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
return resp.StatusCode, rb, nil
}
// domainRec — домен и его общая квота (max_quota_for_domain), в байтах; 0 = безлимит.
type domainRec struct {
name string
quota int64
}
// mailboxRec — существующий ящик с выделенной квотой (байты) и доменом.
type mailboxRec struct {
username string
domain string
quota int64
}
func fetchDomains(ctx context.Context, base, key string) ([]domainRec, error) {
code, body, err := mcDo(ctx, base, key, "GET", "/api/v1/get/domain/all", nil)
if err != nil {
return nil, fmt.Errorf("нет связи с API: %v", err)
}
if code == 401 || code == 403 {
return nil, fmt.Errorf("отказ доступа (код %d) — проверьте API-ключ и allow-list IP", code)
}
if code != 200 {
return nil, fmt.Errorf("неожиданный ответ API (код %d)", code)
}
var arr []struct {
DomainName string `json:"domain_name"`
MaxQuotaDomain int64 `json:"max_quota_for_domain"` // байты
}
if err := json.Unmarshal(body, &arr); err != nil {
return nil, fmt.Errorf("не удалось разобрать список доменов (ключ без прав на чтение?)")
}
out := make([]domainRec, 0, len(arr))
for _, d := range arr {
if d.DomainName != "" {
out = append(out, domainRec{name: strings.ToLower(d.DomainName), quota: d.MaxQuotaDomain})
}
}
return out, nil
}
func fetchMailboxes(ctx context.Context, base, key string) ([]mailboxRec, error) {
code, body, err := mcDo(ctx, base, key, "GET", "/api/v1/get/mailbox/all", nil)
if err != nil || code != 200 {
return nil, fmt.Errorf("не удалось получить ящики (код %d)", code)
}
var arr []struct {
Username string `json:"username"`
Domain string `json:"domain"`
Quota int64 `json:"quota"` // байты, выделенная квота
}
if err := json.Unmarshal(body, &arr); err != nil {
return nil, err
}
out := make([]mailboxRec, 0, len(arr))
for _, m := range arr {
if m.Username != "" {
out = append(out, mailboxRec{
username: strings.ToLower(m.Username),
domain: strings.ToLower(m.Domain),
quota: m.Quota,
})
}
}
return out, nil
}
// domainNames возвращает только имена доменов (для UI: chips + селект).
func domainNames(d []domainRec) []string {
out := make([]string, len(d))
for i, x := range d {
out[i] = x.name
}
return out
}
// checkDomainQuota воспроизводит проверку mailcow: свободная доменная квота =
// max_quota_for_domain Σ(выделенных квот существующих ящиков домена). Если
// суммарная квота импортируемых строк домена превышает свободную — возвращает
// предупреждение. Домены с квотой 0 (безлимит) пропускаются.
func checkDomainQuota(rows []Row, domains []domainRec, mbx []mailboxRec) []quotaWarn {
domMax := map[string]int64{}
for _, d := range domains {
domMax[d.name] = d.quota
}
domUsed := map[string]int64{}
for _, m := range mbx {
domUsed[m.domain] += m.quota
}
domNeed := map[string]int64{}
for i := range rows {
if !rows[i].Importable {
continue
}
q, err := strconv.Atoi(rows[i].Quota)
if err != nil {
continue
}
domNeed[rows[i].Domain] += int64(q) << 20 // МиБ → байты
}
out := []quotaWarn{}
for dom, need := range domNeed {
max := domMax[dom]
if max <= 0 {
continue // безлимитный домен
}
used := domUsed[dom]
free := max - used
if free < 0 {
free = 0
}
if need > free {
out = append(out, quotaWarn{
Domain: dom,
DomainMiB: max >> 20,
UsedMiB: used >> 20,
FreeMiB: free >> 20,
NeededMiB: need >> 20,
SuggestMiB: (used + need) >> 20,
})
}
}
return out
}
// parseMailcowMsg decodes mailcow's [{ "type": "...", "msg": ... }] response.
func parseMailcowMsg(body []byte) (bool, string) {
var arr []struct {
Type string `json:"type"`
Msg json.RawMessage `json:"msg"`
}
if err := json.Unmarshal(body, &arr); err != nil || len(arr) == 0 {
s := strings.TrimSpace(string(body))
if s == "" {
s = "пустой ответ API"
}
return false, s
}
ok := true
var parts []string
for _, e := range arr {
if e.Type != "success" {
ok = false
}
parts = append(parts, e.Type+": "+flattenMsg(e.Msg))
}
return ok, strings.Join(parts, "; ")
}
func flattenMsg(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var s string
if json.Unmarshal(raw, &s) == nil {
return s
}
var arr []json.RawMessage
if json.Unmarshal(raw, &arr) == nil {
var out []string
for _, e := range arr {
var es string
if json.Unmarshal(e, &es) == nil {
out = append(out, es)
} else {
out = append(out, strings.Trim(string(e), `"`))
}
}
return strings.Join(out, " ")
}
return string(raw)
}
// ---------- helpers ----------
func resolveCreds(base, key string) (string, string) {
if strings.TrimSpace(base) == "" {
base = os.Getenv("MAILCOW_URL")
}
if strings.TrimSpace(key) == "" {
key = os.Getenv("MAILCOW_API_KEY")
}
return strings.TrimSpace(base), strings.TrimSpace(key)
}
func toSet(items []string) map[string]bool {
m := make(map[string]bool, len(items))
for _, s := range items {
m[s] = true
}
return m
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
func logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
if r.URL.Path != "/" {
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond))
}
})
}