init
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
.DS_Store
|
||||
.env
|
||||
*.csv
|
||||
@@ -0,0 +1,87 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Что это
|
||||
|
||||
`mcimport` — однобинарный инструмент (Go, только stdlib) для массового создания
|
||||
почтовых ящиков в mailcow из CSV через его REST API. Веб-морда встроена в бинарь
|
||||
(`//go:embed index.html`). Пайплайн пользователя: загрузка CSV → предпросмотр с
|
||||
валидацией → потоковый импорт с журналом.
|
||||
|
||||
## Команды
|
||||
|
||||
```bash
|
||||
# Сборка статического бинаря
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o mcimport .
|
||||
|
||||
# Локальный запуск (UI на http://localhost:8080)
|
||||
MAILCOW_URL=https://mail.dom.ru ./mcimport
|
||||
|
||||
# Docker (порт публикуется только на 127.0.0.1 — ходить через SSH-туннель)
|
||||
docker compose up -d --build
|
||||
|
||||
go vet ./...
|
||||
gofmt -l . # список неотформатированных файлов; -w для правки
|
||||
```
|
||||
|
||||
Тестов в проекте нет. `go.mod` не имеет внешних зависимостей — только stdlib,
|
||||
поэтому `go mod tidy`/`go get` при обычной работе не требуются.
|
||||
|
||||
## Архитектура
|
||||
|
||||
Весь backend — один файл `main.go`, весь frontend — один `index.html` (встроен).
|
||||
Состояние на сервере не хранится: инструмент **stateless**, API-ключ и пароли
|
||||
не пишутся на диск и не логируются.
|
||||
|
||||
Четыре HTTP-эндпоинта (`net/http` ServeMux с method-роутингом Go 1.22):
|
||||
|
||||
- `GET /api/config` — отдаёт дефолты в UI (base URL, есть ли ключ в env, дефолт-квота).
|
||||
- `GET /api/example` — отдаёт образец CSV (`exampleCSV`) как вложение для скачивания.
|
||||
- `POST /api/test` — проверка связи: читает домены и ящики, подтверждает ключ и allow-list.
|
||||
- `POST /api/preview` — разбор CSV (multipart), валидация, обогащение против живого mailcow. Form-поле `domain` включает режим override (см. ниже). В ответе — `quotaWarnings` (проверка доменной квоты).
|
||||
- `POST /api/import` — создание ящиков, **потоковый NDJSON** (по строке результата на ящик, с `flusher.Flush()`).
|
||||
|
||||
Три конвейерных этапа обработки строки CSV, каждый меняет `Row.Status`
|
||||
(`create`→`warn`→`exists`→`error`) и флаг `Importable`:
|
||||
|
||||
1. `parseCSV` → `validateRow` — структурная проверка без сети (email, пароль, дубликаты в файле).
|
||||
2. `enrichRow` — сверка с живым mailcow: несуществующий домен → `error`, существующий ящик → `exists` (пропуск). Работает только если заданы креды.
|
||||
3. `checkDomainQuota` — воспроизводит проверку mailcow: свободная квота домена = `max_quota_for_domain` − Σ(выделенных квот существующих ящиков). Если Σ квот импортируемых строк домена превышает свободную — возвращает `quotaWarn` (в МиБ). UI показывает предупреждение и **блокирует** импорт до подъёма квоты домена (домены с квотой 0 = безлимит пропускаются).
|
||||
4. `handleImport` — POST на `/api/v1/add/mailbox`, ответ разбирается `parseMailcowMsg`. Используется **только** `add` — существующие ящики никогда не модифицируются (идемпотентность).
|
||||
|
||||
### Ключевые соглашения
|
||||
|
||||
- **Разрешение кредов** — `resolveCreds`: значения из тела запроса приоритетнее env
|
||||
(`MAILCOW_URL`/`MAILCOW_API_KEY`). Ключ можно задать через env и не вводить в UI.
|
||||
- **Ответы API mailcow** имеют форму `[{"type": "...", "msg": ...}]`; `type != "success"`
|
||||
считается провалом. `msg` бывает строкой или массивом — разворачивается в `flattenMsg`.
|
||||
- **CSV-парсинг** терпимый: авто-определение разделителя (`sniffDelimiter`: `,`/`;`/таб;
|
||||
на практике используется `;`), снятие BOM, `LazyQuotes`, RU-алиасы заголовков
|
||||
(`headerAliases` + `normHeader` игнорирует регистр/пробелы/подчёркивания).
|
||||
`email` можно заменить парой `localpart`+`domain`.
|
||||
- **Режим override-домена** (`parseCSV(..., overrideDomain)`): если домен выбран в UI
|
||||
и передан form-полем `domain`, колонка логина (`email`/`login`/`localpart`)
|
||||
трактуется как localpart без домена, email собирается как `localpart@overrideDomain`.
|
||||
Полный email в значении терпится — берётся часть до `@` с пометкой в `Notes`.
|
||||
Домен нормализуется в lowercase в `handlePreview`.
|
||||
- Ошибки пользователю — **на русском**; комментарии в коде — на английском. Держать этот сплит.
|
||||
- `INSECURE_SKIP_VERIFY=true` отключает проверку TLS (для внутреннего CA mailcow).
|
||||
|
||||
- Дефолт квоты — константа `defaultQuotaMiB` (10240 МиБ). Одна точка правды;
|
||||
дублируется в `docker-compose.yml`, `README.md` и `value` поля `#quota` в `index.html`.
|
||||
- **Единицы квот**: `fetchDomains`/`fetchMailboxes` возвращают квоты в **байтах**
|
||||
(как отдаёт mailcow), `Row.Quota` — в **МиБ** (строка). `checkDomainQuota` сводит
|
||||
всё к байтам, `quotaWarn` наружу — в МиБ. UI форматирует МиБ→ГБ (`gb()`).
|
||||
- **Повтор в UI без перезагрузки файла** (`index.html`): `previewRows`/`importResults`
|
||||
держатся в памяти. «Повторить проверку» = повторный `runPreview()` (файл в `fileInput`
|
||||
сохраняется). «Повторить неудавшиеся» = `runImport()` по строкам с `ok:false` из
|
||||
`importResults` — идемпотентно, т.к. упавшие ящики не создались, успешные не трогаются.
|
||||
|
||||
При добавлении полей ящика править **три места синхронно**: заголовки/алиасы CSV
|
||||
(`headerAliases`), структуру `Row` + `validateRow`, и `payload` в `handleImport`.
|
||||
|
||||
## Требования на стороне mailcow
|
||||
|
||||
- API-ключ с правами **Read-Write** (`Configuration → Access → API`).
|
||||
- IP, с которого ходит mcimport, добавлен в `API_ALLOW_FROM` — иначе отказ доступа (401/403).
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
# ---- build stage ----
|
||||
FROM golang:1.22-alpine AS build
|
||||
RUN apk add --no-cache ca-certificates
|
||||
WORKDIR /src
|
||||
COPY go.mod ./
|
||||
COPY main.go index.html ./
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /mcimport .
|
||||
|
||||
# ---- runtime stage ----
|
||||
FROM scratch
|
||||
# CA bundle so HTTPS to mailcow (Let's Encrypt) validates
|
||||
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
COPY --from=build /mcimport /mcimport
|
||||
EXPOSE 8080
|
||||
USER 65534:65534
|
||||
ENTRYPOINT ["/mcimport"]
|
||||
@@ -0,0 +1,111 @@
|
||||
# mcimport — массовое создание ящиков mailcow из CSV
|
||||
|
||||
Однобинарный инструмент (stdlib-only Go, embedded веб-морда) для провижининга
|
||||
почтовых ящиков mailcow через API. Импорт файла → предпросмотр с валидацией →
|
||||
потоковый журнал создания.
|
||||
|
||||
## Возможности
|
||||
|
||||
- **Проверка подключения** к API (домены/ящики читаются, чтобы подтвердить ключ и allow-list).
|
||||
- **Выбор домена**: после проверки подключения можно выбрать домен из списка mailcow —
|
||||
тогда в CSV достаточно логина без `@домена` (email собирается как `логин@выбранный_домен`).
|
||||
- **Предпросмотр**: разбор CSV, структурная валидация (email, пароль, дубликаты),
|
||||
плюс — если задан ключ — сверка с живым mailcow: пропуск существующих ящиков и
|
||||
отсев несуществующих доменов.
|
||||
- **Импорт** с потоковым (NDJSON) журналом в реальном времени: по строке на ящик,
|
||||
разбор ответа mailcow (`success`/`danger`), счётчики, выгрузка лога в CSV.
|
||||
|
||||
## Формат CSV
|
||||
|
||||
Заголовок обязателен. Разделитель — точка с запятой (`;`); также распознаются
|
||||
`,` и таб. BOM снимается, кодировка — UTF-8. Готовый образец можно скачать в UI
|
||||
(ссылка «Скачать пример CSV») или по `GET /api/example`.
|
||||
|
||||
С выбранным в UI доменом достаточно логина:
|
||||
|
||||
```csv
|
||||
login;password;name
|
||||
ivanov;S3cretPass!;Иван Иванов
|
||||
petrov;An0therPass!;Пётр Петров
|
||||
```
|
||||
|
||||
Без выбора домена — полный email в каждой строке:
|
||||
|
||||
```csv
|
||||
email;password;name
|
||||
ivanov@bildme.ru;S3cretPass!;Иван Иванов
|
||||
petrov@amega.kz;An0therPass!;Пётр Петров
|
||||
```
|
||||
|
||||
Колонки (регистр/пробелы/подчёркивания не важны, есть RU-алиасы):
|
||||
|
||||
| колонка | обяз. | примечание |
|
||||
|----------------------|-------|--------------------------------------------------------|
|
||||
| `email` / `login` | да* | при выбранном домене — логин без `@`; иначе полный email или пара `localpart` + `domain` |
|
||||
| `password` | да | <8 символов → предупреждение |
|
||||
| `name` | нет | ФИО; по умолчанию = localpart |
|
||||
| `quota` | нет | МиБ; иначе «квота по умолчанию» |
|
||||
| `active` | нет | `1`/`0`, по умолчанию `1` |
|
||||
|
||||
## Запуск
|
||||
|
||||
### Бинарь
|
||||
```bash
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o mcimport .
|
||||
MAILCOW_URL=https://mail.dom.ru ./mcimport
|
||||
# открыть http://localhost:8080
|
||||
```
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
# морда на 127.0.0.1:8080 — ходите через SSH-туннель:
|
||||
# ssh -L 8080:127.0.0.1:8080 user@host
|
||||
```
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
| переменная | по умолчанию | назначение |
|
||||
|------------------------|-------------------------|---------------------------------------------------|
|
||||
| `MAILCOW_URL` | — | базовый URL (можно ввести в UI) |
|
||||
| `MAILCOW_API_KEY` | — | Read-Write ключ; если задан — не вводится в UI |
|
||||
| `DEFAULT_QUOTA` | `10240` | квота по умолчанию, МиБ |
|
||||
| `LISTEN` | `:8080` | адрес прослушивания |
|
||||
| `INSECURE_SKIP_VERIFY` | `false` | отключить проверку TLS (внутренний CA) |
|
||||
|
||||
## Что нужно на стороне mailcow
|
||||
|
||||
1. UI → `Configuration → Access → Edit administrator details → API`:
|
||||
создать ключ с правами **Read-Write**.
|
||||
2. В `API_ALLOW_FROM` добавить IP, с которого mcimport ходит в API
|
||||
(egress-IP контейнера/хоста). Иначе — отказ доступа.
|
||||
3. Убедиться, что лимит «Максимум почтовых ящиков» у домена и квоты домена
|
||||
покрывают импортируемую пачку.
|
||||
|
||||
## Общая квота домена (частая ошибка `mailbox_quota_left_exceeded`)
|
||||
|
||||
mailcow не даст создать ящик, если `Σ выделенных квот ящиков домена` превысит
|
||||
**Domain quota** (общую квоту домена). Это не про квоту одного ящика — ставить
|
||||
её в `0` (безлимит) не нужно. Предпросмотр (при заданном ключе) заранее считает
|
||||
нехватку и **останавливает** импорт с подсказкой, до какого значения поднять
|
||||
Domain quota.
|
||||
|
||||
Поднять квоту домена: mailcow UI → домен → «Domain quota», либо один вызов API:
|
||||
|
||||
```bash
|
||||
curl -sk -X POST https://mail.dom.ru/api/v1/edit/domain \
|
||||
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
|
||||
-d '{"items":["a27.kz"],"attr":{"quota":"307200"}}' # 300 ГБ (МиБ)
|
||||
```
|
||||
|
||||
После правки — в UI кнопка **«Повторить проверку»** (перепроверить без загрузки
|
||||
файла) или **«Повторить неудавшиеся»** в журнале (досоздать только упавшие ящики).
|
||||
|
||||
## Замечания по безопасности
|
||||
|
||||
- Инструмент **stateless**: ключ и пароли нигде не пишутся на диск и не логируются.
|
||||
Пароли из CSV во время предпросмотра проходят через браузер (внутренний тул) —
|
||||
запускайте только в доверенной сети / за туннелем, не публикуйте наружу.
|
||||
- Публикация порта в примере — только на `127.0.0.1`.
|
||||
- Слабые пароли mailcow отклонит по политике; такие строки видны в предпросмотре
|
||||
как предупреждение и в журнале как `danger: password_complexity`.
|
||||
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
mcimport:
|
||||
build: .
|
||||
# image: mcimport:latest # или свой pushed-образ
|
||||
container_name: mcimport
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080" # доступ только с localhost; ходите через SSH-туннель
|
||||
environment:
|
||||
MAILCOW_URL: "https://amail.amega.kz"
|
||||
# Ключ можно задать здесь и не вводить в UI (поле останется с плейсхолдером):
|
||||
MAILCOW_API_KEY: ${MAILCOW_API_KEY}
|
||||
DEFAULT_QUOTA: "10240"
|
||||
# INSECURE_SKIP_VERIFY: "true" # только если у mailcow самоподписанный/внутренний CA
|
||||
LISTEN: ":8080"
|
||||
|
||||
# Вариант «внутри сети mailcow» — тогда MAILCOW_URL можно указать
|
||||
# http://nginx-mailcow:8080 и добавить IP этого контейнера в API_ALLOW_FROM.
|
||||
# networks:
|
||||
# - mailcowdockerized_mailcow-network
|
||||
|
||||
# networks:
|
||||
# mailcowdockerized_mailcow-network:
|
||||
# external: true
|
||||
+415
@@ -0,0 +1,415 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>mailcow · массовое создание ящиков</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0e1117; --panel:#161b26; --panel2:#1b2231; --border:#262f40;
|
||||
--text:#c8d0dc; --muted:#7c8798; --head:#e7ecf3;
|
||||
--primary:#5b8cff; --primary-ink:#0a1020; --teal:#3dd7c4;
|
||||
--create:#3dd7c4; --warn:#e0a54b; --exists:#8590a1; --error:#f2555a;
|
||||
--mono:ui-monospace,"JetBrains Mono","SF Mono",Menlo,Consolas,monospace;
|
||||
--sans:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--text);font-family:var(--sans);
|
||||
font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased}
|
||||
a{color:var(--primary)}
|
||||
.wrap{max-width:1040px;margin:0 auto;padding:32px 20px 80px}
|
||||
header.top{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;
|
||||
padding-bottom:22px;margin-bottom:26px;border-bottom:1px solid var(--border)}
|
||||
header.top h1{font-size:19px;font-weight:650;color:var(--head);margin:0;letter-spacing:-.02em}
|
||||
header.top h1 .dot{color:var(--teal);font-weight:700}
|
||||
header.top p{margin:0;color:var(--muted);font-size:13px}
|
||||
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;
|
||||
padding:22px 22px 24px;margin-bottom:20px}
|
||||
.step{display:flex;align-items:center;gap:11px;margin-bottom:18px}
|
||||
.step .n{font-family:var(--mono);font-size:12px;color:var(--teal);
|
||||
border:1px solid var(--border);border-radius:6px;padding:2px 8px;letter-spacing:.05em}
|
||||
.step h2{font-size:15px;font-weight:600;color:var(--head);margin:0}
|
||||
.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||
@media(max-width:680px){.grid{grid-template-columns:1fr}}
|
||||
label.f{display:block;font-size:12px;color:var(--muted);margin-bottom:6px;letter-spacing:.02em}
|
||||
input[type=text],input[type=password],input[type=number],select{width:100%;background:var(--bg);
|
||||
border:1px solid var(--border);border-radius:8px;color:var(--text);padding:10px 12px;
|
||||
font-family:var(--mono);font-size:13px;outline:none;transition:border-color .15s}
|
||||
select{cursor:pointer}
|
||||
select:disabled{opacity:.5;cursor:not-allowed}
|
||||
input:focus,select:focus{border-color:var(--primary)}
|
||||
input::placeholder{color:#586171}
|
||||
.row-inline{display:flex;gap:14px;align-items:end;flex-wrap:wrap;margin-top:16px}
|
||||
.row-inline > div{flex:1;min-width:150px}
|
||||
button{font-family:var(--sans);font-size:13px;font-weight:600;cursor:pointer;
|
||||
border-radius:8px;padding:10px 18px;border:1px solid var(--border);background:var(--panel2);
|
||||
color:var(--text);transition:all .15s;white-space:nowrap}
|
||||
button:hover:not(:disabled){border-color:var(--primary)}
|
||||
button:disabled{opacity:.45;cursor:not-allowed}
|
||||
button.primary{background:var(--primary);border-color:var(--primary);color:#fff}
|
||||
button.primary:hover:not(:disabled){filter:brightness(1.08)}
|
||||
button.go{background:var(--teal);border-color:var(--teal);color:#06201c}
|
||||
button.go:hover:not(:disabled){filter:brightness(1.08)}
|
||||
.status{margin-top:14px;font-family:var(--mono);font-size:12.5px;padding:9px 12px;
|
||||
border-radius:8px;border:1px solid var(--border);background:var(--bg);display:none}
|
||||
.status.show{display:block}
|
||||
.status.ok{border-color:#245a52;color:var(--teal)}
|
||||
.status.err{border-color:#5a2b2f;color:var(--error)}
|
||||
.status.info{color:var(--muted)}
|
||||
.chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:12px}
|
||||
.chip{font-family:var(--mono);font-size:11.5px;color:var(--muted);
|
||||
border:1px solid var(--border);border-radius:20px;padding:3px 10px}
|
||||
.drop{border:1.5px dashed var(--border);border-radius:10px;padding:22px;text-align:center;
|
||||
color:var(--muted);cursor:pointer;transition:all .15s;background:var(--bg)}
|
||||
.drop:hover,.drop.drag{border-color:var(--primary);color:var(--text)}
|
||||
.drop b{color:var(--text);font-weight:600}
|
||||
.drop .fname{font-family:var(--mono);color:var(--teal);font-size:12.5px;margin-top:8px}
|
||||
.toggle{display:flex;align-items:center;gap:9px;cursor:pointer;user-select:none;font-size:13px}
|
||||
.toggle input{width:16px;height:16px;accent-color:var(--primary)}
|
||||
.summary{display:flex;gap:8px;flex-wrap:wrap;margin:18px 0 12px}
|
||||
.pill{font-family:var(--mono);font-size:12px;padding:4px 11px;border-radius:7px;
|
||||
border:1px solid var(--border);display:flex;gap:7px;align-items:center}
|
||||
.pill .c{width:8px;height:8px;border-radius:50%}
|
||||
.pill.create .c{background:var(--create)} .pill.warn .c{background:var(--warn)}
|
||||
.pill.exists .c{background:var(--exists)} .pill.error .c{background:var(--error)}
|
||||
.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}
|
||||
thead th{position:sticky;top:0;background:var(--panel2);text-align:left;color:var(--muted);
|
||||
font-weight:500;padding:8px 11px;border-bottom:1px solid var(--border);z-index:1;letter-spacing:.03em}
|
||||
tbody td{padding:7px 11px;border-bottom:1px solid #1e2534;vertical-align:top}
|
||||
tbody tr:last-child td{border-bottom:none}
|
||||
.st{font-weight:600;display:inline-flex;align-items:center;gap:6px}
|
||||
.st::before{content:"";width:7px;height:7px;border-radius:50%;display:inline-block}
|
||||
.st.create{color:var(--create)} .st.create::before{background:var(--create)}
|
||||
.st.warn{color:var(--warn)} .st.warn::before{background:var(--warn)}
|
||||
.st.exists{color:var(--exists)} .st.exists::before{background:var(--exists)}
|
||||
.st.error{color:var(--error)} .st.error::before{background:var(--error)}
|
||||
.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 b{font-size:20px;font-weight:650;display:block;letter-spacing:-.02em}
|
||||
.counts .ok b{color:var(--create)} .counts .skip b{color:var(--exists)} .counts .fail b{color:var(--error)}
|
||||
.counts span{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.06em}
|
||||
.log{background:var(--bg);border:1px solid var(--border);border-radius:9px;
|
||||
font-family:var(--mono);font-size:12px;max-height:360px;overflow:auto;padding:6px 0}
|
||||
.logrow{display:grid;grid-template-columns:64px 20px 1fr;gap:10px;padding:3px 14px;align-items:baseline}
|
||||
.logrow .t{color:#556}
|
||||
.logrow .m{white-space:pre-wrap;word-break:break-word}
|
||||
.logrow.ok .g{color:var(--create)} .logrow.ok .g::before{content:"✓"}
|
||||
.logrow.fail .g{color:var(--error)} .logrow.fail .g::before{content:"✕"}
|
||||
.logrow.fail .m{color:#f6a1a4}
|
||||
.progress{height:4px;background:var(--border);border-radius:3px;overflow:hidden;margin:2px 0 16px}
|
||||
.progress > i{display:block;height:100%;width:0;background:var(--teal);transition:width .2s}
|
||||
.foot{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-top:16px;flex-wrap:wrap}
|
||||
.hide{display:none}
|
||||
.muted{color:var(--muted);font-size:12.5px}
|
||||
.hint{font-size:11.5px;color:var(--muted);margin-top:7px;font-family:var(--mono)}
|
||||
.qwarn{border:1px solid #6b4a1f;background:#241a0e;border-radius:9px;padding:14px 16px;margin:4px 0 16px}
|
||||
.qwarn h3{margin:0 0 8px;font-size:13.5px;color:var(--warn);font-weight:650}
|
||||
.qwarn ul{margin:0;padding-left:18px}
|
||||
.qwarn li{font-family:var(--mono);font-size:12px;color:var(--text);margin:4px 0}
|
||||
.qwarn li b{color:var(--warn)}
|
||||
.qwarn .act{margin-top:12px;display:flex;gap:10px;align-items:center;flex-wrap:wrap}
|
||||
.qwarn .act .muted{font-size:11.5px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header class="top">
|
||||
<h1>mailcow<span class="dot"> · </span>bulk provisioning</h1>
|
||||
<p>импорт почтовых ящиков из CSV через API</p>
|
||||
</header>
|
||||
|
||||
<!-- 01 CONNECTION -->
|
||||
<section class="card">
|
||||
<div class="step"><span class="n">01</span><h2>Подключение к API</h2></div>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label class="f" for="baseUrl">Базовый URL mailcow</label>
|
||||
<input id="baseUrl" type="text" placeholder="https://mail.dom.ru" autocomplete="off">
|
||||
</div>
|
||||
<div>
|
||||
<label class="f" for="apiKey">API-ключ (Read-Write)</label>
|
||||
<input id="apiKey" type="password" placeholder="XXXXXX-XXXXXX-…" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row-inline">
|
||||
<div style="flex:0"><button id="btnTest" class="primary">Проверить подключение</button></div>
|
||||
</div>
|
||||
<div class="hint">Ключ создаётся в UI: Configuration → Access → Edit administrator details → API. Не забудьте добавить IP этого сервиса в allow-list.</div>
|
||||
<div id="testStatus" class="status"></div>
|
||||
<div id="domainChips" class="chips"></div>
|
||||
</section>
|
||||
|
||||
<!-- 02 SOURCE + PREVIEW -->
|
||||
<section class="card">
|
||||
<div class="step"><span class="n">02</span><h2>Файл и предпросмотр</h2></div>
|
||||
<div id="drop" class="drop" tabindex="0" role="button" aria-label="Выбрать CSV файл">
|
||||
<b>Перетащите CSV сюда</b> или нажмите для выбора
|
||||
<div>колонки: <span style="font-family:var(--mono)">login, password, name</span> (опц. quota, active) — разделитель <b>;</b> (также , или tab)</div>
|
||||
<div id="fname" class="fname"></div>
|
||||
</div>
|
||||
<div class="hint">Не знаете формат? <a href="/api/example" download>Скачать пример CSV</a> и заполнить своими данными.</div>
|
||||
<div class="row-inline">
|
||||
<div style="max-width:300px">
|
||||
<label class="f" for="domainSelect">Домен для импорта</label>
|
||||
<select id="domainSelect" disabled>
|
||||
<option value="">— брать из email в файле —</option>
|
||||
</select>
|
||||
<div class="hint">Выберите домен → в файле указывайте только логин без <b>@домена</b>. Список доменов появится после проверки подключения.</div>
|
||||
</div>
|
||||
</div>
|
||||
<input id="file" type="file" accept=".csv,text/csv" class="hide">
|
||||
<div class="row-inline">
|
||||
<div style="max-width:180px">
|
||||
<label class="f" for="quota">Квота по умолчанию, МиБ</label>
|
||||
<input id="quota" type="number" value="10240" min="1">
|
||||
</div>
|
||||
<div style="flex:0;padding-bottom:9px">
|
||||
<label class="toggle"><input id="forcePw" type="checkbox"> Требовать смену пароля при входе</label>
|
||||
</div>
|
||||
<div style="flex:0"><button id="btnPreview" disabled>Предпросмотр</button></div>
|
||||
</div>
|
||||
<div id="previewStatus" class="status"></div>
|
||||
|
||||
<div id="previewBlock" class="hide">
|
||||
<div id="quotaWarn" class="qwarn hide"></div>
|
||||
<div id="summary" class="summary"></div>
|
||||
<div class="tablewrap">
|
||||
<table>
|
||||
<thead><tr><th>#</th><th>email</th><th>имя</th><th>квота</th><th>статус</th><th>примечание</th></tr></thead>
|
||||
<tbody id="prevBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="foot">
|
||||
<span id="importHint" class="muted"></span>
|
||||
<button id="btnImport" class="go" disabled>Создать ящики</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 03 LOG -->
|
||||
<section id="logCard" class="card hide">
|
||||
<div class="step"><span class="n">03</span><h2>Журнал провижининга</h2></div>
|
||||
<div class="counts">
|
||||
<div class="ok"><b id="cOk">0</b><span>создано</span></div>
|
||||
<div class="fail"><b id="cFail">0</b><span>ошибок</span></div>
|
||||
<div><b id="cTotal">0</b><span>из всего</span></div>
|
||||
</div>
|
||||
<div class="progress"><i id="bar"></i></div>
|
||||
<div id="log" class="log"></div>
|
||||
<div class="foot">
|
||||
<span id="doneMsg" class="muted"></span>
|
||||
<div style="display:flex;gap:10px">
|
||||
<button id="btnRetry" class="go hide">Повторить неудавшиеся</button>
|
||||
<button id="btnDownload" disabled>Скачать лог (CSV)</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = s => document.querySelector(s);
|
||||
let previewRows = []; // normalized rows from /api/preview
|
||||
let importResults = []; // collected for CSV download
|
||||
|
||||
function creds(){ return { baseUrl: $('#baseUrl').value.trim(), apiKey: $('#apiKey').value.trim() }; }
|
||||
function setStatus(el, cls, text){ el.className = 'status show ' + cls; el.textContent = text; }
|
||||
|
||||
// prefill from server env
|
||||
fetch('/api/config').then(r=>r.json()).then(c=>{
|
||||
if(c.baseUrl) $('#baseUrl').value = c.baseUrl;
|
||||
if(c.defaultQuota) $('#quota').value = c.defaultQuota;
|
||||
if(c.hasEnvKey) $('#apiKey').placeholder = '•••••• (взят из окружения)';
|
||||
}).catch(()=>{});
|
||||
|
||||
// --- 01 test ---
|
||||
$('#btnTest').onclick = async () => {
|
||||
const b = $('#btnTest'); b.disabled = true;
|
||||
setStatus($('#testStatus'), 'info', 'Проверяю…');
|
||||
$('#domainChips').innerHTML = '';
|
||||
try{
|
||||
const r = await fetch('/api/test', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(creds())});
|
||||
const j = await r.json();
|
||||
if(j.ok){
|
||||
setStatus($('#testStatus'), 'ok', '● ' + j.message);
|
||||
const domains = j.domains || [];
|
||||
domains.slice(0,40).forEach(d=>{
|
||||
const c=document.createElement('span'); c.className='chip'; c.textContent=d; $('#domainChips').appendChild(c);
|
||||
});
|
||||
fillDomains(domains);
|
||||
} else setStatus($('#testStatus'), 'err', '● ' + j.message);
|
||||
}catch(e){ setStatus($('#testStatus'), 'err', 'ошибка сети: '+e.message); }
|
||||
b.disabled = false;
|
||||
};
|
||||
|
||||
// populate the domain selector, keeping the current choice if still valid
|
||||
function fillDomains(domains){
|
||||
const sel = $('#domainSelect'), prev = sel.value;
|
||||
sel.innerHTML = '<option value="">— брать из email в файле —</option>';
|
||||
domains.forEach(d=>{
|
||||
const o=document.createElement('option'); o.value=d; o.textContent=d; sel.appendChild(o);
|
||||
});
|
||||
if(domains.includes(prev)) sel.value = prev;
|
||||
sel.disabled = domains.length === 0;
|
||||
}
|
||||
|
||||
// --- 02 file pick ---
|
||||
const drop = $('#drop'), fileInput = $('#file');
|
||||
drop.onclick = () => fileInput.click();
|
||||
drop.onkeydown = e => { if(e.key==='Enter'||e.key===' '){ e.preventDefault(); fileInput.click(); } };
|
||||
['dragover','dragenter'].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.add('drag');}));
|
||||
['dragleave','drop'].forEach(ev=>drop.addEventListener(ev,e=>{e.preventDefault();drop.classList.remove('drag');}));
|
||||
drop.addEventListener('drop', e=>{ if(e.dataTransfer.files.length){ fileInput.files=e.dataTransfer.files; onFile(); } });
|
||||
fileInput.onchange = onFile;
|
||||
function onFile(){
|
||||
if(!fileInput.files.length) return;
|
||||
$('#fname').textContent = '▸ ' + fileInput.files[0].name;
|
||||
$('#btnPreview').disabled = false;
|
||||
}
|
||||
|
||||
// --- 02 preview ---
|
||||
$('#btnPreview').onclick = runPreview;
|
||||
async function runPreview(){
|
||||
if(!fileInput.files.length){ setStatus($('#previewStatus'),'err','файл не выбран'); return; }
|
||||
const b = $('#btnPreview'); b.disabled = true;
|
||||
$('#previewBlock').classList.add('hide');
|
||||
setStatus($('#previewStatus'), 'info', 'Разбираю файл…');
|
||||
const fd = new FormData();
|
||||
fd.append('file', fileInput.files[0]);
|
||||
fd.append('baseUrl', creds().baseUrl);
|
||||
fd.append('apiKey', creds().apiKey);
|
||||
fd.append('defaultQuota', $('#quota').value);
|
||||
fd.append('domain', $('#domainSelect').value);
|
||||
try{
|
||||
const r = await fetch('/api/preview', {method:'POST', body:fd});
|
||||
const j = await r.json();
|
||||
if(j.error){ setStatus($('#previewStatus'),'err', j.error); b.disabled=false; return; }
|
||||
previewRows = j.rows || [];
|
||||
renderPreview(j);
|
||||
const note = j.enriched ? 'проверено против mailcow (домены + дубликаты + квоты)' : (j.enrichNote||'');
|
||||
setStatus($('#previewStatus'), j.enriched?'ok':'info', (j.enriched?'● ':'')+ 'строк: '+j.total+' · '+note);
|
||||
}catch(e){ setStatus($('#previewStatus'),'err','ошибка: '+e.message); }
|
||||
b.disabled = false;
|
||||
}
|
||||
|
||||
function renderPreview(j){
|
||||
const s = j.summary||{};
|
||||
const labels = {create:'к созданию', warn:'с предупреждением', exists:'уже есть', error:'ошибки'};
|
||||
$('#summary').innerHTML = ['create','warn','exists','error'].map(k=>
|
||||
`<span class="pill ${k}"><span class="c"></span>${labels[k]}: ${s[k]||0}</span>`).join('');
|
||||
const body = $('#prevBody'); body.innerHTML='';
|
||||
previewRows.forEach(r=>{
|
||||
const tr=document.createElement('tr');
|
||||
tr.innerHTML =
|
||||
`<td>${r.line}</td>`+
|
||||
`<td>${esc(r.email)}</td>`+
|
||||
`<td>${esc(r.name||'—')}</td>`+
|
||||
`<td>${esc(r.quota)}</td>`+
|
||||
`<td><span class="st ${r.status}">${statusText(r.status)}</span></td>`+
|
||||
`<td class="note">${(r.notes||[]).map(esc).join('<br>')||''}</td>`;
|
||||
body.appendChild(tr);
|
||||
});
|
||||
const importable = previewRows.filter(r=>r.importable).length;
|
||||
$('#previewBlock').classList.remove('hide');
|
||||
|
||||
// domain-quota shortfall: warn and block import until quota is raised in mailcow
|
||||
const warns = j.quotaWarnings || [];
|
||||
const qw = $('#quotaWarn');
|
||||
if(warns.length){
|
||||
qw.innerHTML =
|
||||
'<h3>⚠ Не хватает общей квоты домена — импорт остановлен</h3>'+
|
||||
'<ul>'+warns.map(w=>
|
||||
`<li><b>${esc(w.domain)}</b>: нужно <b>${gb(w.neededMiB)}</b>, свободно <b>${gb(w.freeMiB)}</b> `+
|
||||
`(квота домена ${gb(w.domainMiB)}, занято ${gb(w.usedMiB)}). `+
|
||||
`Поднимите Domain quota до <b>≥ ${gb(w.suggestMiB)}</b>.</li>`).join('')+'</ul>'+
|
||||
'<div class="act"><button id="btnRecheck" class="primary">Повторить проверку</button>'+
|
||||
'<span class="muted">Поправьте квоту домена в админке mailcow → нажмите «Повторить проверку» (файл перезагружать не нужно).</span></div>';
|
||||
qw.classList.remove('hide');
|
||||
$('#btnRecheck').onclick = runPreview;
|
||||
} else {
|
||||
qw.classList.add('hide'); qw.innerHTML='';
|
||||
}
|
||||
|
||||
const blocked = warns.length > 0;
|
||||
$('#btnImport').disabled = importable===0 || blocked;
|
||||
$('#importHint').textContent = blocked
|
||||
? 'создание заблокировано — сначала увеличьте квоту домена (см. предупреждение выше)'
|
||||
: importable
|
||||
? `будет создано ${importable} ящик(ов); строки "уже есть"/"ошибки" пропускаются`
|
||||
: 'нет строк, пригодных к созданию';
|
||||
$('#btnImport').textContent = importable ? `Создать ${importable} ящик(ов)` : 'Создать ящики';
|
||||
}
|
||||
const gb = mib => mib >= 1024 ? (mib/1024).toFixed(mib%1024?1:0)+' ГБ' : mib+' МиБ';
|
||||
const statusText = s => ({create:'создать',warn:'создать (!)',exists:'пропуск',error:'ошибка'}[s]||s);
|
||||
const esc = s => String(s==null?'':s).replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]));
|
||||
|
||||
// --- 03 import (streamed NDJSON) ---
|
||||
$('#btnImport').onclick = () => runImport(previewRows.filter(r=>r.importable));
|
||||
|
||||
// retry only the rows that failed last run — no need to re-upload the file
|
||||
$('#btnRetry').onclick = () => {
|
||||
const failed = new Set(importResults.filter(r=>!r.ok).map(r=>r.email));
|
||||
const rows = previewRows.filter(r=>r.importable && failed.has(r.email));
|
||||
if(rows.length) runImport(rows);
|
||||
};
|
||||
|
||||
async function runImport(rows){
|
||||
if(!rows.length) return;
|
||||
$('#btnImport').disabled = true; $('#btnPreview').disabled = true; $('#btnRetry').disabled = true;
|
||||
$('#btnRetry').classList.add('hide');
|
||||
$('#logCard').classList.remove('hide');
|
||||
$('#log').innerHTML=''; importResults=[];
|
||||
let ok=0, fail=0; const total=rows.length;
|
||||
$('#cOk').textContent=0; $('#cFail').textContent=0; $('#cTotal').textContent=total;
|
||||
$('#bar').style.width='0%'; $('#doneMsg').textContent=''; $('#btnDownload').disabled=true;
|
||||
$('#logCard').scrollIntoView({behavior:'smooth'});
|
||||
|
||||
const body = JSON.stringify({...creds(), forcePwUpdate: $('#forcePw').checked, rows});
|
||||
try{
|
||||
const resp = await fetch('/api/import', {method:'POST', headers:{'Content-Type':'application/json'}, body});
|
||||
if(!resp.ok){ const j=await resp.json().catch(()=>({error:'HTTP '+resp.status})); appendLog({ok:false,message:j.error||'ошибка',email:'—',ts:''}); $('#btnPreview').disabled=false; return; }
|
||||
const reader = resp.body.getReader(); const dec=new TextDecoder(); let buf='';
|
||||
while(true){
|
||||
const {done,value}=await reader.read(); if(done) break;
|
||||
buf += dec.decode(value,{stream:true});
|
||||
let nl; while((nl=buf.indexOf('\n'))>=0){
|
||||
const line=buf.slice(0,nl); buf=buf.slice(nl+1);
|
||||
if(!line.trim()) continue;
|
||||
const res=JSON.parse(line); importResults.push(res);
|
||||
if(res.ok) ok++; else fail++;
|
||||
$('#cOk').textContent=ok; $('#cFail').textContent=fail;
|
||||
$('#bar').style.width = Math.round((res.index/res.total)*100)+'%';
|
||||
appendLog(res);
|
||||
}
|
||||
}
|
||||
$('#doneMsg').textContent = `готово: создано ${ok}, ошибок ${fail} из ${total}`;
|
||||
$('#btnDownload').disabled = false;
|
||||
// offer to retry just the failures (e.g. after raising domain quota in mailcow)
|
||||
if(fail>0){
|
||||
$('#btnRetry').textContent = `Повторить неудавшиеся (${fail})`;
|
||||
$('#btnRetry').classList.remove('hide'); $('#btnRetry').disabled = false;
|
||||
}
|
||||
}catch(e){ appendLog({ok:false,message:'поток прерван: '+e.message,email:'—',ts:''}); }
|
||||
$('#btnPreview').disabled = false;
|
||||
}
|
||||
|
||||
function appendLog(r){
|
||||
const div=document.createElement('div');
|
||||
div.className='logrow '+(r.ok?'ok':'fail');
|
||||
div.innerHTML = `<span class="t">${esc(r.ts||'')}</span><span class="g"></span>`+
|
||||
`<span class="m"><b>${esc(r.email)}</b>${r.httpCode?` <span class="t">[${r.httpCode}]</span>`:''} — ${esc(r.message||'')}</span>`;
|
||||
const log=$('#log'); log.appendChild(div); log.scrollTop=log.scrollHeight;
|
||||
}
|
||||
|
||||
// --- download log ---
|
||||
$('#btnDownload').onclick = () => {
|
||||
const head='email,ok,http_code,message\n';
|
||||
const rows=importResults.map(r=>[r.email, r.ok?'ok':'fail', r.httpCode, '"'+String(r.message||'').replace(/"/g,'""')+'"'].join(',')).join('\n');
|
||||
const blob=new Blob([head+rows],{type:'text/csv'});
|
||||
const a=document.createElement('a'); a.href=URL.createObjectURL(blob);
|
||||
a.download='mcimport-log-'+new Date().toISOString().slice(0,19).replace(/[:T]/g,'-')+'.csv';
|
||||
a.click(); URL.revokeObjectURL(a.href);
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,716 @@
|
||||
// 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))
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user