// Email conversations and the existing durable outbound composer.
const { useState: useMailState, useEffect: useMailEffect, useRef: useMailRef } = React;
const MAIL_STATES = {
pending: ["В очереди", "text-[rgb(var(--hd-warning-text))]"],
sending: ["Отправляется", "text-[rgb(var(--hd-accent-text))]"],
sent: ["Отправлено", "text-[rgb(var(--hd-success-text))]"],
failed: ["Не отправлено", "text-[rgb(var(--hd-danger-text))]"],
unknown: ["Требует проверки", "text-[rgb(var(--hd-amber-text))]"],
};
function EmailComposeScreen({ visible, showToast, currentOperator }) {
const [config, setConfig] = useMailState(null);
const [letters, setLetters] = useMailState([]);
const [sender, setSender] = useMailState("");
const [recipient, setRecipient] = useMailState("");
const [subject, setSubject] = useMailState("");
const [text, setText] = useMailState("");
const [attachment, setAttachment] = useMailState(null);
const [busy, setBusy] = useMailState(false);
const [error, setError] = useMailState("");
const [loadError, setLoadError] = useMailState("");
const requestKey = useMailRef(null);
const busyRef = useMailRef(false);
const fileRef = useMailRef(null);
const alive = useMailRef(true);
useMailEffect(() => {
alive.current = true;
return () => { alive.current = false; };
}, []);
useMailEffect(() => {
if (!visible) return;
let cancelled = false;
let loading = false;
const refresh = async () => {
if (loading) return;
loading = true;
try {
const [settings, history] = await Promise.all([
window.apiFetch("GET", "/api/email/settings", undefined, false, 10000),
window.apiFetch("GET", "/api/email/outbox", undefined, false, 10000),
]);
if (cancelled) return;
setConfig(settings);
setLetters(history);
setSender(previous => previous || settings.senders.find(item => item.ready)?.address
|| settings.senders[0]?.address || "");
setLoadError("");
} catch {
if (!cancelled) setLoadError("Не удалось загрузить почту. Повторим загрузку автоматически.");
} finally { loading = false; }
};
refresh();
const timer = setInterval(refresh, 5000);
return () => { cancelled = true; clearInterval(timer); };
}, [visible]);
function change(setter, value) {
setter(value);
requestKey.current = null;
setError("");
}
function selectFile(file) {
if (file && file.size > (config?.max_attachment_bytes || 10 * 1024 * 1024)) {
setError("Вложение — до 10 МБ");
if (fileRef.current) fileRef.current.value = "";
return;
}
change(setAttachment, file);
if (!file && fileRef.current) fileRef.current.value = "";
}
async function submit(event) {
event.preventDefault();
if (busyRef.current) return;
busyRef.current = true;
setBusy(true);
setError("");
try {
// Reuse after a timeout: the server deduplicates this operator/request.
requestKey.current ||= crypto.randomUUID();
const body = new FormData();
for (const [key, value] of Object.entries({
sender, recipient, subject, text, request_key: requestKey.current,
})) body.append(key, value);
if (attachment) body.append("attachment", attachment);
const letter = await window.apiFetch("POST", "/api/email/outbox", body);
if (!alive.current) return;
setLetters(previous => [letter, ...previous.filter(item => item.id !== letter.id)].slice(0, 100));
setRecipient(""); setSubject(""); setText(""); setAttachment(null);
requestKey.current = null;
if (fileRef.current) fileRef.current.value = "";
showToast("Письмо поставлено в очередь");
} catch (failure) {
if (alive.current) setError(typeof failure.detail === "string" ? failure.detail
: "Запрос не подтверждён. Повторите отправку без изменения письма — дубликат не создастся.");
} finally {
busyRef.current = false;
if (alive.current) setBusy(false);
}
}
async function retry(letter) {
try {
const updated = await window.apiFetch("POST", "/api/email/outbox/" + letter.id + "/retry");
if (alive.current) {
setLetters(previous => previous.map(item => item.id === updated.id ? updated : item));
showToast("Отправка поставлена в очередь повторно");
}
} catch (failure) {
showToast(typeof failure.detail === "string" ? failure.detail : "Не удалось повторить отправку", "error");
}
}
const ready = config?.enabled && config.senders.some(item => item.address === sender && item.ready);
const inputClass = "w-full bg-[rgb(var(--hd-canvas))] border border-[rgb(var(--hd-border))] rounded-lg px-3 py-2 text-sm text-[rgb(var(--hd-text))] focus:outline-none focus:border-[rgb(var(--hd-accent))] disabled:opacity-50";
return (
{loadError &&
{loadError}
}
{config && !ready &&
{config.enabled ? "Выбранный адрес пока не подключён. Выберите другой адрес или обратитесь к администратору."
: "Почта пока не подключена. Обратитесь к администратору."}
}
История отправок
{!letters.length && Отправленных писем пока нет.
}
{letters.map(letter => {
const state = MAIL_STATES[letter.state] || ["Проверяем статус", "text-[rgb(var(--hd-secondary))]"];
return
{state[0]}
{letter.subject}
{letter.sender} → {letter.recipient}
{new Date(letter.created_at).toLocaleString("ru-RU")} · {letter.operator_name}
{letter.body}
{letter.attachment_name && Вложение: {letter.attachment_name}
}
{letter.state === "sent" && Принято почтовым сервером. Получение и прочтение клиентом не подтверждаются.
}
{letter.last_error && {letter.last_error}
}
{letter.state === "failed" && config?.enabled
&& (currentOperator?.role === "admin" || currentOperator?.id === letter.operator_id)
&& retry(letter)} className="text-xs text-[rgb(var(--hd-accent-text))] mt-3">Повторить отправку }
;
})}
);
}
async function downloadMailAttachment(path, name) {
const blob = await window.apiFetch("DOWNLOAD", path);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url; link.download = name;
document.body.appendChild(link); link.click(); link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
const MAIL_FOLDERS = [['awaiting','Требуют ответа'],['unread','Непрочитанные'],['open','Открытые'],['closed','Закрытые'],['all','Вся почта']];
function EmailNavBadge() {
const [count,setCount]=useMailState(0);
useMailEffect(() => {
const update=event => setCount(event.detail.unread || 0);
window.addEventListener('mail:summary',update);
return () => window.removeEventListener('mail:summary',update);
},[]);
return count>0 ? {count>99?'99+':count} : null;
}
function EmailScreen({visible,showToast,currentOperator}) {
const [tab,setTab]=useMailState('conversations');
const [data,setData]=useMailState({items:[],counts:{},total:0,page:1,pages:1});
const [selected,setSelected]=useMailState(null);
const [view,setView]=useMailState(null);
const [config,setConfig]=useMailState(null);
const [health,setHealth]=useMailState(null);
const [mailbox,setMailbox]=useMailState('');
const [folder,setFolder]=useMailState('awaiting');
const [scope,setScope]=useMailState('mine');
const [operators,setOperators]=useMailState([]);
const [transferTo,setTransferTo]=useMailState('');
const [assigning,setAssigning]=useMailState(false);
const assigningRef=useMailRef(false);
const [query,setQuery]=useMailState('');
const [search,setSearch]=useMailState('');
const [page,setPage]=useMailState(1);
const [revision,setRevision]=useMailState(0);
const [error,setError]=useMailState('');
const [loading,setLoading]=useMailState(true);
const [busy,setBusy]=useMailState(false);
const [drafts,setDrafts]=useMailState({});
const [permission,setPermission]=useMailState(() => typeof Notification==='undefined'?'unsupported':Notification.permission);
const [notices,setNotices]=useMailState(false);
const busyRef=useMailRef(false), currentId=useMailRef(selected), cursor=useMailRef(null);
const fileRef=useMailRef(null), bodyRef=useMailRef(null), searchRef=useMailRef(null);
const visibleRef=useMailRef(visible), toastRef=useMailRef(showToast);
const draftKey=`helpdesk-mail-drafts:${currentOperator?.id || 'operator'}`;
currentId.current=selected;visibleRef.current=visible;toastRef.current=showToast;
useMailEffect(() => {
try {setDrafts(JSON.parse(sessionStorage.getItem(draftKey)||'{}'));} catch {setDrafts({});}
try {setNotices(localStorage.getItem(`mail-notices:${currentOperator?.id}`)==='true');} catch {}
},[draftKey]);
useMailEffect(() => {
const timer=setTimeout(() => {setSearch(query.trim());setPage(1);},250);
return () => clearTimeout(timer);
},[query]);
// Always mounted: rare email must remain visible while operators work in Telegram.
useMailEffect(() => {
let cancelled=false,loading=false;
cursor.current=null;
async function poll() {
if (loading) return;
loading=true;
try {
const result=await window.apiFetch('GET','/api/email/summary',undefined,false,10000);
if (cancelled) return;
window.dispatchEvent(new CustomEvent('mail:summary',{detail:result}));
if (cursor.current!==null && (result.notification_cursor ?? result.cursor)>cursor.current) {
toastRef.current(result.notification_kind==='assigned'?'Вам назначено почтовое обращение':result.notification_kind==='released'?'Обращение возвращено в общую почтовую очередь':'Новое письмо в разделе «Почта»');
setRevision(value=>value+1);
if (notices && typeof Notification!=='undefined' && Notification.permission==='granted' && (!visibleRef.current || document.hidden)) {
const notice=new Notification(result.notification_kind==='assigned'?'Вам назначено почтовое обращение':'Новое письмо в поддержку',{body:'Откройте «Почту», чтобы прочитать обращение.',tag:'helpdesk-email'});
notice.onclick=() => {window.focus();window.dispatchEvent(new CustomEvent('mail:navigate'));notice.close();};
}
}
cursor.current=result.cursor;
} catch {} finally {loading=false;}
}
poll();const timer=setInterval(poll,10000);
return () => {cancelled=true;clearInterval(timer);};
},[notices,currentOperator?.id]);
useMailEffect(() => {
const open=()=>{setTab('conversations');setFolder('open');setScope('all');setPage(1);};
window.addEventListener('mail:navigate',open);
return ()=>window.removeEventListener('mail:navigate',open);
},[]);
useMailEffect(() => {
if (!visible) return;
let cancelled=false;
async function settings() {
try {
const [c,h,ops]=await Promise.all([window.apiFetch('GET','/api/email/settings',undefined,false,10000),window.apiFetch('GET','/api/email/incoming-status',undefined,false,10000),window.apiFetch('GET','/api/operators',undefined,false,10000)]);
if (!cancelled) {setConfig(c);setHealth(h);setOperators(ops);}
} catch {if(!cancelled)setError('Не удалось проверить подключение почты.');}
}
settings();const timer=setInterval(settings,30000);
return ()=>{cancelled=true;clearInterval(timer);};
},[visible]);
useMailEffect(() => {
if (!visible || tab!=='conversations') return;
let cancelled=false,loading=false;
setLoading(true);
async function refresh() {
if(loading)return;loading=true;
try {
const params=new URLSearchParams({mailbox,folder,scope,q:search,page:String(page),limit:'50'});
const result=await window.apiFetch('GET',`/api/email/workspace?${params}`,undefined,false,10000);
if(!cancelled){setData(result);setError('');setLoading(false);}
} catch {if(!cancelled){setError('Не удалось загрузить письма. Повторим автоматически.');setLoading(false);}}
finally{loading=false;}
}
refresh();const timer=setInterval(refresh,10000);
return ()=>{cancelled=true;clearInterval(timer);};
},[visible,tab,mailbox,folder,scope,search,page,revision]);
useMailEffect(() => {
if(!visible || tab!=='conversations' || !selected)return;
let cancelled=false,loading=false;
async function refresh() {
if(loading)return;loading=true;
try {
const result=await window.apiFetch('GET',`/api/email/threads/${selected}`,undefined,false,10000);
if(cancelled || currentId.current!==selected)return;
setView(result);
if(result.thread.unread_count>0 && result.thread.assigned_operator_id===currentOperator?.id && !document.hidden) {
await window.apiFetch('POST',`/api/email/threads/${selected}/read`,{version:result.thread.unread_version},false);
if(!cancelled)setData(previous=>({...previous,items:previous.items.map(row=>row.id===selected && row.unread_version===result.thread.unread_version?{...row,unread_count:0}:row)}));
}
}catch{if(!cancelled)setError('Не удалось обновить переписку.');}
finally{loading=false;}
}
refresh();const timer=setInterval(refresh,5000);
const focus=()=>refresh();window.addEventListener('focus',focus);
return ()=>{cancelled=true;clearInterval(timer);window.removeEventListener('focus',focus);};
},[selected,visible,tab,revision,currentOperator?.id]);
const draft=drafts[selected] || {text:'',file:null,requestKey:null};
function saveDrafts(next) {
setDrafts(next);
try {
const textOnly=Object.fromEntries(Object.entries(next).filter(([,d])=>d.text).map(([id,d])=>[id,{text:d.text,requestKey:d.requestKey || null}]));
sessionStorage.setItem(draftKey,JSON.stringify(textOnly));
} catch {}
}
function changeDraft(change) {saveDrafts({...drafts,[selected]:{...draft,...change,requestKey:null}});}
async function reply(event) {
event.preventDefault();if(busyRef.current || assigningRef.current || !selected || !draft.text.trim() || view?.thread.assigned_operator_id!==currentOperator?.id)return;
const id=selected,requestKey=draft.requestKey || crypto.randomUUID();
saveDrafts({...drafts,[id]:{...draft,requestKey}});busyRef.current=true;setBusy(true);
try {
const body=new FormData();body.append('text',draft.text);body.append('request_key',requestKey);
if(draft.file)body.append('attachment',draft.file);
await window.apiFetch('POST',`/api/email/threads/${id}/reply`,body);
setDrafts(previous=>{const next={...previous};delete next[id];try{const saved=JSON.parse(sessionStorage.getItem(draftKey)||'{}');delete saved[id];sessionStorage.setItem(draftKey,JSON.stringify(saved));}catch{}return next;});
if(fileRef.current)fileRef.current.value='';
showToast('Ответ поставлен в очередь');setRevision(value=>value+1);
if(currentId.current===id){const result=await window.apiFetch('GET',`/api/email/threads/${id}`);if(currentId.current===id)setView(result);}
}catch(failure){showToast(typeof failure.detail==='string'?failure.detail:'Ответ не подтверждён. Повторите без изменения текста — дубликат не создастся.','error');}
finally{busyRef.current=false;setBusy(false);}
}
async function toggleStatus() {
if(!view || busyRef.current || assigningRef.current)return;
const id=selected,status=view.thread.status==='closed'?'open':'closed';
try{
await window.apiFetch('POST',`/api/email/threads/${id}/status`,{status});setRevision(value=>value+1);
if(currentId.current===id)setView(previous=>({...previous,thread:{...previous.thread,status}}));
}catch{showToast('Не удалось изменить статус','error');}
}
async function assignTo(target) {
if(!active || busyRef.current || assigningRef.current)return;
const id=selected;assigningRef.current=true;setAssigning(true);
try {
const result=await window.apiFetch('POST',`/api/email/threads/${id}/assignment`,{operator_id:target,version:active.thread.assignment_version});
if(currentId.current===id)setView(previous=>({...previous,thread:result}));
setTransferTo('');setRevision(value=>value+1);
showToast(target===currentOperator?.id?'Обращение закреплено за вами':target?'Обращение передано оператору':'Обращение возвращено в общую очередь');
} catch(failure) {
showToast(typeof failure.detail==='string'?failure.detail:'Не удалось изменить ответственного','error');
setRevision(value=>value+1);
} finally {assigningRef.current=false;setAssigning(false);}
}
async function toggleNotifications() {
if(typeof Notification==='undefined')return;
if(notices){setNotices(false);try{localStorage.setItem(`mail-notices:${currentOperator?.id}`,'false');}catch{}return;}
const result=await Notification.requestPermission();setPermission(result);
if(result==='granted'){setNotices(true);try{localStorage.setItem(`mail-notices:${currentOperator?.id}`,'true');}catch{}}
else showToast('Уведомления заблокированы в настройках браузера','error');
}
const healthy=health?.enabled && health.agents.length>0 && health.agents.every(agent=>!agent.error_code && Date.now()-new Date(agent.last_seen).getTime()<120000);
const active=view?.thread.id===selected?view:null;
const mine=active?.thread.assigned_operator_id===currentOperator?.id;
const canManage=mine || currentOperator?.role==='admin';
const ready=mine && !assigning && config?.enabled && config.senders.some(row=>row.address===active?.thread.mailbox && row.ready);
const inputClass='w-full bg-[rgb(var(--hd-canvas))] border border-[rgb(var(--hd-border))] rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-[rgb(var(--hd-accent))]';
function openThread(id){setSelected(id);setError('');}
return
Почта поддержки
{!health?'Проверяем подключение…':healthy?'● Входящие подключены · обновляются автоматически':!health.enabled?'Входящие не подключены':'● Синхронизация задерживается'}
{permission!=='unsupported' && {notices?'Уведомления включены':'Включить уведомления'} }
setTab(tab==='compose'?'conversations':'compose')} className="px-3 py-2 bg-[rgb(var(--hd-accent))] text-white rounded-lg text-sm">{tab==='compose'?'К входящим':'+ Новое письмо'}
{error &&
{error}
}
{tab==='compose'?
:
{[['mine','Мои'],['unassigned','Без ответственного'],['all','Все']].map(([id,label])=>{setScope(id);setPage(1);}} className={'flex-1 rounded-md px-1 py-2 text-[10px] leading-tight '+(scope===id?'bg-[rgb(var(--hd-mail-chip))] text-[rgb(var(--hd-mail-link))]':'text-[rgb(var(--hd-secondary))]')}>
{label}{data.scope_counts?.[id] || 0} )}
setQuery(event.target.value)} className={inputClass}/>
{setFolder(event.target.value);setPage(1);}} className={inputClass+' text-xs'}>{MAIL_FOLDERS.map(([id,label])=>{label} )}
{setMailbox(event.target.value);setPage(1);}} className={inputClass+' text-xs'}>Все ящики {config?.senders.map(row=>{row.address} )}
{MAIL_FOLDERS.find(([id])=>id===folder)?.[1]} · {data.total} setRevision(value=>value+1)} className="text-[rgb(var(--hd-link))] p-1">Обновить
{!data.items.length &&
{loading?'Загружаем письма…':search?'Ничего не найдено':folder==='awaiting'?'Все обращения обработаны':'Здесь пока нет писем'}
{!loading && (search?'Попробуйте другой адрес или тему.':folder==='awaiting'?'История доступна в папке «Вся почта».':'Новые обращения появятся автоматически.')}
}
{data.items.map(row=>
openThread(row.id)} className={'w-full text-left px-4 py-4 border-b border-[rgb(var(--hd-mail-border))] border-l-2 transition '+(selected===row.id?'border-l-[rgb(var(--hd-accent))] bg-[rgb(var(--hd-mail-selected))]':'border-l-transparent hover:bg-[rgb(var(--hd-mail-hover))]')}>
{row.customer_name || row.customer_email} {new Date(row.activity_at || row.updated_at).toLocaleDateString('ru-RU',{day:'numeric',month:'short'})}
{row.subject || 'Без темы'}
{row.preview || row.customer_email}
{row.mailbox} {row.has_attachment && 📎 }{drafts[row.id]?.text && Черновик }{row.unread_count>0 && {row.unread_count} }{row.status==='closed' && Закрыто }{row.assigned_operator_id?(row.assigned_operator_id===currentOperator?.id?'Ответственный: вы':`Ответственный: ${row.assigned_operator_name || 'оператор'}`):'Без ответственного'}
)}
setPage(Math.max(1,data.page-1))} className="px-2 py-1 disabled:opacity-30">← Назад {data.page} / {data.pages} =data.pages || loading} onClick={()=>setPage(data.page+1)} className="px-2 py-1 disabled:opacity-30">Далее →
{!active?✉ {selected?'Загружаем переписку…':'Выберите обращение'} Письма клиента, вложения и ответы команды — в одной переписке.
{selected &&
setSelected(null)}>К списку писем }
:<>
{active.thread.assigned_operator_id?`Ответственный: ${active.thread.assigned_operator_name || 'оператор'}${mine?' (вы)':''}`:'Обращение пока без ответственного'}
{(!active.thread.assigned_operator_id || (!mine && currentOperator?.role==='admin')) &&
assignTo(currentOperator.id)} disabled={busy || assigning} className="px-3 py-2 bg-[rgb(var(--hd-mail-action))] text-[rgb(var(--hd-mail-link))] rounded-lg text-xs disabled:opacity-40">{assigning?'Сохраняем…':'Взять в работу'} }
{canManage && active.thread.assigned_operator_id &&
setTransferTo(event.target.value)} className={inputClass+' text-xs !w-auto max-w-[200px]'}>Передать коллеге… {operators.filter(op=>op.id!==active.thread.assigned_operator_id).map(op=>{op.name}{op.paused?' · на паузе':''} )}
assignTo(Number(transferTo))} disabled={!transferTo || busy || assigning} className="text-xs text-[rgb(var(--hd-link))] px-2 py-2 disabled:opacity-30">Передать
assignTo(null)} disabled={busy || assigning} className="text-xs text-[rgb(var(--hd-secondary))] px-2 py-2 disabled:opacity-30">В общую очередь
}
{active.assignment_history?.length>0 &&
История назначений {active.assignment_history.map(event=>
{new Date(event.created_at).toLocaleString('ru-RU')} · {event.actor_name}: {event.previous_name || 'Общая очередь'} → {event.new_name || 'Общая очередь'}
)}
}
{active.messages.map(message=>
{message.direction==='incoming'?'Клиент':message.operator_name || 'Поддержка'} {new Date(message.created_at).toLocaleString('ru-RU')}{message.state && {MAIL_STATES[message.state]?.[0] || message.state} }
{message.body}
{(message.attachments || []).map(file=>downloadMailAttachment(`/api/email/attachments/${file.id}`,file.name).catch(()=>showToast('Не удалось скачать вложение','error'))}>📎 {file.name} )}
{message.attachment_name && downloadMailAttachment(`/api/email/outbox/${message.id}/attachment`,message.attachment_name).catch(()=>showToast('Не удалось скачать вложение','error'))}>📎 {message.attachment_name} }
)}
Ответ от {active.thread.mailbox} {draft.text?'Черновик сохранён в этой вкладке':''}
changeDraft({text:event.target.value})} onKeyDown={event=>{if((event.ctrlKey || event.metaKey) && event.key==='Enter'){event.preventDefault();if(ready)event.currentTarget.form.requestSubmit();}}} placeholder="Напишите ответ клиенту…" className={inputClass+' resize-y max-h-[30vh]'}/>
{const file=event.target.files[0] || null;if(file?.size>10*1024*1024){event.target.value='';showToast('Вложение — до 10 МБ','error');return;}changeDraft({file});}}/>
{draft.file && 📎 {draft.file.name}{changeDraft({file:null});if(fileRef.current)fileRef.current.value='';}}>Убрать
}
fileRef.current?.click()} className="text-xs text-[rgb(var(--hd-mail-secondary))] px-2 py-2">Прикрепить файл Ctrl / ⌘ + Enter {busy?'Сохраняем…':'Отправить ответ'}
{!ready && {!mine?'Ответ доступен ответственному. Возьмите обращение в работу или попросите передать его вам.':'Отправка с этого ящика пока недоступна.'}
}
>}
}
;
}
window.EmailScreen=EmailScreen;
window.EmailNavBadge=EmailNavBadge;