416 lines
22 KiB
HTML
416 lines
22 KiB
HTML
<!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>
|