feat(admin): integrate mail flow controls into email settings page

This commit is contained in:
hellcat 2026-10-11 09:48:58 +08:00
parent d51fb5e398
commit 966a8b2c32
3 changed files with 163 additions and 125 deletions

View File

@ -166,7 +166,7 @@ class ConfigSave extends FormRequest
}
$required = [];
if (!empty($settings['resend_enabled'])) {
$required = array_merge($required, ['resend_key', 'resend_from', 'resend_webhook_secret']);
$required = array_merge($required, ['resend_key', 'resend_from']);
}
if (!empty($settings['inbound_enabled'])) {
$required = array_merge($required, ['imap_host', 'imap_username', 'imap_password']);

View File

@ -1,14 +1,7 @@
// Standalone settings panel, using the existing authenticated admin API.
const host = document.createElement('div');
host.id = 'mail-flow-tools';
const style = document.createElement('style');
style.textContent = `#mail-flow-tools .entry{position:fixed;right:24px;bottom:72px;z-index:40;padding:9px 14px;border:1px solid #cbd5e1;border-radius:8px;background:#fff;color:#0f172a;cursor:pointer}#mail-flow-dialog{width:min(760px,calc(100vw - 40px));max-height:85vh;overflow:auto;border:1px solid #cbd5e1;border-radius:12px;padding:24px;background:#fff;color:#0f172a}#mail-flow-dialog::backdrop{background:#0007}#mail-flow-dialog fieldset{border:1px solid #e2e8f0;border-radius:8px;margin:14px 0;padding:12px}#mail-flow-dialog label{display:block;margin:10px 0;font-size:14px}#mail-flow-dialog select,#mail-flow-dialog input:not([type=checkbox]),#mail-flow-dialog textarea{display:block;width:100%;padding:7px;border:1px solid #cbd5e1;border-radius:5px;box-sizing:border-box}#mail-flow-dialog button{padding:8px 14px;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer}#mail-flow-dialog pre{white-space:pre-wrap;font-size:12px}#mail-flow-dialog p{font-size:13px;line-height:1.6}`;
host.append(style);
const entry = document.createElement('button'); entry.className='entry'; entry.textContent='邮件渠道与自动回复'; entry.type='button'; entry.hidden=true; host.append(entry);
const dialog=document.createElement('dialog'); dialog.id='mail-flow-dialog'; host.append(dialog); document.body.append(host);
const visibility=()=>{entry.hidden=location.hash.split('?')[0]!=='#/config/system/email' || !document.querySelector('#root main'); if(entry.hidden && dialog.open) dialog.close();};
window.addEventListener('hashchange',visibility);
new MutationObserver(visibility).observe(document.getElementById('root'),{childList:true,subtree:true}); visibility();
// Extend the existing email settings page using its authenticated admin API and styles.
const fieldClass='flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50';
const buttonClass='inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-9 px-4 py-2';
function el(tag, text, className){const node=document.createElement(tag);if(text)node.textContent=text;if(className)node.className=className;return node;}
async function api(action, payload) {
let token; try { token=JSON.parse(localStorage.getItem('XBOARD_ACCESS_TOKEN')||'null'); } catch { throw new Error('请重新登录后台'); }
if(!token?.value || (token.expire!=null && token.expire<=Date.now())) throw new Error('请重新登录后台');
@ -21,60 +14,86 @@ async function api(action, payload) {
return data.data;
}
const groups=[
['发送渠道', [['enabled','启用邮件记录与分流','checkbox'],['resend_enabled','验证码、登录链接和工单通知优先 Resend','checkbox'],['resend_key','Resend API 密钥','password'],['resend_from','Resend 发件地址','email'],['resend_webhook_secret','Resend 回调签名密钥','password'],['reply_to','统一回复地址(常规邮箱)','email'],['daily_limit','日额度上限','number'],['monthly_limit','月额度上限','number']]],
['发送渠道', [['enabled','启用邮件记录与分流','checkbox'],['resend_enabled','验证码、登录链接和工单通知优先 Resend','checkbox'],['resend_key','Resend API 密钥','password'],['resend_from','Resend 发件地址','email'],['resend_webhook_secret','Resend 回调签名密钥(选填)','password'],['reply_to','统一回复地址(常规邮箱)','email'],['daily_limit','日额度上限','number'],['monthly_limit','月额度上限','number']]],
['常规邮箱收件与自动回复', [['inbound_enabled','启用收件','checkbox'],['imap_host','IMAP 主机','text'],['imap_port','IMAP TLS 端口','number'],['imap_username','IMAP 用户名','text'],['imap_password','IMAP 密码','password'],['imap_folder','收件文件夹','text'],['auto_reply_enabled','自动确认收件(按发件地址限频)','checkbox'],['auto_reply_subject','自动回复标题','text'],['auto_reply_text','自动回复内容','textarea']]],
['每天八点汇报', [['report_enabled','启用 master 汇报','checkbox'],['master_url','Master 基础地址(HTTPS)','url'],['master_bot_code','Master 机器人代号','text'],['master_chat_code','Master 接收会话代号','text']]],
];
entry.addEventListener('click',async()=>{
dialog.textContent='正在读取…'; dialog.showModal(); entry.disabled=true;
try {
const data=await api('mailFlowStatus'); dialog.replaceChildren();
const heading=document.createElement('h2'); heading.textContent='邮件渠道与自动回复'; dialog.append(heading);
const note=document.createElement('p'); note.textContent='群发、流量和到期提醒仍用原常规邮箱。Resend 额度不足时回退;收件固定用常规邮箱。密钥留空保留。自动回复仅处理启用之后的新邮件,并限制每个发件地址北京时间每天一次,无需绑定账户。'; dialog.append(note);
const readiness=document.createElement('p'); readiness.textContent=(data.database_ready?'数据库迁移已就绪。':'数据库尚未迁移,暂不可启用。')+(data.imap_available?' IMAP 扩展可用。':' 收件需要安装 PHP IMAP 扩展。'); dialog.append(readiness);
const form=document.createElement('form'); dialog.append(form); const inputs={};
const tierLabel=document.createElement('label'); tierLabel.textContent='自动邮件档位';
const tier=document.createElement('select'); tier.name='automatic_mail_level';
for(const [value,label] of [[1,'一档:保持原有提醒'],[2,'二档:仅套餐到期时提醒'],[3,'三档:关闭自动批量提醒']]) {
const option=document.createElement('option'); option.value=String(value); option.textContent=label; tier.append(option);
function control(key,label,type,value,configured){
const wrap=el('div',null,'space-y-2');
const id='mail-flow-'+key;
const caption=el('label',label,'font-medium text-base');caption.htmlFor=id;wrap.append(caption);
let input;
if(type==='checkbox'){
input=el('button');input.type='button';input.setAttribute('role','switch');input.checked=!!value;
input.className='peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input';
const thumb=el('span',null,'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0');input.append(thumb);
const sync=()=>{const state=input.checked?'checked':'unchecked';input.dataset.state=state;thumb.dataset.state=state;input.setAttribute('aria-checked',String(input.checked));};
input.addEventListener('click',()=>{input.checked=!input.checked;sync();});sync();
const row=el('div',null,'flex items-center justify-between gap-4');wrap.replaceChildren(row);row.append(caption,input);
}else{
input=el(type==='textarea'?'textarea':'input');if(type!=='textarea')input.type=type;
input.className=fieldClass+(type==='textarea'?' min-h-24 py-2':'');
if(type==='password'){input.autocomplete='new-password';input.placeholder=configured?'已配置,留空保留':'尚未配置';}
else input.value=value??'';
if(type==='number'){input.step='1';input.min='1';if(key==='daily_limit')input.max='100';if(key==='monthly_limit')input.max='3000';if(key==='imap_port')input.max='65535';}
wrap.append(input);
}
input.id=id;input.name=key;
return {wrap,input,type};
}
async function render(host){
const loading=el('p','正在读取邮件渠道设置…','text-sm text-muted-foreground');host.append(loading);
try{
const data=await api('mailFlowStatus');if(!host.isConnected)return;
if(data.automatic_mail_level==null)throw new Error('自动邮件档位暂不可读取,请稍后重试');
const form=el('form',null,'space-y-6');const inputs={};
const policy=el('section',null,'space-y-4');policy.append(el('h3','自动邮件档位','text-lg font-semibold'));
const tierWrap=el('div',null,'space-y-2');const label=el('label','自动邮件档位','font-medium text-base');label.htmlFor='mail-flow-tier';
const tier=el('select');tier.id='mail-flow-tier';tier.name='automatic_mail_level';tier.className=fieldClass;
for(const [value,text] of [[1,'一档:保持原有提醒'],[2,'二档:仅套餐到期时提醒'],[3,'三档:关闭自动批量提醒']]){const option=el('option',text);option.value=String(value);tier.append(option);}
tier.value=String(data.automatic_mail_level);tierWrap.append(label,tier,el('p','管理员人工群发、验证码、登录邮件、工单回应通知和来信自动回复始终允许。到期通知须同时满足后台允许和用户未关闭提醒。','text-[0.8rem] text-muted-foreground'));policy.append(tierWrap);form.append(policy);
for(const [title,fields] of groups){
const section=el('section',null,'space-y-4 border-t pt-6');section.append(el('h3',title==='发送渠道'?'Resend 与邮件发送渠道':title,'text-lg font-semibold'));
if(title==='发送渠道')section.append(el('p','Resend 额度不足时使用原常规邮箱。其他通知仍走常规邮箱;密钥留空保留现有值。','text-sm text-muted-foreground'));
if(title==='常规邮箱收件与自动回复')section.append(el('p','收件及自动回复使用常规邮箱。每个发件地址北京时间每天仅回复一次,无需绑定网站账号。','text-sm text-muted-foreground'));
if(title==='每天八点汇报')section.append(el('p','北京时间每天早上八点汇报;机器人和接收会话代号留空使用 master 默认配置。','text-sm text-muted-foreground'));
for(const [key,text,type] of fields){const item=control(key,text,type,data.settings[key],data.settings[key+'_configured']);inputs[key]=item;section.append(item.wrap);}
form.append(section);
}
if(data.automatic_mail_level==null) throw new Error('自动邮件档位暂不可读取,请稍后重试');
tier.value=String(data.automatic_mail_level); tierLabel.append(tier); form.append(tierLabel);
const tierNote=document.createElement('p'); tierNote.textContent='人工群发、验证码、登录、工单回复和收件自动回复均不受档位影响。二档保留原提醒开关与用户到期提醒偏好,从启用二档之后的新到期开始,每分钟检查,不补发此前历史记录;改档取消禁发任务,恢复档位不补发已取消邮件。'; form.append(tierNote);
for(const [title, fields] of groups){
const fieldset=document.createElement('fieldset'); const legend=document.createElement('legend'); legend.textContent=title; fieldset.append(legend);
for(const [key,label,type] of fields){
const wrap=document.createElement('label'); wrap.textContent=label;
const input=document.createElement(type==='textarea'?'textarea':'input');
if(type!=='textarea') input.type=type;
if(type==='checkbox') input.checked=!!data.settings[key];
else if(type==='password') {input.autocomplete='new-password'; input.placeholder=data.settings[key+'_configured']?'已配置,留空保留':'尚未配置';}
else input.value=data.settings[key]??'';
if(type==='number'){input.step='1';input.min='1';if(key==='daily_limit') input.max='100';if(key==='monthly_limit') input.max='3000';if(key==='imap_port') input.max='65535';}
inputs[key]=input; wrap.append(input); fieldset.append(wrap);
} form.append(fieldset);
}
const message=document.createElement('p'); message.setAttribute('role','status'); form.append(message);
const save=document.createElement('button'); save.type='submit'; save.textContent='保存'; form.append(save);
const close=document.createElement('button'); close.type='button'; close.textContent='关闭';close.addEventListener('click',()=>dialog.close());form.append(close);
const history=document.createElement('pre');history.textContent='最近邮件:\n'+data.messages.map(m=>`${m.created_at} · ${m.purpose} · ${m.channel||'待选择'} · ${m.status}${m.fallback_reason?' · '+m.fallback_reason:''}`).join('\n');dialog.append(history);
for(const incoming of data.messages.filter(m=>m.direction==='in')) {
const view=document.createElement('button'); view.type='button'; view.textContent='查看来信:'+incoming.email;
const content=document.createElement('pre'); dialog.append(view,content);
const message=el('p',null,'text-sm text-muted-foreground');message.setAttribute('role','status');
const save=el('button','保存邮件渠道设置',buttonClass);save.type='submit';form.append(message,save);
form.addEventListener('submit',async event=>{
event.preventDefault();if(!form.reportValidity())return;
if((inputs.enabled.input.checked||Number(tier.value)===2)&&!data.database_ready){message.textContent='数据库尚未就绪,暂不可启用。';return;}
if(inputs.inbound_enabled.input.checked&&!data.settings.inbound_enabled&&!data.imap_available){message.textContent='收件需要安装 PHP IMAP 扩展。';return;}
const values={};for(const [key,{input,type}] of Object.entries(inputs)){
if(type==='checkbox')values[key]=input.checked;
else if(type==='password'&&!input.value)continue;
else values[key]=type==='number'?Number(input.value):input.value;
}
save.disabled=true;message.textContent='正在保存…';
try{await api('save',{mail_flow:values,automatic_mail_level:Number(tier.value)});message.textContent='邮件渠道设置已保存。';}
catch(error){message.textContent=error.message;}
finally{save.disabled=false;}
});
host.replaceChildren(form);
const history=el('details',null,'border-t pt-6 mt-6');history.append(el('summary','邮件处理与汇报记录','cursor-pointer text-sm font-medium'));
history.append(el('pre',data.messages.map(m=>`${m.created_at} · ${m.purpose} · ${m.channel||'待选择'} · ${m.status}`).join('\n')||'暂无邮件记录','text-xs whitespace-pre-wrap mt-4'));
for(const incoming of data.messages.filter(m=>m.direction==='in')){
const view=el('button','查看来信:'+incoming.email,buttonClass+' mt-3');view.type='button';const content=el('pre',null,'text-sm whitespace-pre-wrap mt-3');history.append(view,content);
view.addEventListener('click',async()=>{view.disabled=true;try{const mail=await api('mailFlowIncoming?id='+encodeURIComponent(incoming.id));content.textContent=mail.subject+'\n\n'+mail.text;}catch(error){content.textContent=error.message;}finally{view.disabled=false;}});
}
const reports=document.createElement('pre'); reports.textContent='最近汇报:\n'+data.reports.map(r=>r.report_date+' · '+r.status).join('\n');dialog.append(reports);
form.addEventListener('submit',async event=>{
event.preventDefault();if(!form.reportValidity()) return;
if((inputs.enabled.checked || Number(tier.value)===2) && !data.database_ready){message.textContent='请先执行数据库迁移。';return;}
if(inputs.inbound_enabled.checked && !data.imap_available){message.textContent='请先安装 PHP IMAP 扩展。';return;}
const values={};for(const [key,input] of Object.entries(inputs)){
if(input.type==='checkbox') values[key]=input.checked;
else if(input.type==='password' && !input.value) continue;
else values[key]=input.type==='number'?Number(input.value):input.value;
}
save.disabled=true;try{await api('save',{mail_flow:values,automatic_mail_level:Number(tier.value)});message.textContent='配置已保存。实际收发结果请查看邮件状态。';}catch(error){message.textContent=error.message;}finally{save.disabled=false;}
});
} catch(error) {dialog.textContent=error.message;const close=document.createElement('button');close.textContent='关闭';close.addEventListener('click',()=>dialog.close());dialog.append(close);}
finally {entry.disabled=false;}
});
history.append(el('pre','最近汇报:\n'+(data.reports.map(r=>r.report_date+' · '+r.status).join('\n')||'暂无汇报记录'),'text-xs whitespace-pre-wrap mt-4'));host.append(history);
}catch(error){if(!host.isConnected)return;host.replaceChildren(el('p',error.message,'text-sm text-destructive'));const retry=el('button','重新读取',buttonClass+' mt-3');retry.type='button';retry.addEventListener('click',()=>{host.replaceChildren();render(host);});host.append(retry);}
}
function mount(){
const current=document.getElementById('mail-flow-settings');
const panel=location.hash.split('?')[0]==='#/config/system/email'?document.querySelector('#root [role="tabpanel"][data-state="active"]'):null;
if(!panel?.querySelector('input[name="email_host"]')){current?.remove();return;}
if(current?.parentElement===panel)return;
current?.remove();const host=el('section',null,'border-t mt-6 pt-6');host.id='mail-flow-settings';panel.append(host);render(host);
}
let scheduled=false;
const schedule=()=>{if(scheduled)return;scheduled=true;requestAnimationFrame(()=>{scheduled=false;mount();});};
window.addEventListener('hashchange',schedule);
new MutationObserver(schedule).observe(document.getElementById('root'),{childList:true,subtree:true});schedule();

View File

@ -1,14 +1,7 @@
// Standalone settings panel, using the existing authenticated admin API.
const host = document.createElement('div');
host.id = 'mail-flow-tools';
const style = document.createElement('style');
style.textContent = `#mail-flow-tools .entry{position:fixed;right:24px;bottom:72px;z-index:40;padding:9px 14px;border:1px solid #cbd5e1;border-radius:8px;background:#fff;color:#0f172a;cursor:pointer}#mail-flow-dialog{width:min(760px,calc(100vw - 40px));max-height:85vh;overflow:auto;border:1px solid #cbd5e1;border-radius:12px;padding:24px;background:#fff;color:#0f172a}#mail-flow-dialog::backdrop{background:#0007}#mail-flow-dialog fieldset{border:1px solid #e2e8f0;border-radius:8px;margin:14px 0;padding:12px}#mail-flow-dialog label{display:block;margin:10px 0;font-size:14px}#mail-flow-dialog select,#mail-flow-dialog input:not([type=checkbox]),#mail-flow-dialog textarea{display:block;width:100%;padding:7px;border:1px solid #cbd5e1;border-radius:5px;box-sizing:border-box}#mail-flow-dialog button{padding:8px 14px;border:1px solid #cbd5e1;border-radius:6px;cursor:pointer}#mail-flow-dialog pre{white-space:pre-wrap;font-size:12px}#mail-flow-dialog p{font-size:13px;line-height:1.6}`;
host.append(style);
const entry = document.createElement('button'); entry.className='entry'; entry.textContent='邮件渠道与自动回复'; entry.type='button'; entry.hidden=true; host.append(entry);
const dialog=document.createElement('dialog'); dialog.id='mail-flow-dialog'; host.append(dialog); document.body.append(host);
const visibility=()=>{entry.hidden=location.hash.split('?')[0]!=='#/config/system/email' || !document.querySelector('#root main'); if(entry.hidden && dialog.open) dialog.close();};
window.addEventListener('hashchange',visibility);
new MutationObserver(visibility).observe(document.getElementById('root'),{childList:true,subtree:true}); visibility();
// Extend the existing email settings page using its authenticated admin API and styles.
const fieldClass='flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50';
const buttonClass='inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground shadow hover:bg-primary/90 h-9 px-4 py-2';
function el(tag, text, className){const node=document.createElement(tag);if(text)node.textContent=text;if(className)node.className=className;return node;}
async function api(action, payload) {
let token; try { token=JSON.parse(localStorage.getItem('XBOARD_ACCESS_TOKEN')||'null'); } catch { throw new Error('请重新登录后台'); }
if(!token?.value || (token.expire!=null && token.expire<=Date.now())) throw new Error('请重新登录后台');
@ -21,60 +14,86 @@ async function api(action, payload) {
return data.data;
}
const groups=[
['发送渠道', [['enabled','启用邮件记录与分流','checkbox'],['resend_enabled','验证码、登录链接和工单通知优先 Resend','checkbox'],['resend_key','Resend API 密钥','password'],['resend_from','Resend 发件地址','email'],['resend_webhook_secret','Resend 回调签名密钥','password'],['reply_to','统一回复地址(常规邮箱)','email'],['daily_limit','日额度上限','number'],['monthly_limit','月额度上限','number']]],
['发送渠道', [['enabled','启用邮件记录与分流','checkbox'],['resend_enabled','验证码、登录链接和工单通知优先 Resend','checkbox'],['resend_key','Resend API 密钥','password'],['resend_from','Resend 发件地址','email'],['resend_webhook_secret','Resend 回调签名密钥(选填)','password'],['reply_to','统一回复地址(常规邮箱)','email'],['daily_limit','日额度上限','number'],['monthly_limit','月额度上限','number']]],
['常规邮箱收件与自动回复', [['inbound_enabled','启用收件','checkbox'],['imap_host','IMAP 主机','text'],['imap_port','IMAP TLS 端口','number'],['imap_username','IMAP 用户名','text'],['imap_password','IMAP 密码','password'],['imap_folder','收件文件夹','text'],['auto_reply_enabled','自动确认收件(按发件地址限频)','checkbox'],['auto_reply_subject','自动回复标题','text'],['auto_reply_text','自动回复内容','textarea']]],
['每天八点汇报', [['report_enabled','启用 master 汇报','checkbox'],['master_url','Master 基础地址(HTTPS)','url'],['master_bot_code','Master 机器人代号','text'],['master_chat_code','Master 接收会话代号','text']]],
];
entry.addEventListener('click',async()=>{
dialog.textContent='正在读取…'; dialog.showModal(); entry.disabled=true;
try {
const data=await api('mailFlowStatus'); dialog.replaceChildren();
const heading=document.createElement('h2'); heading.textContent='邮件渠道与自动回复'; dialog.append(heading);
const note=document.createElement('p'); note.textContent='群发、流量和到期提醒仍用原常规邮箱。Resend 额度不足时回退;收件固定用常规邮箱。密钥留空保留。自动回复仅处理启用之后的新邮件,并限制每个发件地址北京时间每天一次,无需绑定账户。'; dialog.append(note);
const readiness=document.createElement('p'); readiness.textContent=(data.database_ready?'数据库迁移已就绪。':'数据库尚未迁移,暂不可启用。')+(data.imap_available?' IMAP 扩展可用。':' 收件需要安装 PHP IMAP 扩展。'); dialog.append(readiness);
const form=document.createElement('form'); dialog.append(form); const inputs={};
const tierLabel=document.createElement('label'); tierLabel.textContent='自动邮件档位';
const tier=document.createElement('select'); tier.name='automatic_mail_level';
for(const [value,label] of [[1,'一档:保持原有提醒'],[2,'二档:仅套餐到期时提醒'],[3,'三档:关闭自动批量提醒']]) {
const option=document.createElement('option'); option.value=String(value); option.textContent=label; tier.append(option);
function control(key,label,type,value,configured){
const wrap=el('div',null,'space-y-2');
const id='mail-flow-'+key;
const caption=el('label',label,'font-medium text-base');caption.htmlFor=id;wrap.append(caption);
let input;
if(type==='checkbox'){
input=el('button');input.type='button';input.setAttribute('role','switch');input.checked=!!value;
input.className='peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input';
const thumb=el('span',null,'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0');input.append(thumb);
const sync=()=>{const state=input.checked?'checked':'unchecked';input.dataset.state=state;thumb.dataset.state=state;input.setAttribute('aria-checked',String(input.checked));};
input.addEventListener('click',()=>{input.checked=!input.checked;sync();});sync();
const row=el('div',null,'flex items-center justify-between gap-4');wrap.replaceChildren(row);row.append(caption,input);
}else{
input=el(type==='textarea'?'textarea':'input');if(type!=='textarea')input.type=type;
input.className=fieldClass+(type==='textarea'?' min-h-24 py-2':'');
if(type==='password'){input.autocomplete='new-password';input.placeholder=configured?'已配置,留空保留':'尚未配置';}
else input.value=value??'';
if(type==='number'){input.step='1';input.min='1';if(key==='daily_limit')input.max='100';if(key==='monthly_limit')input.max='3000';if(key==='imap_port')input.max='65535';}
wrap.append(input);
}
input.id=id;input.name=key;
return {wrap,input,type};
}
async function render(host){
const loading=el('p','正在读取邮件渠道设置…','text-sm text-muted-foreground');host.append(loading);
try{
const data=await api('mailFlowStatus');if(!host.isConnected)return;
if(data.automatic_mail_level==null)throw new Error('自动邮件档位暂不可读取,请稍后重试');
const form=el('form',null,'space-y-6');const inputs={};
const policy=el('section',null,'space-y-4');policy.append(el('h3','自动邮件档位','text-lg font-semibold'));
const tierWrap=el('div',null,'space-y-2');const label=el('label','自动邮件档位','font-medium text-base');label.htmlFor='mail-flow-tier';
const tier=el('select');tier.id='mail-flow-tier';tier.name='automatic_mail_level';tier.className=fieldClass;
for(const [value,text] of [[1,'一档:保持原有提醒'],[2,'二档:仅套餐到期时提醒'],[3,'三档:关闭自动批量提醒']]){const option=el('option',text);option.value=String(value);tier.append(option);}
tier.value=String(data.automatic_mail_level);tierWrap.append(label,tier,el('p','管理员人工群发、验证码、登录邮件、工单回应通知和来信自动回复始终允许。到期通知须同时满足后台允许和用户未关闭提醒。','text-[0.8rem] text-muted-foreground'));policy.append(tierWrap);form.append(policy);
for(const [title,fields] of groups){
const section=el('section',null,'space-y-4 border-t pt-6');section.append(el('h3',title==='发送渠道'?'Resend 与邮件发送渠道':title,'text-lg font-semibold'));
if(title==='发送渠道')section.append(el('p','Resend 额度不足时使用原常规邮箱。其他通知仍走常规邮箱;密钥留空保留现有值。','text-sm text-muted-foreground'));
if(title==='常规邮箱收件与自动回复')section.append(el('p','收件及自动回复使用常规邮箱。每个发件地址北京时间每天仅回复一次,无需绑定网站账号。','text-sm text-muted-foreground'));
if(title==='每天八点汇报')section.append(el('p','北京时间每天早上八点汇报;机器人和接收会话代号留空使用 master 默认配置。','text-sm text-muted-foreground'));
for(const [key,text,type] of fields){const item=control(key,text,type,data.settings[key],data.settings[key+'_configured']);inputs[key]=item;section.append(item.wrap);}
form.append(section);
}
if(data.automatic_mail_level==null) throw new Error('自动邮件档位暂不可读取,请稍后重试');
tier.value=String(data.automatic_mail_level); tierLabel.append(tier); form.append(tierLabel);
const tierNote=document.createElement('p'); tierNote.textContent='人工群发、验证码、登录、工单回复和收件自动回复均不受档位影响。二档保留原提醒开关与用户到期提醒偏好,从启用二档之后的新到期开始,每分钟检查,不补发此前历史记录;改档取消禁发任务,恢复档位不补发已取消邮件。'; form.append(tierNote);
for(const [title, fields] of groups){
const fieldset=document.createElement('fieldset'); const legend=document.createElement('legend'); legend.textContent=title; fieldset.append(legend);
for(const [key,label,type] of fields){
const wrap=document.createElement('label'); wrap.textContent=label;
const input=document.createElement(type==='textarea'?'textarea':'input');
if(type!=='textarea') input.type=type;
if(type==='checkbox') input.checked=!!data.settings[key];
else if(type==='password') {input.autocomplete='new-password'; input.placeholder=data.settings[key+'_configured']?'已配置,留空保留':'尚未配置';}
else input.value=data.settings[key]??'';
if(type==='number'){input.step='1';input.min='1';if(key==='daily_limit') input.max='100';if(key==='monthly_limit') input.max='3000';if(key==='imap_port') input.max='65535';}
inputs[key]=input; wrap.append(input); fieldset.append(wrap);
} form.append(fieldset);
}
const message=document.createElement('p'); message.setAttribute('role','status'); form.append(message);
const save=document.createElement('button'); save.type='submit'; save.textContent='保存'; form.append(save);
const close=document.createElement('button'); close.type='button'; close.textContent='关闭';close.addEventListener('click',()=>dialog.close());form.append(close);
const history=document.createElement('pre');history.textContent='最近邮件:\n'+data.messages.map(m=>`${m.created_at} · ${m.purpose} · ${m.channel||'待选择'} · ${m.status}${m.fallback_reason?' · '+m.fallback_reason:''}`).join('\n');dialog.append(history);
for(const incoming of data.messages.filter(m=>m.direction==='in')) {
const view=document.createElement('button'); view.type='button'; view.textContent='查看来信:'+incoming.email;
const content=document.createElement('pre'); dialog.append(view,content);
const message=el('p',null,'text-sm text-muted-foreground');message.setAttribute('role','status');
const save=el('button','保存邮件渠道设置',buttonClass);save.type='submit';form.append(message,save);
form.addEventListener('submit',async event=>{
event.preventDefault();if(!form.reportValidity())return;
if((inputs.enabled.input.checked||Number(tier.value)===2)&&!data.database_ready){message.textContent='数据库尚未就绪,暂不可启用。';return;}
if(inputs.inbound_enabled.input.checked&&!data.settings.inbound_enabled&&!data.imap_available){message.textContent='收件需要安装 PHP IMAP 扩展。';return;}
const values={};for(const [key,{input,type}] of Object.entries(inputs)){
if(type==='checkbox')values[key]=input.checked;
else if(type==='password'&&!input.value)continue;
else values[key]=type==='number'?Number(input.value):input.value;
}
save.disabled=true;message.textContent='正在保存…';
try{await api('save',{mail_flow:values,automatic_mail_level:Number(tier.value)});message.textContent='邮件渠道设置已保存。';}
catch(error){message.textContent=error.message;}
finally{save.disabled=false;}
});
host.replaceChildren(form);
const history=el('details',null,'border-t pt-6 mt-6');history.append(el('summary','邮件处理与汇报记录','cursor-pointer text-sm font-medium'));
history.append(el('pre',data.messages.map(m=>`${m.created_at} · ${m.purpose} · ${m.channel||'待选择'} · ${m.status}`).join('\n')||'暂无邮件记录','text-xs whitespace-pre-wrap mt-4'));
for(const incoming of data.messages.filter(m=>m.direction==='in')){
const view=el('button','查看来信:'+incoming.email,buttonClass+' mt-3');view.type='button';const content=el('pre',null,'text-sm whitespace-pre-wrap mt-3');history.append(view,content);
view.addEventListener('click',async()=>{view.disabled=true;try{const mail=await api('mailFlowIncoming?id='+encodeURIComponent(incoming.id));content.textContent=mail.subject+'\n\n'+mail.text;}catch(error){content.textContent=error.message;}finally{view.disabled=false;}});
}
const reports=document.createElement('pre'); reports.textContent='最近汇报:\n'+data.reports.map(r=>r.report_date+' · '+r.status).join('\n');dialog.append(reports);
form.addEventListener('submit',async event=>{
event.preventDefault();if(!form.reportValidity()) return;
if((inputs.enabled.checked || Number(tier.value)===2) && !data.database_ready){message.textContent='请先执行数据库迁移。';return;}
if(inputs.inbound_enabled.checked && !data.imap_available){message.textContent='请先安装 PHP IMAP 扩展。';return;}
const values={};for(const [key,input] of Object.entries(inputs)){
if(input.type==='checkbox') values[key]=input.checked;
else if(input.type==='password' && !input.value) continue;
else values[key]=input.type==='number'?Number(input.value):input.value;
}
save.disabled=true;try{await api('save',{mail_flow:values,automatic_mail_level:Number(tier.value)});message.textContent='配置已保存。实际收发结果请查看邮件状态。';}catch(error){message.textContent=error.message;}finally{save.disabled=false;}
});
} catch(error) {dialog.textContent=error.message;const close=document.createElement('button');close.textContent='关闭';close.addEventListener('click',()=>dialog.close());dialog.append(close);}
finally {entry.disabled=false;}
});
history.append(el('pre','最近汇报:\n'+(data.reports.map(r=>r.report_date+' · '+r.status).join('\n')||'暂无汇报记录'),'text-xs whitespace-pre-wrap mt-4'));host.append(history);
}catch(error){if(!host.isConnected)return;host.replaceChildren(el('p',error.message,'text-sm text-destructive'));const retry=el('button','重新读取',buttonClass+' mt-3');retry.type='button';retry.addEventListener('click',()=>{host.replaceChildren();render(host);});host.append(retry);}
}
function mount(){
const current=document.getElementById('mail-flow-settings');
const panel=location.hash.split('?')[0]==='#/config/system/email'?document.querySelector('#root [role="tabpanel"][data-state="active"]'):null;
if(!panel?.querySelector('input[name="email_host"]')){current?.remove();return;}
if(current?.parentElement===panel)return;
current?.remove();const host=el('section',null,'border-t mt-6 pt-6');host.id='mail-flow-settings';panel.append(host);render(host);
}
let scheduled=false;
const schedule=()=>{if(scheduled)return;scheduled=true;requestAnimationFrame(()=>{scheduled=false;mount();});};
window.addEventListener('hashchange',schedule);
new MutationObserver(schedule).observe(document.getElementById('root'),{childList:true,subtree:true});schedule();