feat(inbox): show the agent's real name as sender + cleaner Gmail-style thread
Sender identity on outbound: - Replies/new emails now go out as e.g. "Gilles Drolet" <support@targo.ca> instead of the generic alias name "Service TARGO". The name is derived from the agent's SSO email (gilles.drolet@ -> "Gilles Drolet"); the ADDRESS stays the monitored alias so replies still land in the shared inbox. Verified: Gmail honors the custom display name on the send-as alias (sent From header = "Gilles Drolet <support@targo.ca>"). - gmail.sendFrom() exported; conversation.js agentSendFrom() builds the From from the stamped msg.agent (replies) / x-authentik-email (new sends via email-new). UI cleanup (space + clarity, Gmail-style): - One thin separator line between messages; removed the rounded gray card box around emails (.email-card flat), the email-card-head bar, and the blue expanded-head highlight. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9466645e17
commit
08069ec0dc
|
|
@ -1430,9 +1430,9 @@ function formatDate (dateStr) {
|
|||
|
||||
<style scoped>
|
||||
/* Courriel complet : carte large + iframe sandbox (rendu WYSIWYG) */
|
||||
.email-card { width: 100%; border: 1px solid #e2e8f0; border-radius: 10px; background: #fff; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.05); }
|
||||
.email-card { width: 100%; border: 0; border-radius: 0; background: transparent; overflow: hidden; }
|
||||
.email-card-agent { border-color: #c7d2fe; background: #f5f7ff; }
|
||||
.email-card-head { display: flex; align-items: center; padding: 5px 10px; background: #f8fafc; border-bottom: 1px solid #eef2f7; }
|
||||
.email-card-head { display: flex; align-items: center; padding: 2px 0; background: transparent; }
|
||||
.email-card-agent .email-card-head { background: #eef2ff; }
|
||||
.email-frame { width: 100%; height: 300px; border: 0; background: #fff; display: block; }
|
||||
.pay-card { margin: 6px 8px; padding: 8px 10px; border: 1px solid #bbf7d0; background: #f0fdf4; border-radius: 8px; }
|
||||
|
|
@ -1462,14 +1462,15 @@ function formatDate (dateStr) {
|
|||
gap: 6px;
|
||||
height: calc(100vh - 100px);
|
||||
}
|
||||
.conv-msg { display: block; width: 100%; } /* accordéon → message pleine largeur (l'alignement bulle est géré dans .msg-body) */
|
||||
.conv-msg { display: block; width: 100%; border-top: 1px solid #eceff3; } /* séparateur UNIQUE entre messages (style Gmail) — pas de boîte arrondie autour de chaque message */
|
||||
.conv-msg:first-child { border-top: none; }
|
||||
.conv-msg-customer { justify-content: flex-start; }
|
||||
.conv-msg-agent { justify-content: flex-end; }
|
||||
.conv-msg-system { justify-content: center; }
|
||||
/* En-tête repliable (accordéon style Gmail) */
|
||||
.msg-head { display: block; width: 100%; padding: 5px 8px; cursor: pointer; border-radius: 6px; transition: background .12s; }
|
||||
.msg-head:hover { background: #f1f5f9; }
|
||||
.msg-head-open { background: #eef2ff; }
|
||||
.msg-head { display: block; width: 100%; padding: 6px 4px; cursor: pointer; transition: background .12s; }
|
||||
.msg-head:hover { background: #f8fafc; }
|
||||
.msg-head-open { background: transparent; }
|
||||
.msg-head-line1 { font-size: 0.82rem; line-height: 1.2; }
|
||||
.msg-who { font-weight: 600; color: #1e293b; }
|
||||
.msg-time { color: #94a3b8; font-size: 0.72rem; white-space: nowrap; }
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ async function notifyCustomer (conv, message) {
|
|||
const gmail = require('./gmail')
|
||||
const base = conv.lastSubject || conv.subject || ''
|
||||
const subj = withTicketTag(conv, /^re\s*:/i.test(base) ? base : ('Re: ' + base))
|
||||
const r = await gmail.sendMessage({ to: conv.email, subject: subj, body: message.text || '', html: message.html || '', threadId: conv.threadId, inReplyTo: conv.lastMessageId })
|
||||
const r = await gmail.sendMessage({ to: conv.email, subject: subj, body: message.text || '', html: message.html || '', threadId: conv.threadId, inReplyTo: conv.lastMessageId, from: agentSendFrom(message.agent) })
|
||||
if (r && r.id) conv.lastGmailId = r.id
|
||||
log(`Email reply sent for conv ${conv.token} → ${conv.email} (gmail ${r.id})`)
|
||||
return 'email'
|
||||
|
|
@ -287,6 +287,19 @@ function fromDisplayName (from) {
|
|||
n = n.replace(/^['"]|['"]$/g, '').trim()
|
||||
return (n && !/@/.test(n)) ? n.slice(0, 80) : ''
|
||||
}
|
||||
// Nom d'affichage de l'agent pour le From sortant, dérivé de son courriel SSO : gilles.drolet@targo.ca → « Gilles Drolet » (gilles@ → « Gilles »).
|
||||
function agentDisplayName (email) {
|
||||
const local = String(email || '').split('@')[0]
|
||||
return local.split(/[._-]+/).filter(Boolean).map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(' ')
|
||||
}
|
||||
// From sortant PERSONNALISÉ : « Gilles Drolet » <support@targo.ca>. L'ADRESSE reste l'alias surveillé (les réponses reviennent dans l'inbox partagé) ; seul le nom affiché change. Sans nom/agent → undefined ⇒ défaut Gmail (« Service TARGO »).
|
||||
function agentSendFrom (email) {
|
||||
const name = agentDisplayName(email)
|
||||
if (!name) return undefined
|
||||
const base = require('./gmail').sendFrom()
|
||||
const addr = (String(base).match(/<([^>]+)>/) || [null, base])[1].trim()
|
||||
return `"${name.replace(/["<>]/g, '')}" <${addr}>`
|
||||
}
|
||||
function ingestGuard (email, subject) {
|
||||
const s = String(subject || '')
|
||||
const e = String(email || '').trim()
|
||||
|
|
@ -724,7 +737,7 @@ async function sendSms ({ phone, customer, customerName, message, media } = {})
|
|||
}
|
||||
|
||||
// Compose un NOUVEAU courriel SORTANT (≠ réponse) : envoie via Gmail + crée/append une conversation EMAIL suivie.
|
||||
async function sendNewEmail ({ to, subject, body, html, customer, customerName } = {}) {
|
||||
async function sendNewEmail ({ to, subject, body, html, customer, customerName, agentEmail } = {}) {
|
||||
const triage = require('./inbox-triage')
|
||||
const dest = triage.extractEmail(to) || String(to || '').trim()
|
||||
if (!dest || !/.+@.+\..+/.test(dest)) return { ok: false, error: 'destinataire courriel invalide' }
|
||||
|
|
@ -732,11 +745,11 @@ async function sendNewEmail ({ to, subject, body, html, customer, customerName }
|
|||
let conv = findConversationByEmail(dest)
|
||||
if (!conv) { conv = createConversation({ phone: null, customer: customer || null, customerName: customerName || dest, subject: subject || 'Courriel' }); conv.channel = 'email'; conv.email = dest }
|
||||
// réponse dans le fil si la conversation existe déjà (threadId connu)
|
||||
const r = await gmail.sendMessage({ to: dest, subject: withTicketTag(conv, subject || conv.lastSubject || '(sans objet)'), body: body || '', html: html || '', threadId: conv.threadId, inReplyTo: conv.lastMessageId })
|
||||
const r = await gmail.sendMessage({ to: dest, subject: withTicketTag(conv, subject || conv.lastSubject || '(sans objet)'), body: body || '', html: html || '', threadId: conv.threadId, inReplyTo: conv.lastMessageId, from: agentSendFrom(agentEmail) })
|
||||
if (r && r.threadId) conv.threadId = r.threadId
|
||||
if (r && r.id) conv.lastGmailId = r.id
|
||||
conv.lastSubject = subject || conv.lastSubject
|
||||
const m = addMessage(conv, { from: 'agent', text: (body || ''), via: 'email', html: html || '' })
|
||||
const m = addMessage(conv, { from: 'agent', text: (body || ''), via: 'email', html: html || '', agent: agentEmail || '' })
|
||||
if (conv.linkedTickets && conv.linkedTickets.length) logToLinkedTicket(conv, m, subject).catch(() => {})
|
||||
conv.lastHumanDate = todayET(); saveToDisk()
|
||||
log(`Nouveau courriel sortant → ${dest} (gmail ${r && r.id})`)
|
||||
|
|
@ -944,7 +957,7 @@ async function handle (req, res, method, p, url) {
|
|||
|
||||
// NOUVEAU courriel SORTANT depuis l'Inbox (agent). Gaté (Authentik/proxy).
|
||||
if (p === '/conversations/email-new' && method === 'POST') {
|
||||
try { const b = await parseBody(req); const r = await sendNewEmail(b || {}); return json(res, r.ok ? 200 : 400, r) } catch (e) { return json(res, 500, { error: e.message }) }
|
||||
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 }) }
|
||||
}
|
||||
|
||||
// INGESTION COURRIEL (Phase 3) — poussé par n8n (Gmail Trigger sur cc@) ou un poller. Protégé par X-Mail-Token (PAS Authentik).
|
||||
|
|
|
|||
|
|
@ -182,4 +182,4 @@ async function handle (req, res, method, p, url) {
|
|||
}
|
||||
function readBody (req) { return new Promise((resolve) => { let d = ''; req.on('data', c => { d += c; if (d.length > 5e6) req.destroy() }); req.on('end', () => { try { resolve(JSON.parse(d || '{}')) } catch (e) { resolve({}) } }); req.on('error', () => resolve({})) }) }
|
||||
|
||||
module.exports = { getToken, listMessages, getMessage, getAttachment, sendMessage, trashMessage, deleteMessage, markSpam, trashThread, poll, start, stop, handle, mailbox }
|
||||
module.exports = { getToken, listMessages, getMessage, getAttachment, sendMessage, trashMessage, deleteMessage, markSpam, trashThread, poll, start, stop, handle, mailbox, sendFrom }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user