|
|
<!DOCTYPE html>
|
|
|
<html lang="zh-CN">
|
|
|
<head>
|
|
|
<meta charset="UTF-8">
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
<title>邮件批量通知工具</title>
|
|
|
<style>
|
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
|
body {
|
|
|
margin: 0; padding: 24px 16px;
|
|
|
background: #0b0f14;
|
|
|
color: #e5e7eb;
|
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial;
|
|
|
min-height: 100vh;
|
|
|
}
|
|
|
.container { max-width: 1200px; margin: auto; }
|
|
|
.card {
|
|
|
background: rgba(255,255,255,0.04);
|
|
|
border: 1px solid rgba(255,255,255,0.08);
|
|
|
border-radius: 14px;
|
|
|
padding: 18px;
|
|
|
margin-bottom: 14px;
|
|
|
}
|
|
|
h2 { margin: 0 0 10px 0; font-size: 20px; }
|
|
|
h3 { margin: 10px 0 6px 0; font-size: 16px; }
|
|
|
.badge {
|
|
|
display: inline-block;
|
|
|
padding: 3px 10px; font-size: 12px;
|
|
|
border-radius: 999px;
|
|
|
background: rgba(59,130,246,0.15);
|
|
|
border: 1px solid rgba(59,130,246,0.35);
|
|
|
color: #60a5fa;
|
|
|
}
|
|
|
.grid {
|
|
|
display: grid;
|
|
|
grid-template-columns: 1fr 1fr;
|
|
|
gap: 12px;
|
|
|
margin-top: 10px;
|
|
|
}
|
|
|
.grid3 {
|
|
|
display: grid;
|
|
|
grid-template-columns: 1fr 1fr 1fr;
|
|
|
gap: 12px;
|
|
|
margin-top: 10px;
|
|
|
}
|
|
|
input, textarea, select {
|
|
|
width: 100%; padding: 10px 12px;
|
|
|
border-radius: 10px;
|
|
|
background: #111827;
|
|
|
border: 1px solid rgba(255,255,255,0.12);
|
|
|
color: #e5e7eb;
|
|
|
outline: none; font-size: 14px; font-family: inherit;
|
|
|
}
|
|
|
textarea { min-height: 400px; resize: vertical; }
|
|
|
input:focus, textarea:focus { border-color: #2563eb; }
|
|
|
button {
|
|
|
margin-top: 12px; padding: 10px 14px;
|
|
|
border: none; border-radius: 10px;
|
|
|
cursor: pointer; font-weight: 600; font-size: 14px; margin-right: 8px;
|
|
|
}
|
|
|
.btn-primary { background: #2563eb; color: #fff; }
|
|
|
.btn-primary:hover { background: #1d4ed8; }
|
|
|
.btn-green { background: #22c55e; color: #0b0f14; }
|
|
|
.btn-green:hover { background: #16a34a; }
|
|
|
.btn-gray { background: #374151; color: #e5e7eb; }
|
|
|
.btn-gray:hover { background: #4b5563; }
|
|
|
.hint { font-size: 12px; color: #9ca3af; margin-top: 6px; }
|
|
|
|
|
|
/* Progress */
|
|
|
#progress-area { display: none; }
|
|
|
.progress-bar-bg {
|
|
|
width: 100%; height: 20px;
|
|
|
background: #1f2937; border-radius: 999px;
|
|
|
overflow: hidden; margin: 8px 0;
|
|
|
}
|
|
|
.progress-bar-fill {
|
|
|
height: 100%; width: 0%;
|
|
|
background: linear-gradient(90deg, #2563eb, #22c55e);
|
|
|
border-radius: 999px;
|
|
|
transition: width 0.5s;
|
|
|
}
|
|
|
.stat-row { display: flex; gap: 20px; font-size: 14px; margin: 6px 0; }
|
|
|
.stat-item { display: flex; align-items: center; gap: 4px; }
|
|
|
#result-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 8px; }
|
|
|
#result-table th, #result-table td {
|
|
|
padding: 6px 10px; border-bottom: 1px solid rgba(255,255,255,0.06);
|
|
|
text-align: left;
|
|
|
}
|
|
|
.status-success { color: #22c55e; }
|
|
|
.status-failed { color: #ef4444; }
|
|
|
|
|
|
/* Toast & Loading */
|
|
|
#toast {
|
|
|
position: fixed; bottom: 30px; left: 50%;
|
|
|
transform: translateX(-50%);
|
|
|
padding: 10px 14px; border-radius: 10px;
|
|
|
font-size: 13px; display: none; z-index: 9999;
|
|
|
}
|
|
|
#loading {
|
|
|
display: none; position: fixed;
|
|
|
top: 0; left: 0; right: 0; bottom: 0;
|
|
|
background: rgba(0,0,0,0.4);
|
|
|
align-items: center; justify-content: center; z-index: 9998;
|
|
|
}
|
|
|
.spinner {
|
|
|
width: 40px; height: 40px;
|
|
|
border: 4px solid #374151;
|
|
|
border-top: 4px solid #60a5fa;
|
|
|
border-radius: 50%;
|
|
|
animation: spin 1s linear infinite;
|
|
|
}
|
|
|
@keyframes spin { 0%{transform:rotate(0)} 100%{transform:rotate(360deg)} }
|
|
|
|
|
|
@media (max-width: 600px) {
|
|
|
.grid, .grid3 { grid-template-columns: 1fr; }
|
|
|
body { padding: 16px 12px; }
|
|
|
textarea { min-height: 280px; }
|
|
|
button { width: 100%; margin-right: 0; margin-bottom: 8px; }
|
|
|
}
|
|
|
</style>
|
|
|
</head>
|
|
|
<body data-task-id="{{task_id or ""}}">
|
|
|
<div class="container">
|
|
|
<form method="post" action="/tool/mail_notify" enctype="multipart/form-data" id="sendForm">
|
|
|
<input type="hidden" name="action" id="action" value="send">
|
|
|
|
|
|
<div class="card">
|
|
|
<h2>📧 邮件批量通知工具 <span class="badge">SMTP · TXT Batch</span></h2>
|
|
|
<div class="hint">上传 TXT → 自动解析账号密码 → 批量发送邮件通知(含自动重试 × 3)</div>
|
|
|
</div>
|
|
|
|
|
|
<div class="card">
|
|
|
<h3>🔧 SMTP 配置</h3>
|
|
|
<div class="grid3">
|
|
|
<input name="smtp_server" placeholder="SMTP服务器" value="{{smtp_server or 'smtp.ctvit.com.cn'}}">
|
|
|
<input name="smtp_port" placeholder="端口" value="{{smtp_port or '25'}}">
|
|
|
<input name="reply_to" placeholder="回复地址(可选)" value="{{reply_to or ''}}">
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<div class="card">
|
|
|
<h3>📬 发件人</h3>
|
|
|
<div class="grid3">
|
|
|
<input name="sender_email" placeholder="发件邮箱" value="{{sender_email or ''}}">
|
|
|
<input name="sender_pass" type="password" placeholder="SMTP密码 / 授权码" value="" autocomplete="off">
|
|
|
<input name="subject" placeholder="邮件主题" value="{{subject or '账号及初始口令通知'}}">
|
|
|
</div>
|
|
|
<div class="hint">🔒 密码仅用于本次请求,不回传、不存储</div>
|
|
|
</div>
|
|
|
|
|
|
<div class="card">
|
|
|
<h3>📝 邮件模板</h3>
|
|
|
<textarea name="template_content">{% raw %}尊敬的老师/同事:
|
|
|
|
|
|
您好!
|
|
|
|
|
|
根据系统账号开通安排,您的账号已创建完成。现将相关登录信息通知如下,请妥善保存并注意账号安全。
|
|
|
|
|
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
|
|
|
|
账号(邮箱):{{email}}
|
|
|
初始口令:{{password}}
|
|
|
|
|
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
|
|
|
|
请在首次登录后及时修改初始口令,并妥善保管个人账号信息。
|
|
|
|
|
|
此致
|
|
|
敬礼!
|
|
|
|
|
|
信息技术支持中心
|
|
|
{% endraw %}</textarea>
|
|
|
<div class="hint">支持变量:<code>{<!-- -->{email}}</code>、<code>{<!-- -->{password}}</code></div>
|
|
|
</div>
|
|
|
|
|
|
<div class="card">
|
|
|
<h3>📎 用户列表</h3>
|
|
|
<input type="file" name="user_file" id="user_file" required>
|
|
|
<div class="hint">TXT格式:每行"邮箱 密码"(空格 / | / , / tab 均支持)</div>
|
|
|
<br>
|
|
|
<button class="btn-primary" type="submit" onclick="setAction('send')">🚀 开始群发</button>
|
|
|
<button class="btn-green" type="submit" onclick="setAction('test')">🧪 测试发送(发给自己)</button>
|
|
|
</div>
|
|
|
</form>
|
|
|
|
|
|
{% if message %}
|
|
|
<div class="card"><div>{{message}}</div></div>
|
|
|
{% endif %}
|
|
|
|
|
|
<!-- ========== 进度区域(异步群发时显示) ========== -->
|
|
|
<div id="progress-area" class="card">
|
|
|
<h3>⏳ 发送进度</h3>
|
|
|
<div class="stat-row">
|
|
|
<span class="stat-item">📤 总计:<strong id="p-total">0</strong></span>
|
|
|
<span class="stat-item">✅ 成功:<strong id="p-success" style="color:#22c55e">0</strong></span>
|
|
|
<span class="stat-item">❌ 失败:<strong id="p-failed" style="color:#ef4444">0</strong></span>
|
|
|
<span class="stat-item">📶 进度:<strong id="p-pct">0%</strong></span>
|
|
|
</div>
|
|
|
<div class="progress-bar-bg">
|
|
|
<div id="progress-fill" class="progress-bar-fill"></div>
|
|
|
</div>
|
|
|
<table id="result-table">
|
|
|
<thead><tr><th>邮箱</th><th>状态</th></tr></thead>
|
|
|
<tbody id="result-body"></tbody>
|
|
|
</table>
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div id="loading"><div class="spinner"></div></div>
|
|
|
<div id="toast"></div>
|
|
|
|
|
|
<script>
|
|
|
let pollTimer = null;
|
|
|
|
|
|
function setAction(type){
|
|
|
document.getElementById("action").value = type;
|
|
|
}
|
|
|
|
|
|
function showToast(msg, color){
|
|
|
const t = document.getElementById("toast");
|
|
|
t.innerText = msg;
|
|
|
t.style.background = color || "rgba(34,197,94,0.95)";
|
|
|
t.style.display = "block";
|
|
|
setTimeout(() => t.style.display = "none", 3000);
|
|
|
}
|
|
|
|
|
|
// 异步任务进度轮询
|
|
|
function startPoll(taskId){
|
|
|
const area = document.getElementById("progress-area");
|
|
|
area.style.display = "block";
|
|
|
document.getElementById("result-body").innerHTML = "";
|
|
|
document.getElementById("progress-fill").style.width = "0%";
|
|
|
|
|
|
if(pollTimer) clearInterval(pollTimer);
|
|
|
|
|
|
pollTimer = setInterval(() => {
|
|
|
fetch("/tool/mail_notify/progress/" + taskId)
|
|
|
.then(r => r.json())
|
|
|
.then(d => {
|
|
|
if(d.error){
|
|
|
clearInterval(pollTimer);
|
|
|
return;
|
|
|
}
|
|
|
const total = d.total || 0;
|
|
|
const done = d.current || 0;
|
|
|
const success = d.success || 0;
|
|
|
const failed = d.failed || 0;
|
|
|
const pct = total > 0 ? Math.round(done / total * 100) : 0;
|
|
|
|
|
|
document.getElementById("p-total").innerText = total;
|
|
|
document.getElementById("p-success").innerText = success;
|
|
|
document.getElementById("p-failed").innerText = failed;
|
|
|
document.getElementById("p-pct").innerText = pct + "%";
|
|
|
document.getElementById("progress-fill").style.width = pct + "%";
|
|
|
|
|
|
// 结果列表
|
|
|
const results = d.results || [];
|
|
|
let html = "";
|
|
|
for(let i = results.length - 20; i < results.length; i++){
|
|
|
const r = results[i];
|
|
|
if(!r) continue;
|
|
|
const cls = r.status === "success" ? "status-success" : "status-failed";
|
|
|
html += "<tr><td>" + r.email + "</td><td class='" + cls + "'>" + r.status + "</td></tr>";
|
|
|
}
|
|
|
document.getElementById("result-body").innerHTML = html;
|
|
|
|
|
|
// 完成
|
|
|
if(d.status === "done" || d.status === "error"){
|
|
|
clearInterval(pollTimer);
|
|
|
pollTimer = null;
|
|
|
if(d.error){
|
|
|
showToast("❌ 发送异常:" + d.error, "rgba(239,68,68,0.95)");
|
|
|
} else if(failed > 0) {
|
|
|
showToast("✅ 发送完成:成功 " + success + " / 失败 " + failed, "rgba(34,197,94,0.95)");
|
|
|
} else {
|
|
|
showToast("🎉 全部发送成功!共 " + success + " 封", "rgba(34,197,94,0.95)");
|
|
|
}
|
|
|
}
|
|
|
})
|
|
|
.catch(e => {
|
|
|
// 忽略网络错误,继续轮询
|
|
|
});
|
|
|
}, 1500);
|
|
|
}
|
|
|
|
|
|
// 页面加载时检测 task_id 自动启动轮询
|
|
|
document.addEventListener("DOMContentLoaded", function(){
|
|
|
const taskId = document.body.getAttribute("data-task-id");
|
|
|
if(taskId && taskId.length === 32){
|
|
|
startPoll(taskId);
|
|
|
}
|
|
|
});
|
|
|
|
|
|
// 提交表单时显示 loading
|
|
|
document.getElementById("sendForm").addEventListener("submit", function(){
|
|
|
document.getElementById("loading").style.display = "flex";
|
|
|
});
|
|
|
</script>
|
|
|
</body>
|
|
|
</html>
|