ticket: aller-retour courriel (envoi + réponse revient au fil)

Hub (conversation.js) : getOrCreateConvForIssue (lie une Issue à une conv email) + emailTicket
(envoie dans un fil Gmail, sujet [Ticket #ISS-…] + lien direct, journalise Sent Communication).
Route POST /conversations/ticket-email (gaté Authentik). La réponse du client re-thread par le tag
sujet (TICKET_REF_RX) OU le threadId → logToLinkedTicket (déjà en place) → Communication Received
sur l'Issue → visible au panneau. notifyCustomer mémorise désormais conv.threadId au 1er envoi.

UI (IssueDetail) : bouton « Envoyer par courriel » + dialogue (À/Cc/message), « Répondre » scindé
en « Note interne » (Communication) vs courriel. Lien direct = URL du ticket.

Testé : envoi réel à louis@targo.ca sur ISS-0000250013 (channel:email, Sent Communication OK).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-07 07:42:39 -04:00
parent cb16167f72
commit 417ef08f58
2 changed files with 124 additions and 5 deletions

View File

@ -249,17 +249,44 @@
</div>
<div class="q-mt-md reply-box">
<div class="info-block-title">Repondre</div>
<div class="info-block-title">Répondre</div>
<q-input v-model="replyContent" dense outlined type="textarea" autogrow
placeholder="Ecrire une reponse..."
placeholder="Écrire une réponse ou une note…"
:input-style="{ fontSize: '0.85rem', minHeight: '50px' }"
@keydown.ctrl.enter="sendReply" @keydown.meta.enter="sendReply" />
<div class="row justify-end q-mt-xs">
<q-btn unelevated dense size="sm" label="Envoyer" color="primary" icon="send"
:disable="!replyContent?.trim()" :loading="sendingReply" @click="sendReply" />
<div class="row justify-end q-gutter-sm q-mt-xs">
<q-btn outline dense size="sm" label="Note interne" color="grey-8" icon="sticky_note_2"
:disable="!replyContent?.trim()" :loading="sendingReply" @click="sendReply">
<q-tooltip>Ajoute une note au fil non envoyée au client</q-tooltip>
</q-btn>
<q-btn unelevated dense size="sm" label="Envoyer par courriel" color="primary" icon="mail" @click="openEmailDialog">
<q-tooltip>Envoyer par courriel ; la réponse du destinataire reviendra dans ce ticket</q-tooltip>
</q-btn>
</div>
</div>
<!-- Aller-retour courriel : envoie le ticket, la réponse revient au fil du ticket -->
<q-dialog v-model="showEmailDialog">
<q-card style="min-width:min(92vw,460px)">
<q-card-section class="row items-center q-pb-none">
<q-icon name="mail" color="primary" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">Envoyer par courriel</div>
<q-space /><q-btn flat dense round icon="close" v-close-popup />
</q-card-section>
<q-card-section class="q-gutter-sm">
<q-input v-model="emailTo" dense outlined label="À (courriel)" type="email" autofocus />
<q-input v-model="emailCc" dense outlined label="Cc (optionnel)" />
<q-input v-model="emailMessage" dense outlined type="textarea" autogrow label="Message" :input-style="{ minHeight: '90px' }" />
<div class="text-caption text-grey-6"><q-icon name="info" size="13px" /> Un lien vers le ticket est ajouté. La réponse du destinataire s'ajoutera automatiquement à ce ticket.</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Annuler" v-close-popup />
<q-btn unelevated color="primary" icon="send" label="Envoyer" :loading="sendingEmail"
:disable="!emailTo || !/.+@.+\..+/.test(emailTo)" @click="sendTicketEmail" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Create single Dispatch Job dialog -->
<UnifiedCreateModal
v-model="showCreateDialog"
@ -287,6 +314,7 @@ import { createDoc, updateDoc, deleteDoc, listDocs } from 'src/api/erp'
import { authFetch } from 'src/api/auth'
import { useAuthStore } from 'src/stores/auth'
import { BASE_URL } from 'src/config/erpnext'
import { HUB_URL } from 'src/config/hub'
import { fetchTags, updateTag, renameTag, deleteTag as deleteTagApi } from 'src/api/dispatch'
import InlineField from 'src/components/shared/InlineField.vue'
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue'
@ -725,6 +753,48 @@ async function sendReply () {
sendingReply.value = false
}
}
// Envoi par courriel (aller-retour : la réponse revient au ticket)
const showEmailDialog = ref(false)
const emailTo = ref('')
const emailCc = ref('')
const emailMessage = ref('')
const sendingEmail = ref(false)
function openEmailDialog () {
emailTo.value = (props.doc?.raised_by && /.+@.+\..+/.test(props.doc.raised_by)) ? props.doc.raised_by : ''
emailCc.value = ''
emailMessage.value = replyContent.value?.trim() || ''
showEmailDialog.value = true
}
async function sendTicketEmail () {
if (!emailTo.value || sendingEmail.value) return
sendingEmail.value = true
try {
const res = await fetch(`${HUB_URL}/conversations/ticket-email`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
issue: props.docName, to: emailTo.value.trim(), cc: emailCc.value.trim(),
message: emailMessage.value.trim(), link: window.location.href,
}),
})
const d = await res.json()
if (d.ok) {
Notify.create({ type: 'positive', message: 'Courriel envoyé — la réponse reviendra à ce ticket', timeout: 2800 })
showEmailDialog.value = false
replyContent.value = ''
emit('reply-sent', props.docName)
} else {
Notify.create({ type: 'negative', message: d.error || 'Échec de lenvoi', timeout: 3500 })
}
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3500 })
} finally {
sendingEmail.value = false
}
}
</script>
<style scoped>

View File

@ -149,6 +149,23 @@ function getOrCreateConvForLegacyTicket (legacyTicketId, { customer, customerNam
return conv
}
// Variante ERPNext Issue : conversation courriel liée à un ticket ERPNext (Issue name = ISS-…).
// Clé = conv.linkedTickets[].name. Idempotent. La réponse du client re-thread ici (subject [Ticket #ISS-…] → TICKET_REF_RX).
function getOrCreateConvForIssue (issueName, { customer, customerName, email, subject } = {}) {
const n = String(issueName || '').trim(); if (!n) return null
for (const c of conversations.values()) {
if ((c.linkedTickets || []).some(t => t && String(t.name) === n)) { if (email && !c.email) c.email = email; if (!c.channel) c.channel = 'email'; return c }
}
const conv = createConversation({ phone: null, customer: customer || null, customerName: customerName || email || ('Ticket ' + n), agentEmail: null, subject: subject || ('Ticket ' + n) })
conv.channel = 'email'
conv.email = email || null
conv.linkedTickets = [{ name: n, subject: subject || '', ts: new Date().toISOString() }]
saveToDisk()
require('./erp').update('Issue', n, { conversation_token: conv.token }).catch(() => {}) // back-link best-effort
log(`Conv ${conv.token} liée au ticket ERPNext ${n}`)
return conv
}
// Variante ERPNext-NATIF (jobs sans ticket osTicket, ex. chaînes d'installation FR-…) : conversation liée à un Dispatch Job.
// Clé = nom du job dans conv.linkedJobs[]. Même logique lazy/idempotente/canal que getOrCreateConvForLegacyTicket.
function findConvByJob (jobName) {
@ -269,6 +286,7 @@ async function notifyCustomer (conv, message) {
const outHtml = await require('./rating').expandRatingMarker(message.html || '', conv)
const r = await gmail.sendMessage({ to: message.replyToOverride || conv.email, cc: message.cc || '', subject: subj, body: message.text || '', html: outHtml, attachments: resolveAttachments(message.attachments), threadId: conv.threadId, inReplyTo: conv.lastMessageId, from: resolveSendFrom(message.sendAs, message.agent) })
if (r && r.id) { conv.lastGmailId = r.id; conv._gmailIds = conv._gmailIds || []; if (!conv._gmailIds.includes(r.id)) conv._gmailIds.push(r.id) } // mémorise l'ID Gmail du sortant → la re-synchro du fil ne le ré-ajoute pas en double
if (r && r.threadId && !conv.threadId) conv.threadId = r.threadId // 1er envoi → mémorise le fil Gmail pour re-threader la réponse (en + du tag [Ticket #…])
log(`Email reply sent for conv ${conv.token}${conv.email} (gmail ${r.id})`)
return 'email'
} catch (e) { log(`Email reply error for conv ${conv.token}: ${e.message}`); return 'none' }
@ -761,6 +779,32 @@ async function logToLinkedTicket (conv, msg, subject) {
} catch (e) { log('logToLinkedTicket ' + issue.name + ': ' + e.message) }
}
// ALLER-RETOUR COURRIEL depuis un ticket : lie l'Issue à une conversation email, envoie dans un fil Gmail
// avec sujet [Ticket #ISS-…] + lien direct. La réponse du client re-thread ici (TICKET_REF_RX) → logToLinkedTicket
// → Communication (Received) sur l'Issue → visible dans le panneau ticket. Envoi RÉEL : gaté (feu vert humain).
async function emailTicket ({ issue, to, cc, message, agent, link } = {}) {
const iss = String(issue || '').trim()
const dest = String(to || '').trim()
if (!iss || !/.+@.+\..+/.test(dest)) return { ok: false, error: 'issue + destinataire courriel valides requis' }
let subject = 'Ticket ' + iss, customer = null
try {
const rows = await require('./erp').list('Issue', { filters: [['name', '=', iss]], fields: ['subject', 'customer'], limit: 1 })
if (rows && rows[0]) { subject = rows[0].subject || subject; customer = rows[0].customer || null }
} catch (e) { log('emailTicket lookup ' + iss + ': ' + e.message) }
const conv = getOrCreateConvForIssue(iss, { customer, email: dest, subject })
if (!conv) return { ok: false, error: 'conversation non créée' }
conv.email = dest; conv.channel = 'email'; if (!conv.lastSubject) conv.lastSubject = subject
const text = String(message || '').trim()
const esc = s => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
let html = esc(text).replace(/\n/g, '<br>')
if (link) html += `<br><br>— <a href="${esc(link)}">Ouvrir le ticket ${esc(iss)} dans OPS</a>`
const msg = addMessage(conv, { from: 'agent', text, via: 'email', html, agent: agent || '', toEmail: dest, ccRaw: cc || '' })
const chan = await notifyCustomer(conv, { text, html, cc: cc || '', replyToOverride: dest, agent })
await logToLinkedTicket(conv, msg, subject).catch(() => {})
saveToDisk()
return { ok: chan === 'email', channel: chan, conv: conv.token, issue: iss }
}
// P2 (convergence) — MIROIR osTicket : quand un AGENT répond au client sur une conversation liée à un ticket osTicket
// legacy (linkedTickets[].legacy_ticket_id), on consigne le texte dans le fil osTicket (ticket_msg) pour que F reste synchro.
// Note INTERNE (public=0) + préfixe : l'envoi RÉEL au client est DÉJÀ parti par l'Outbox (notifyCustomer) — cette entrée est un
@ -1261,6 +1305,11 @@ async function handle (req, res, method, p, url) {
try { const b = await parseBody(req); const r = await sendNewEmail({ ...(b || {}), agentEmail: req.headers['x-authentik-email'] || (b && b.agentEmail) || '' }); return json(res, r.ok ? 200 : 400, r) } catch (e) { return json(res, 500, { error: e.message }) }
}
// ALLER-RETOUR COURRIEL depuis un TICKET (Issue). Gaté (Authentik/proxy). La réponse du client re-thread → Communication sur l'Issue.
if (p === '/conversations/ticket-email' && method === 'POST') {
try { const b = await parseBody(req); const r = await emailTicket({ ...(b || {}), agent: req.headers['x-authentik-email'] || (b && b.agent) || '' }); return json(res, r.ok ? 200 : 400, r) } catch (e) { return json(res, 500, { error: e.message }) }
}
// INGESTION COURRIEL (Phase 3) — poussé par n8n (Gmail Trigger sur cc@) ou un poller. Protégé par X-Mail-Token (PAS Authentik).
if (p === '/conversations/email-ingest' && method === 'POST') {
const tok = req.headers['x-mail-token'] || ''