security: harden payments/hub auth + remove leaked ERPNext token from source

Auth hardening:
- payments: per-customer JWT authorization on dual-use /payments routes
  (balance/methods/invoice/checkout/setup/portal/toggle-ppa) via authorizeCustomer
  + PAYMENTS_AUTH=enforce; portal retains+sends magic-link JWT (sessionStorage)
- hub: fail-closed Stripe webhook, /accept/doc-pdf IDOR gate, telephony field-name
  guard (SQLi), modem-bridge private-IP guard (SSRF), ALWAYS_ENFORCE expansion

Leaked-credential cleanup (token already rotated in ERPNext):
- de-hardcode ERPNext API token -> env in bulk_submit.py, import_items.py,
  apps/client/deploy.sh; placeholder in apps/ops/infra/nginx.conf (nginx injects)
- ops prod build no longer bakes VITE_ERP_TOKEN (.env.production empty)
- de-hardcode legacy DB password -> env in import_items.py
- gitignore legacy migration PII exports (tsv/json)

Conversations/UI:
- floating (un-docked) conversation panel; full-width mailbox
- per-message real sender from email headers; unified scroll; header spacing
- campaign pre-send review (subject/from/channel), Gmail send channel, clone-as-draft

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-06-16 06:17:17 -04:00
parent 54f0d271dd
commit 1b7bad1686
26 changed files with 1652 additions and 100 deletions

4
.gitignore vendored
View File

@ -5,6 +5,10 @@
apps/**/.env
apps/**/.env.local
# Legacy migration exports — customer PII, never commit (purged from history 2026-06-16)
scripts/migration/genieacs-export/*.tsv
scripts/migration/genieacs-export/devices-*.json
# Dependencies
node_modules/

View File

@ -30,9 +30,9 @@ echo "==> Installing dependencies..."
npm ci --silent
echo "==> Building PWA (base=/ for portal.gigafibre.ca)..."
# VITE_ERP_TOKEN is still needed by a few API calls that hit ERPNext
# directly (catalog, invoice PDFs). TODO: migrate these behind the hub.
VITE_ERP_TOKEN="***ERP-TOKEN-REDACTED-20260616***" DEPLOY_BASE=/ npx quasar build -m pwa
# Do NOT bake an ERPNext token into the portal bundle. Any direct ERPNext
# calls (catalog, invoice PDFs) must go through a server-side token proxy.
DEPLOY_BASE=/ npx quasar build -m pwa
if [ "${1:-}" = "local" ]; then
echo ""

View File

@ -3,8 +3,17 @@
*/
const HUB = location.hostname === 'localhost' ? 'http://localhost:3300' : 'https://msg.gigafibre.ca'
// Attach the customer's magic-link JWT (stored by the customer store) so the hub can
// authorize per-customer access to balance/methods/invoice/portal/toggle-ppa — see
// stores/customer.js TOKEN_KEY (kept in sync).
function authHeaders (base = {}) {
let t = ''
try { t = sessionStorage.getItem('targo_portal_jwt') || '' } catch { t = '' }
return t ? { ...base, Authorization: 'Bearer ' + t } : base
}
async function hubGet (path) {
const r = await fetch(HUB + path)
const r = await fetch(HUB + path, { headers: authHeaders() })
if (!r.ok) {
const data = await r.json().catch(() => ({}))
throw new Error(data.error || `Hub ${r.status}`)
@ -15,7 +24,7 @@ async function hubGet (path) {
async function hubPost (path, body) {
const r = await fetch(HUB + path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify(body),
})
const data = await r.json().catch(() => ({}))

View File

@ -2,6 +2,10 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getPortalUser } from 'src/api/portal'
// sessionStorage key holding the raw magic-link JWT for the current tab session.
// Read by src/api/payments.js to authorize /payments/* calls — keep the two in sync.
const TOKEN_KEY = 'targo_portal_jwt'
/**
* Customer session store.
*
@ -60,17 +64,20 @@ export const useCustomerStore = defineStore('customer', () => {
const email = ref('')
const customerId = ref('')
const customerName = ref('')
const token = ref('')
const loading = ref(true)
const error = ref(null)
function hydrateFromToken () {
const token = readTokenFromLocation()
if (!token) return false
const payload = decodeJwtPayload(token)
const t = readTokenFromLocation()
if (!t) return false
const payload = decodeJwtPayload(t)
if (!payload || payload.scope !== 'customer') return false
customerId.value = payload.sub
customerName.value = payload.name || payload.sub
email.value = payload.email || ''
token.value = t
try { sessionStorage.setItem(TOKEN_KEY, t) } catch { /* private mode */ }
stripTokenFromUrl()
return true
}
@ -82,6 +89,24 @@ export const useCustomerStore = defineStore('customer', () => {
// Path 1: magic-link token in URL
if (hydrateFromToken()) return
// Restore a prior magic-link session (sessionStorage) so a page reload keeps
// both the identity AND the raw token we send on /payments/* calls.
if (!customerId.value) {
let saved = ''
try { saved = sessionStorage.getItem(TOKEN_KEY) || '' } catch { saved = '' }
if (saved) {
const payload = decodeJwtPayload(saved)
if (payload && payload.scope === 'customer') {
token.value = saved
customerId.value = payload.sub
customerName.value = payload.name || payload.sub
email.value = payload.email || ''
return
}
try { sessionStorage.removeItem(TOKEN_KEY) } catch { /* expired/invalid */ }
}
}
// Already authenticated in this tab? (e.g. subsequent nav)
if (customerId.value) return
@ -102,8 +127,10 @@ export const useCustomerStore = defineStore('customer', () => {
email.value = ''
customerId.value = ''
customerName.value = ''
token.value = ''
error.value = null
try { sessionStorage.removeItem(TOKEN_KEY) } catch { /* ignore */ }
}
return { email, customerId, customerName, loading, error, init, hydrateFromToken, clear }
return { email, customerId, customerName, token, loading, error, init, hydrateFromToken, clear }
})

6
apps/ops/.env.production Normal file
View File

@ -0,0 +1,6 @@
# Production builds MUST NOT bake the ERPNext token into the JS bundle.
# In prod the ops-frontend nginx injects `Authorization: token …` server-side
# (see apps/ops/infra/nginx.conf). The client calls same-origin /ops/api and never
# holds a token. Leaving this empty keeps the admin key out of the shipped bundle.
# Local dev still uses apps/ops/.env (which may set VITE_ERP_TOKEN for `quasar dev`).
VITE_ERP_TOKEN=

View File

@ -12,7 +12,7 @@ server {
proxy_pass https://erp.gigafibre.ca;
proxy_ssl_verify off;
proxy_set_header Host erp.gigafibre.ca;
proxy_set_header Authorization "token ***ERP-TOKEN-REDACTED-20260616***";
proxy_set_header Authorization "token __ERP_API_TOKEN__"; # injected at deploy never commit a real token
proxy_set_header X-Authentik-Email $http_x_authentik_email;
proxy_set_header X-Authentik-Username $http_x_authentik_username;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@ -69,6 +69,14 @@ export function sendCampaign (id) {
})
}
// Duplicate a campaign as a fresh draft (id "_2", recipients reset to pending).
// Returns the new draft campaign so the caller can open it for review/send.
export function cloneCampaign (id) {
return hubFetch(`/campaigns/${encodeURIComponent(id)}/clone`, {
method: 'POST',
})
}
// Permanent deletion — removes the JSON on the hub. Used for clearing
// test campaigns from the list. Giftbit shortlinks are unaffected.
export function deleteCampaign (id) {

View File

@ -1,8 +1,8 @@
<template>
<component :is="inline ? 'div' : QDrawer" v-bind="rootBind" @update:model-value="onRootModel" :class="['conv-panel', { 'conv-fullpage': inline }]">
<div v-if="inline || panelOpen" :class="['conv-panel', { 'conv-fullpage': inline, 'conv-float': !inline }]" :style="!inline ? floatStyle : undefined">
<!-- List view (discussions) masquée en mode pleine page (on n'y montre que le fil) -->
<template v-if="!activeDiscussion && !inline">
<q-toolbar class="conv-toolbar">
<q-toolbar class="conv-toolbar" @pointerdown="startDrag">
<q-toolbar-title class="text-weight-bold" style="font-size:0.95rem">
Inbox
<q-badge v-if="activeCount" color="indigo-6" class="q-ml-xs">{{ activeCount }}</q-badge>
@ -113,7 +113,7 @@
<!-- Discussion detail: merged message thread -->
<template v-else-if="activeDiscussion">
<q-toolbar class="conv-toolbar">
<q-toolbar class="conv-toolbar" @pointerdown="startDrag">
<q-btn v-if="!inline" flat round dense icon="arrow_back" size="sm" @click="goBack" />
<q-toolbar-title style="font-size:0.9rem">
<div class="text-weight-bold">
@ -123,7 +123,7 @@
<div class="text-caption text-grey-6">{{ formatDate(activeDiscussion.date) }} &middot; {{ activeDiscussion.messageCount }} messages<span v-if="activeDiscussion.email"> &middot; {{ activeDiscussion.email }}</span></div>
<!-- Fiche client liée (même logique que le SMS via le numéro : ici on résout par courriel) -->
<div class="q-mt-xs">
<q-chip v-if="activeDiscussion.customer" dense size="sm" color="blue-1" text-color="blue-9" icon="person" clickable @click="openLink">{{ activeDiscussion.customerName }}<q-tooltip>Fiche liée clic pour changer</q-tooltip></q-chip>
<q-chip v-if="activeDiscussion.customer" dense size="sm" color="blue-1" text-color="blue-9" icon="person" clickable @click="openLink">Fiche<q-tooltip>Fiche liée ({{ activeDiscussion.customerName }}) clic pour changer</q-tooltip></q-chip>
<q-btn v-else dense flat size="sm" color="orange-8" icon="person_add" label="Lier une fiche" no-caps @click="openLink"><q-tooltip>Associer cette conversation à une fiche client</q-tooltip></q-btn>
</div>
<div v-if="linkedTickets.length" class="q-mt-xs">
@ -146,6 +146,9 @@
@click="confirmClose">
<q-tooltip>Fermer la session</q-tooltip>
</q-btn>
<q-btn v-if="!inline" flat round dense size="sm" icon="close" color="grey-7" @click="panelOpen = false">
<q-tooltip>Fermer la fenêtre</q-tooltip>
</q-btn>
</q-toolbar>
<!-- Barre de coordination shared-inbox : file (label) + « je m'en occupe » + réponse Gmail + ticket suggéré -->
@ -332,9 +335,11 @@
<div class="msg-head" :class="{ 'msg-head-open': isExpanded(msg) }" @click="toggleMsg(msg)">
<div class="msg-head-line1 row items-center no-wrap">
<q-icon :name="msgIcon(msg)" :color="msg.from === 'agent' ? (msg.via === 'ai' ? 'deep-purple-4' : 'indigo-5') : 'teal-6'" size="14px" class="q-mr-xs" />
<span class="msg-who">{{ msgWho(msg) }}</span>
<span v-if="msgWho(msg) && (activeDiscussion.messages || []).length > 1" class="msg-who">{{ msgWho(msg) }}</span>
<q-space />
<!-- Actions du message SUR la ligne d'en-tête (expéditeur) : répondre + agrandir le courriel -->
<q-btn v-if="msg.from === 'customer' && activeDiscussion.status === 'active'" flat dense round size="xs" icon="reply" color="indigo-5" class="q-mr-xs" @click.stop="openReply()"><q-tooltip>Répondre à ce message</q-tooltip></q-btn>
<q-btn v-if="msg.html" flat dense round size="xs" icon="open_in_full" color="grey-6" class="q-mr-xs" @click.stop="expandEmail(msg)"><q-tooltip>Agrandir le courriel (plein écran)</q-tooltip></q-btn>
<span class="msg-time q-ml-xs">{{ formatTime(msg.ts) }}</span>
<q-icon :name="isExpanded(msg) ? 'expand_less' : 'expand_more'" size="16px" color="grey-5" class="q-ml-xs" />
</div>
@ -344,10 +349,6 @@
<div v-if="isExpanded(msg)" class="msg-body">
<!-- Courriel complet (HTML) carte large + iframe sandbox (rendu WYSIWYG fidèle, scripts désactivés) -->
<div v-if="msg.html" class="email-card" :class="{ 'email-card-agent': msg.from === 'agent' }">
<div class="email-card-head">
<q-space />
<q-btn flat dense round size="xs" icon="open_in_full" color="grey-6" @click="expandEmail(msg)"><q-tooltip>Agrandir le courriel</q-tooltip></q-btn>
</div>
<iframe class="email-frame" :srcdoc="emailSrcdoc(msg.html)" sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox" referrerpolicy="no-referrer" loading="lazy" @load="fitEmailFrame"></iframe>
</div>
<!-- Bulle standard (SMS / chat / texte) -->
@ -467,7 +468,7 @@
</div>
</template>
</component>
</div>
<!-- Nouveau texto : rendu HORS du drawer (ouvrable depuis le FAB même panneau fermé). Résout la fiche par téléphone. -->
<q-dialog v-if="!inline" v-model="newDialogOpen" @hide="resetNew">
@ -667,7 +668,7 @@
<script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { useQuasar, QDrawer } from 'quasar'
import { useQuasar } from 'quasar'
import { useConversations } from 'src/composables/useConversations'
import { shortAgent, staffColor, staffInitials, noteAuthorName, relTime } from 'src/composables/useFormatters'
import { useAuthStore } from 'src/stores/auth'
@ -689,8 +690,34 @@ const {
} = useConversations()
// Racine dynamique : tiroir (défaut) OU bloc pleine page (prop `inline`) MÊME composant/logique (DRY).
const rootBind = computed(() => props.inline ? {} : { modelValue: panelOpen.value, side: 'right', width: 400, bordered: true, overlay: true })
function onRootModel (v) { if (!props.inline) panelOpen.value = v }
// Fenêtre FLOTTANTE (mode non-inline) : libre, déplaçable par la barre de titre,
// et NON bloquante (aucun fond modal) on peut continuer à naviguer (y compris
// dans la fiche client) tout en discutant. Remplace l'ancien volet ancré à droite.
const floatPos = ref(null) // { left, top } px ; null = coin bas-droite par défaut (CSS)
const floatStyle = computed(() => floatPos.value
? { left: floatPos.value.left + 'px', top: floatPos.value.top + 'px', right: 'auto', bottom: 'auto' }
: null)
let _drag = null
function startDrag (e) {
if (props.inline || e.button !== 0) return
// Ne pas déclencher le déplacement quand on clique une action de la barre.
if (e.target.closest('button, .q-btn, input, a, .q-chip, .q-toggle')) return
const panel = e.currentTarget.closest('.conv-panel')
if (!panel) return
const r = panel.getBoundingClientRect()
_drag = { dx: e.clientX - r.left, dy: e.clientY - r.top, w: r.width, h: r.height }
window.addEventListener('pointermove', onDrag)
window.addEventListener('pointerup', endDrag)
e.preventDefault()
}
function onDrag (e) {
if (!_drag) return
const m = 6
const left = Math.max(m, Math.min(e.clientX - _drag.dx, window.innerWidth - _drag.w - m))
const top = Math.max(m, Math.min(e.clientY - _drag.dy, window.innerHeight - _drag.h - m))
floatPos.value = { left, top }
}
function endDrag () { _drag = null; window.removeEventListener('pointermove', onDrag); window.removeEventListener('pointerup', endDrag) }
const coordOpen = ref(false) // chrome de coordination replié par défaut (épuration de la vue conversation)
// Créer un ticket (ERPNext Issue OUVERT) depuis la conversation la conversation RESTE
@ -1000,9 +1027,27 @@ function openReply () { replyOpen.value = true; if (coordToken.value) notifyTypi
// Ajuste un iframe courriel à SON contenu (fini le 300px fixe / les blancs). Sûr : sandbox `allow-same-origin` SANS `allow-scripts` le parent lit le DOM pour mesurer, aucun script du courriel ne s'exécute.
function fitFrame (f) {
if (!f) return
// Hauteur = TOUT le contenu (pas de plafond aucun scroll interne au message ; c'est le fil entier qui défile globalement). Garde-fou 20000px contre une valeur aberrante.
const measure = () => { try { const h = f.contentDocument.body.scrollHeight; if (h > 4) f.style.height = Math.min(Math.max(h + 8, 48), 20000) + 'px' } catch (err) { f.style.height = '300px' } }
measure(); setTimeout(measure, 80) // @load (ou un déplié) peut précéder la mise en page 2e mesure après stabilisation
// Hauteur = TOUT le contenu AUCUN scroll interne au message ; c'est le fil
// entier (et la page) qui défile d'un seul bloc. On re-mesure plusieurs fois,
// après le chargement des images, et via ResizeObserver une mesure trop tôt
// (images/layout) laisserait l'iframe trop court un scroll interne réapparaît.
const measure = () => {
try {
const doc = f.contentDocument
if (!doc) return
// On COLLAPSE l'iframe avant de mesurer : sinon `scrollHeight` renvoie la
// hauteur du viewport déjà gonflé (et non celle du contenu) boucle qui
// ratchete jusqu'au plafond 20000px. Collapsé, scrollHeight = le contenu réel.
f.style.height = '0px'
const h = Math.max(doc.documentElement ? doc.documentElement.scrollHeight : 0, doc.body ? doc.body.scrollHeight : 0)
f.style.height = (h > 4 ? Math.min(Math.max(h + 8, 48), 20000) : 48) + 'px'
} catch (err) { /* garder la hauteur courante : NE PAS forcer 300px (recréerait un scroll interne) */ }
}
measure(); setTimeout(measure, 120); setTimeout(measure, 600)
try {
const doc = f.contentDocument
if (doc) doc.querySelectorAll('img').forEach(img => { if (!img.complete) img.addEventListener('load', measure, { once: true }) })
} catch (err) { /* */ }
}
function fitEmailFrame (e) { fitFrame(e.target) }
// Re-mesure les courriels VISIBLES (le @load se produit pendant que le message est replié/display:none hauteur 0 ; il faut re-mesurer au dépliage).
@ -1073,7 +1118,16 @@ async function removeAssistant (email) { if (!coordToken.value) return; try { aw
const expandedMsgs = ref(new Set())
function isExpanded (m) { return expandedMsgs.value.has(m.id) }
function toggleMsg (m) { const s = new Set(expandedMsgs.value); s.has(m.id) ? s.delete(m.id) : s.add(m.id); expandedMsgs.value = s; refitFrames() }
function msgWho (m) { return m.from === 'agent' ? (m.via === 'ai' ? 'IA' : 'Nous') : (activeDiscussion.value?.customerName || activeDiscussion.value?.email || 'Client') }
// Expéditeur d'UN message. Sur un fil multi-messages on veut savoir QUI écrit :
// réponse d'équipe le membre (via m.agent), client son nom, IA « IA ».
// (L'affichage est masqué pour un fil à 1 seul message voir le v-if : redondant
// avec l'en-tête du fil.) Système (« Ticket créé ») pas de nom.
function msgWho (m) {
if (m.from === 'system') return ''
if (m.fromName) return m.fromName // expéditeur RÉEL de l'en-tête, PAR message (fils multi-expéditeurs)
if (m.from === 'agent') return m.via === 'ai' ? 'IA' : (m.agent ? shortAgent(m.agent) : 'Nous')
return activeDiscussion.value?.customerName || activeDiscussion.value?.email || 'Client'
}
function msgIcon (m) { return m.html ? 'mail' : m.via === 'sms' ? 'sms' : m.via === 'ai' ? 'smart_toy' : m.media ? 'image' : 'chat' }
function msgSnippet (m) { const t = String(m.text || '').replace(/^✉️[^\n]*\n+/, '').replace(/\s+/g, ' ').trim(); return t.slice(0, 64) || (m.media ? '[image]' : m.html ? '[courriel]' : '') }
function cleanBody (t) { return String(t || '').replace(/^✉️[^\n]*\n+/, '') } // retire l'ancien préfixe sujet collé au corps (double en-tête)
@ -1456,8 +1510,18 @@ function formatDate (dateStr) {
.conv-toolbar {
background: #fff;
border-bottom: 1px solid #e2e8f0;
min-height: 48px;
align-items: flex-start; /* en-tête multi-lignes : aligner EN HAUT (le centrage faisait chevaucher les lignes) */
padding: 8px 6px 10px 10px; /* de l'air en haut/bas → plus de collision avec la barre FILE en dessous */
height: auto;
row-gap: 4px;
}
/* En fenêtre flottante (hauteur fixe), les rangées d'en-tête NE DOIVENT PAS rétrécir
(sinon leur contenu déborde et chevauche la rangée suivante). Seule la zone des
messages flexe et défile. */
.conv-toolbar, .coord-bar, .coord-bar2, .coord-bar3, .coord-assist { flex: 0 0 auto; }
/* Lignes empilées du titre (nom · date/email · « Lier une fiche » · ticket) aérées */
.conv-toolbar :deep(.q-toolbar__title) { padding: 2px 0; line-height: 1.45; white-space: normal; }
.conv-toolbar :deep(.q-toolbar__title) > div + div { margin-top: 5px; }
.bulk-bar {
display: flex;
align-items: center;
@ -1497,7 +1561,7 @@ function formatDate (dateStr) {
.coord-assist { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; padding: 2px 8px 5px; background: #f8fafc; border-bottom: 1px solid #e2e8f0; }
/* Coordination shared-inbox */
.coord-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; padding: 5px 8px; background: #f8fafc; border-bottom: 1px solid #e2e8f0; }
.coord-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding: 7px 10px; background: #f8fafc; border-bottom: 1px solid #e2e8f0; }
.coord-bar2 { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; padding: 3px 8px; background: #f8fafc; border-bottom: 1px solid #e2e8f0; }
.svc-bar { padding: 6px 9px; background: #ecfeff; border-bottom: 1px solid #cffafe; }
.coord-lbl { font-size: 0.68rem; font-weight: 700; color: #94a3b8; text-transform: uppercase; margin-right: 2px; }
@ -1548,6 +1612,21 @@ function formatDate (dateStr) {
/* Plein écran : colonne flex qui remplit la hauteur ; le fil défile, le composeur reste en bas */
.conv-fullpage { display: flex; flex-direction: column; width: 100%; background: #fff; min-height: 0; }
.conv-fullpage .conv-messages { flex: 1 1 auto; overflow-y: auto; height: auto; min-height: 0; }
/* Fenêtre FLOTTANTE (remplace le volet ancré) : coin bas-droite par défaut, déplaçable,
NON bloquante (pas de fond modal la page reste utilisable pendant la discussion). */
.conv-float {
position: fixed; right: 18px; bottom: 18px;
width: 400px; height: min(78vh, 720px); max-height: calc(100vh - 36px);
background: #fff; border: 1px solid var(--ops-border, #e2e8f0);
border-radius: 12px; box-shadow: 0 12px 40px rgba(15, 23, 42, .22);
z-index: 3000; display: flex; flex-direction: column; overflow: hidden;
}
.conv-float .conv-messages { flex: 1 1 auto; overflow-y: auto; min-height: 0; }
.conv-float .conv-toolbar { cursor: move; user-select: none; } /* barre de titre = poignée de déplacement */
@media (max-width: 700px) {
/* sur mobile, la fenêtre occupe presque tout l'écran */
.conv-float { right: 8px; left: 8px; bottom: 8px; top: 8px; width: auto; height: auto; max-height: none; }
}
/* Chaque courriel s'ajuste à SON contenu (fitEmailFrame) — plus de hauteur fixe ni d'étirement du dernier message. */
.conv-loading { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; height: 50vh; color: #94a3b8; font-size: 0.9rem; }
</style>

View File

@ -43,8 +43,8 @@
</div>
<div class="mlist-body">
<div v-for="d in shown" :key="d.id" class="mrow" :class="{ 'mrow-unread': isUnread(d), 'mrow-checked': checked.has(d.id) }" @click="open(d)">
<q-checkbox :model-value="checked.has(d.id)" dense size="xs" class="mrow-cb" @click.stop @update:model-value="toggleCheck(d.id)" />
<div v-for="(d, idx) in shown" :key="d.id" class="mrow" :class="{ 'mrow-unread': isUnread(d), 'mrow-checked': checked.has(d.id) }" @click="open(d)">
<q-checkbox :model-value="checked.has(d.id)" dense size="xs" class="mrow-cb" @click.stop="onRowCheck($event, idx, d.id)" />
<!-- icône de canal seulement pour SMS/chat/Facebook ; pour le courriel (cas par défaut) elle est redondante -->
<q-icon v-if="d.channel && d.channel !== 'email'" :name="chanIcon(d.channel)" :color="chanColor(d.channel)" size="17px" class="mrow-chan" />
<!-- Ligne unique pleine largeur (style Gmail) : Nom · file · Sujet aperçu · indicateurs · heure -->
@ -216,7 +216,23 @@ const shown = computed(() => {
}).sort((a, b) => String(lastTs(b) || '').localeCompare(String(lastTs(a) || '')))
})
function toggleCheck (id) { const s = new Set(checked.value); s.has(id) ? s.delete(id) : s.add(id); checked.value = s }
// Sélection par plage (style Gmail) : cocher une case, puis SHIFT+clic sur une
// autre case sélectionne TOUTES les lignes entre les deux. Sinon, bascule simple.
// `lastIdx` = ancre (dernière case cliquée sans shift).
const lastIdx = ref(null)
function onRowCheck (e, idx, id) {
const s = new Set(checked.value)
if (e && e.shiftKey && lastIdx.value !== null && lastIdx.value !== idx) {
const a = Math.min(lastIdx.value, idx)
const b = Math.max(lastIdx.value, idx)
for (let i = a; i <= b; i++) { const row = shown.value[i]; if (row) s.add(row.id) }
if (window.getSelection) { try { window.getSelection().removeAllRanges() } catch (_) { /* évite la sélection de texte au shift-clic */ } }
} else {
s.has(id) ? s.delete(id) : s.add(id)
}
checked.value = s
lastIdx.value = idx
}
const checkedDiscs = computed(() => shown.value.filter(d => checked.value.has(d.id)))
// Case maîtresse « tout cocher » : true=tout, null=indéterminé (partiel), false=rien
const allChecked = computed(() => shown.value.length > 0 && shown.value.every(d => checked.value.has(d.id)))
@ -277,8 +293,9 @@ onMounted(() => { if (!discussions.value || !discussions.value.length) fetchList
</script>
<style scoped>
/* Pleine hauteur ; largeur PLAFONNÉE à une taille confortable (ni étriquée, ni étirée à l'infini sur grand écran), ancrée à gauche */
.mlist { display: flex; flex-direction: column; height: calc(100vh - 50px); max-width: 1280px; }
/* Pleine hauteur et PLEINE LARGEUR : le panneau de messages est maintenant une
fenêtre flottante (plus de volet ancré à droite), donc la boîte occupe tout l'espace. */
.mlist { display: flex; flex-direction: column; height: calc(100vh - 50px); width: 100%; }
.mlist-head { display: flex; align-items: center; gap: 6px; padding: 8px 12px 4px; }
.mlist-search { flex: 1; max-width: 520px; }
/* Une SEULE ligne (les puces défilent horizontalement si trop nombreuses) → hauteur constante, donc la sélection ne décale jamais la liste */

View File

@ -0,0 +1,29 @@
<template>
<!-- Sending channel for a campaign: Gmail (direct, no "via mailjet", replies
land in the Inbox, no open/click tracking) vs Mailjet (tracking, volume). -->
<div>
<div class="text-caption text-grey-7 q-mb-xs">Canal d'envoi</div>
<q-btn-toggle
:model-value="modelValue"
@update:model-value="v => emit('update:modelValue', v)"
spread no-caps unelevated toggle-color="primary" color="grey-3" text-color="grey-8"
:options="[
{ label: 'Mailjet', value: 'mailjet' },
{ label: 'Gmail (direct)', value: 'gmail' },
]"
/>
<div class="text-caption text-grey-6 q-mt-xs">
<span v-if="modelValue === 'gmail'">
Envoi direct depuis la boîte du domaine <strong>aucun « via mailjet »</strong>, les réponses retombent dans l'Inbox. Pas de suivi ouvertures/clics · limite ~2000/jour. L'expéditeur doit être un alias Gmail vérifié.
</span>
<span v-else>
Envoi via Mailjet suivi des <strong>ouvertures, clics et rebonds</strong>. Idéal pour le volume.
</span>
</div>
</div>
</template>
<script setup>
defineProps({ modelValue: { type: String, default: 'mailjet' } })
const emit = defineEmits(['update:modelValue'])
</script>

View File

@ -0,0 +1,73 @@
<template>
<!-- Editable "From" field: a plain text input (free text always preserved)
with an auto-suggest dropdown of validated Mailjet senders. Type freely,
or pick a suggestion. -->
<q-input
:model-value="modelValue"
@update:model-value="onInput"
@focus="menuOpen = true"
:label="label"
outlined dense
:placeholder="placeholder"
:hint="hint"
spellcheck="false"
autocomplete="off"
>
<template #append>
<q-icon name="expand_more" class="cursor-pointer" @click.stop="menuOpen = !menuOpen" />
</template>
<q-menu
:model-value="menuOpen && filtered.length > 0"
@update:model-value="v => menuOpen = v"
no-focus no-refocus fit anchor="bottom left" self="top left"
>
<q-list dense style="min-width:320px">
<q-item v-for="s in filtered" :key="s" clickable v-close-popup
:active="s === modelValue" @click="pick(s)">
<q-item-section class="text-body2">{{ s }}</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-input>
</template>
<script setup>
import { ref, computed } from 'vue'
// Validated Mailjet senders single source of truth for the From picker.
// Mailjet only delivers from a validated sender; an unvalidated address
// silently falls back to noreply@targo.ca, so we surface the known-good ones.
// The field stays free-text, so a newly-validated address can just be typed.
const SENDERS = [
'Facturation Targo <facturation@targo.ca>',
'Service TARGO <support@targointernet.com>',
'TARGO <noreply@targo.ca>',
]
const props = defineProps({
modelValue: { type: String, default: '' },
label: { type: String, default: 'Expéditeur (From)' },
placeholder: { type: String, default: 'Facturation Targo <facturation@targo.ca>' },
hint: { type: String, default: 'Expéditeur validé Mailjet — saisie libre, ou choisir une suggestion' },
})
const emit = defineEmits(['update:modelValue'])
const menuOpen = ref(false)
const filtered = computed(() => {
const q = (props.modelValue || '').toLowerCase().trim()
// Empty, or already an exact match (pre-filled / just picked) show the
// full list so the dropdown stays a useful picker. Otherwise substring-filter.
if (!q || SENDERS.some(s => s.toLowerCase() === q)) return SENDERS
return SENDERS.filter(s => s.toLowerCase().includes(q))
})
function onInput (val) {
emit('update:modelValue', val == null ? '' : val)
menuOpen.value = true
}
function pick (s) {
emit('update:modelValue', s)
menuOpen.value = false
}
</script>

View File

@ -12,11 +12,22 @@
>
<q-tooltip>Télécharger le rapport (shortlinks Giftbit, emails, statuts d'envoi)</q-tooltip>
</q-btn>
<q-btn v-if="campaign?.recipients?.length" flat dense icon="visibility" color="indigo-6"
label="Aperçu" class="q-mr-sm" @click="openPreview()">
<q-tooltip>Voir le courriel tel qu'envoyé (rendu du template avec un destinataire réel) conservé avec la campagne</q-tooltip>
</q-btn>
<q-btn v-if="canCreateReminder" flat dense icon="schedule" color="orange-9"
label="Créer une relance" class="q-mr-sm"
:loading="creatingReminder" @click="confirmCreateReminder">
<q-tooltip>Cloner cette campagne pour les destinataires qui n'ont PAS cliqué le cadeau encore</q-tooltip>
</q-btn>
<!-- Re-send path: clone a locked (sent) campaign into a fresh draft where
the sender + subjects are editable again. -->
<q-btn v-if="campaign && campaign.status !== 'draft'" flat dense icon="content_copy" color="primary"
label="Dupliquer (brouillon)" class="q-mr-sm"
:loading="cloning" @click="cloneAndOpen">
<q-tooltip>Crée une copie en brouillon (tous les destinataires réinitialisés) pour réenvoyer, ex. avec un autre expéditeur</q-tooltip>
</q-btn>
<!-- Draft-only edit affordances: params dialog + jump to template
editor. Both invisible once status flips off draft to keep
operators from accidentally mutating a campaign mid-send. -->
@ -31,7 +42,7 @@
<q-tooltip>Sujet, expéditeur, montant affiché, choix de template (FR/EN), etc.</q-tooltip>
</q-btn>
<q-btn v-if="campaign?.status === 'draft'" unelevated color="primary" icon="send" label="Lancer l'envoi"
:loading="resending" @click="relaunch" />
:loading="resending" @click="openSendConfirm" />
</div>
<!-- Counters bar -->
@ -188,10 +199,14 @@
<q-card-section>
<q-form @submit="saveEditParams" class="q-gutter-sm">
<q-input v-model="editParams.name" label="Nom interne (visible uniquement dans l'admin)" outlined dense />
<q-input v-model="editParams.subject" label="Sujet du courriel" outlined dense
:rules="[v => !!v || 'requis']" />
<q-input v-model="editParams.from" label="Expéditeur (From)" outlined dense
placeholder="TARGO <noreply@targo.ca>" />
<div class="row q-col-gutter-sm">
<q-input v-model="editParams.subject_fr" label="Sujet (FR)" outlined dense class="col-12 col-sm-6"
:rules="[v => !!v || 'requis']" />
<q-input v-model="editParams.subject_en" label="Sujet (EN)" outlined dense class="col-12 col-sm-6"
placeholder="(défaut : sujet FR si vide)" />
</div>
<SenderField v-model="editParams.from" />
<ChannelToggle v-model="editParams.channel" />
<div class="row q-col-gutter-sm">
<q-input v-model="editParams.amount" label="Montant affiché" outlined dense
class="col-12 col-sm-4" placeholder="60 $" />
@ -251,6 +266,89 @@
</q-card-section>
</q-card>
</q-dialog>
<!-- Pre-send review. Final summary + last chance to edit the per-language
subject before the send commits. Replaces the old fire-immediately
"Lancer l'envoi" behaviour. -->
<q-dialog v-model="sendConfirmOpen" persistent>
<q-card style="min-width:420px;max-width:560px">
<q-card-section class="row items-center q-pb-none">
<q-icon name="send" color="primary" size="sm" class="q-mr-sm" />
<div class="text-h6">Vérifier avant l'envoi</div>
<q-space />
<q-btn flat dense round icon="close" v-close-popup />
</q-card-section>
<q-card-section class="q-gutter-sm">
<!-- Recipients + From + templates: read-only summary -->
<q-list dense bordered class="rounded-borders q-mb-sm">
<q-item>
<q-item-section avatar><q-icon name="group" color="grey-7" /></q-item-section>
<q-item-section>
<q-item-label caption>Destinataires</q-item-label>
<q-item-label>
<strong>{{ campaign?.recipients?.length || 0 }}</strong>
<q-badge color="blue-7" class="q-ml-sm" v-if="langBreakdown.fr">{{ langBreakdown.fr }} FR</q-badge>
<q-badge color="teal-7" class="q-ml-xs" v-if="langBreakdown.en">{{ langBreakdown.en }} EN</q-badge>
<q-badge color="grey-6" class="q-ml-xs" v-if="langBreakdown.other">{{ langBreakdown.other }} autre</q-badge>
</q-item-label>
</q-item-section>
</q-item>
<q-separator />
<q-item>
<q-item-section avatar><q-icon name="description" color="grey-7" /></q-item-section>
<q-item-section>
<q-item-label caption>Templates</q-item-label>
<q-item-label class="text-body2">
FR : {{ campaign?.params?.template_fr || '—' }}<span v-if="langBreakdown.en"> · EN : {{ campaign?.params?.template_en || '' }}</span>
</q-item-label>
</q-item-section>
</q-item>
</q-list>
<!-- Channel + editable sender + subjects fix them right before sending -->
<ChannelToggle v-model="sendForm.channel" />
<SenderField v-model="sendForm.from" />
<q-input v-model="sendForm.subject_fr" label="Sujet (FR)" outlined dense
:rules="[v => !!v || 'requis']" />
<q-input v-model="sendForm.subject_en" label="Sujet (EN)" outlined dense
v-if="langBreakdown.en"
placeholder="(défaut : sujet FR si vide)" />
<div class="text-caption text-grey-7">
Chaque client reçoit le sujet de sa langue (FR sujet FR, EN sujet EN).
</div>
</q-card-section>
<q-card-actions align="right" class="q-pa-md">
<q-btn flat label="Annuler" v-close-popup :disable="resending" />
<q-btn unelevated color="primary" icon="send"
:label="`Confirmer l'envoi (${campaign?.recipients?.length || 0})`"
:loading="resending" :disable="!sendForm.subject_fr"
@click="confirmSend" />
</q-card-actions>
</q-card>
</q-dialog>
<!-- Aperçu de l'envoi : rendu exact du template (FR/EN), conservé avec la campagne. -->
<q-dialog v-model="previewOpen">
<q-card style="width:680px;max-width:95vw;height:82vh;display:flex;flex-direction:column">
<q-card-section class="row items-center q-py-sm">
<q-icon name="visibility" color="indigo-6" size="sm" class="q-mr-sm" />
<div class="text-h6">Aperçu de l'envoi</div>
<q-space />
<q-btn-toggle :model-value="previewLang" @update:model-value="openPreview" dense no-caps unelevated size="sm"
toggle-color="primary" color="grey-3" text-color="grey-8" class="q-mr-sm"
:options="[{ label: 'FR', value: 'fr' }, { label: 'EN', value: 'en' }]" />
<q-btn flat dense round icon="close" v-close-popup />
</q-card-section>
<q-separator />
<div class="col" style="flex:1;min-height:0;position:relative;background:#f4f6f8">
<q-inner-loading :showing="previewLoading" />
<iframe v-if="previewHtml" :srcdoc="previewHtml" title="Aperçu"
style="width:100%;height:100%;border:0;display:block" sandbox="allow-same-origin"></iframe>
</div>
</q-card>
</q-dialog>
</q-page>
</template>
@ -258,7 +356,9 @@
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import { getCampaign, sendCampaign, campaignSseUrl, campaignReportCsvUrl, retryRecipient, createReminderCampaign, updateCampaign, listTemplates } from 'src/api/campaigns'
import { getCampaign, sendCampaign, cloneCampaign, campaignSseUrl, campaignReportCsvUrl, retryRecipient, createReminderCampaign, updateCampaign, listTemplates, previewTemplate } from 'src/api/campaigns'
import SenderField from 'src/modules/campaigns/components/SenderField.vue'
import ChannelToggle from 'src/modules/campaigns/components/ChannelToggle.vue'
const route = useRoute()
const router = useRouter()
@ -268,6 +368,56 @@ const campaign = ref(null)
const loading = ref(true)
const resending = ref(false)
const creatingReminder = ref(false)
const cloning = ref(false)
// Aperçu de l'envoi
// Rend le template de la campagne (FR/EN) exactement comme envoyé, avec les
// données d'un vrai destinataire. Permet de « conserver l'aperçu » sans encombrer
// l'Inbox (aucun courriel envoyé) on n'ingère plus nos propres envois.
const previewOpen = ref(false)
const previewHtml = ref('')
const previewLang = ref('fr')
const previewLoading = ref(false)
async function openPreview (lang) {
const p = campaign.value?.params || {}
previewLang.value = (lang === 'fr' || lang === 'en') ? lang : (previewLang.value || 'fr')
const tpl = previewLang.value === 'en' ? (p.template_en || p.template_fr) : (p.template_fr || p.template_en)
if (!tpl) { $q.notify({ type: 'warning', message: 'Aucun template défini sur cette campagne.' }); return }
// Destinataire représentatif : le 1er de cette langue, sinon le 1er tout court.
const recips = campaign.value?.recipients || []
const r = recips.find(x => (x.language || 'fr') === previewLang.value) || recips[0] || {}
previewOpen.value = true
previewLoading.value = true
try {
const res = await previewTemplate(tpl, { vars: {
firstname: r.firstname || (previewLang.value === 'en' ? 'dear customer' : 'cher client'),
lastname: r.lastname || '', email: r.email || '', amount: r.amount || p.amount || '',
} })
previewHtml.value = res.rendered || ''
} catch (e) { $q.notify({ type: 'negative', message: e.message }); previewHtml.value = '' } finally { previewLoading.value = false }
}
// Pre-send review dialog
// "Lancer l'envoi" no longer fires immediately; it opens a summary where the
// operator confirms the From, the recipient count (split by language), the
// templates, AND can edit the per-language subject one last time before the
// send commits. sendForm is a snapshot of the subjects; on confirm we PATCH
// any change back (params are merged on the hub) then trigger the send.
const sendConfirmOpen = ref(false)
const sendForm = ref({ channel: 'mailjet', from: '', subject_fr: '', subject_en: '' })
// Recipient count split by language drives the summary line so the operator
// sees exactly how many FR vs EN subjects will go out.
const langBreakdown = computed(() => {
const out = { fr: 0, en: 0, other: 0 }
for (const r of (campaign.value?.recipients || [])) {
const l = (r.language || '').toLowerCase()
if (l === 'fr') out.fr++
else if (l === 'en') out.en++
else out.other++
}
return out
})
// Edit-params dialog state. editParams is a snapshot of campaign.params
// that the form mutates; on save we PATCH only that subset back to the
@ -275,7 +425,7 @@ const creatingReminder = ref(false)
const editParamsOpen = ref(false)
const savingParams = ref(false)
const editParams = ref({
name: '', subject: '', from: '', amount: '', commitment_months: 3,
name: '', subject: '', subject_fr: '', subject_en: '', from: '', channel: 'mailjet', amount: '', commitment_months: 3,
expiry: '', template_fr: 'gift-email-fr', template_en: 'gift-email-en',
// UI-only derived from / writes back to params.gift_expires_at (full ISO).
gift_expires_at_display: '',
@ -463,10 +613,57 @@ function subscribeSse () {
})
}
async function relaunch () {
// Open the pre-send review dialog, snapshotting the current per-language
// subjects so the operator can adjust them in place before committing.
function openSendConfirm () {
const p = campaign.value?.params || {}
sendForm.value = {
channel: p.channel || 'mailjet',
from: p.from || '',
subject_fr: p.subject_fr || p.subject || '',
subject_en: p.subject_en || '',
}
sendConfirmOpen.value = true
}
// Duplicate this (locked, already-sent) campaign into a fresh draft and open
// it. The clone resets recipients to pending and is a draft, so the From
// picker, subjects and template are all editable again before re-sending.
// The page binds `id` at setup, so navigate then reload to bind to the clone.
async function cloneAndOpen () {
cloning.value = true
try {
const clone = await cloneCampaign(id)
$q.notify({ type: 'positive', message: `Copie créée en brouillon : ${clone.name}` })
await router.push(`/campaigns/${clone.id}`)
window.location.reload()
} catch (e) {
$q.notify({ type: 'negative', message: e.message })
cloning.value = false
}
}
// Confirm from the review dialog: persist any subject edit (PATCH merges into
// params on the hub), then fire the actual send.
async function confirmSend () {
const p = campaign.value?.params || {}
const changed = sendForm.value.channel !== (p.channel || 'mailjet')
|| sendForm.value.from !== (p.from || '')
|| sendForm.value.subject_fr !== (p.subject_fr || p.subject || '')
|| sendForm.value.subject_en !== (p.subject_en || '')
resending.value = true
try {
if (changed) {
await updateCampaign(id, { params: {
channel: sendForm.value.channel,
from: sendForm.value.from,
subject_fr: sendForm.value.subject_fr,
subject_en: sendForm.value.subject_en,
subject: sendForm.value.subject_fr, // legacy single-subject fallback
} })
}
await sendCampaign(id)
sendConfirmOpen.value = false
await load()
subscribeSse()
} catch (e) {
@ -508,7 +705,10 @@ function openEditParams () {
editParams.value = {
name: campaign.value?.name || '',
subject: p.subject || '',
subject_fr: p.subject_fr || p.subject || '',
subject_en: p.subject_en || '',
from: p.from || '',
channel: p.channel || 'mailjet',
amount: p.amount || '',
commitment_months: p.commitment_months || 3,
expiry: p.expiry || '',
@ -538,8 +738,11 @@ async function saveEditParams () {
await updateCampaign(id, {
name: editParams.value.name,
params: {
subject: editParams.value.subject,
subject: editParams.value.subject_fr || editParams.value.subject, // repli legacy = sujet FR
subject_fr: editParams.value.subject_fr,
subject_en: editParams.value.subject_en,
from: editParams.value.from,
channel: editParams.value.channel,
amount: editParams.value.amount,
commitment_months: editParams.value.commitment_months,
expiry: editParams.value.expiry,

View File

@ -19,13 +19,14 @@ Usage:
import argparse
import json
import os
import sys
import time
import requests
from urllib.parse import quote
BASE = "https://erp.gigafibre.ca"
TOKEN = "***ERP-TOKEN-REDACTED-20260616***"
TOKEN = os.environ.get("ERP_TOKEN", "") # api_key:api_secret — set via env, never commit
HEADERS = {
"Authorization": f"token {TOKEN}",
"Content-Type": "application/json",

View File

@ -5,6 +5,7 @@ Migration Script: Legacy product -> ERPNext Item
Python 3.5 compatible (no f-strings)
"""
import json
import os
import sys
import requests
import pymysql
@ -21,12 +22,12 @@ except ImportError:
LEGACY = {
"host": "legacy-db",
"user": "facturation",
"password": "***LEGACY-DB-PW-REDACTED***",
"password": os.environ.get("LEGACY_DB_PASSWORD", ""),
"database": "gestionclient",
}
ERP_URL = "https://erp.gigafibre.ca"
ERP_TOKEN = "token ***ERP-TOKEN-REDACTED-20260616***"
ERP_TOKEN = "token " + os.environ.get("ERP_TOKEN", "") # set via env, never commit
CAT_MAP = {
1: u"Garantie prolongée",

View File

@ -441,6 +441,13 @@ async function handle (req, res, method, path) {
// GET /accept/doc-pdf/:doctype/:name — Download PDF of any document (authenticated)
if (path.startsWith('/accept/doc-pdf/') && method === 'GET') {
// Route « PDF de N'IMPORTE QUEL doc » → staff-only. Sous le préfixe PUBLIC
// /accept, le gate ne la couvre pas : on exige le token hub ici (Ops passe par
// le proxy /ops/hub qui l'injecte), sinon IDOR anonyme.
const tok = (req.headers.authorization || '').replace(/^Bearer\s+/i, '')
if (!process.env.HUB_SERVICE_TOKEN || tok !== process.env.HUB_SERVICE_TOKEN) {
return json(res, 401, { error: 'Unauthorized' })
}
const parts = path.replace('/accept/doc-pdf/', '').split('/')
const doctype = decodeURIComponent(parts[0] || '')
const name = decodeURIComponent(parts[1] || '')

View File

@ -40,6 +40,7 @@ const cfg = require('./config')
const { log, json, parseBody } = require('./helpers')
const erp = require('./erp')
const email = require('./email')
const gmail = require('./gmail')
const sse = require('./sse')
// MJML v5 is async — compile MJML source to email-safe HTML server-side at
// save time so the send-worker only ever reads pre-compiled HTML.
@ -880,8 +881,13 @@ function resolveTemplatePath (p, lang) {
const candidate = path.join(TEMPLATES_DIR, tplName + '.html')
if (fs.existsSync(candidate)) return candidate
}
// Legacy single-template campaign-wide override
if (p?.template_path) return p.template_path
// Legacy single-template override — CONFINÉ à TEMPLATES_DIR (anti-LFI : `params`
// vient de la requête ; sans ça, /view lirait n'importe quel fichier du serveur).
if (p?.template_path) {
const base = path.resolve(TEMPLATES_DIR)
const resolved = path.resolve(base, p.template_path)
if ((resolved === base || resolved.startsWith(base + path.sep)) && fs.existsSync(resolved)) return resolved
}
return templateForLanguage(lang)
}
@ -1176,15 +1182,24 @@ async function sendCampaignAsync (id) {
let sendRes = null
let lastErrMessage = null
const attempts = 1 + RETRY_BACKOFF_MS.length
// Sujet PAR LANGUE du destinataire (subject_fr / subject_en) ; repli sur le sujet unique p.subject pour les campagnes legacy.
const subject = (lang === 'en' ? p.subject_en : p.subject_fr) || p.subject || 'Un cadeau pour toi, de la part de TARGO'
const fromAddr = p.from || cfg.MAIL_FROM
for (let attempt = 1; attempt <= attempts; attempt++) {
try {
sendRes = await email.sendEmail({
to,
subject: p.subject || 'Un cadeau pour toi, de la part de TARGO',
html,
from: p.from || cfg.MAIL_FROM,
headers: { 'X-MJ-CustomID': customId },
})
if (p.channel === 'gmail') {
// Envoi DIRECT depuis la boîte Workspace impersonnée (alias « send-as »
// vérifié). Le courriel part du vrai domaine → AUCUN « via mailjet »
// chez le client, et les réponses retombent dans l'Inbox. Compromis :
// pas de suivi ouvertures/clics (pas d'Event API Mailjet).
const gr = await gmail.sendMessage({ to, subject, html, from: fromAddr })
sendRes = { messageId: gr.id || null }
} else {
sendRes = await email.sendEmail({
to, subject, html, from: fromAddr,
headers: { 'X-MJ-CustomID': customId },
})
}
} catch (e) {
sendRes = false
lastErrMessage = String(e.message || e)
@ -2174,6 +2189,54 @@ async function handle (req, res, method, path) {
return json(res, 202, { id, status: 'sending' })
}
// POST /campaigns/:id/clone — duplicate a campaign as a fresh DRAFT.
// Copies the params (subject(s), From, templates, amount…) and the recipient
// list, but resets every per-recipient send-lifecycle field back to pending
// so the clone can be reviewed and re-sent from scratch. Used to re-run a
// completed campaign (e.g. with a corrected sender) without touching the
// original's history/metrics. The clone is a draft, so the From picker and
// the pre-send review are fully editable on it.
const cloneMatch = path.match(/^\/campaigns\/([^/]+)\/clone$/)
if (cloneMatch && method === 'POST') {
const src = loadCampaign(cloneMatch[1])
if (!src) return json(res, 404, { error: 'not found' })
// Pick a non-colliding "_N" id suffix (strip any existing one first), and
// a matching " (N)" on the display name.
const baseId = src.id.replace(/_\d+$/, '')
let n = 2
while (fs.existsSync(campaignPath(`${baseId}_${n}`))) n++
const newId = `${baseId}_${n}`
const baseName = (src.name || 'Campagne').replace(/\s*\(\d+\)\s*$/, '')
const recipients = (src.recipients || []).map((r, i) => ({
...r,
row_index: i + 1,
status: 'pending', // re-send everyone (excluded flag preserved)
error: null,
retry_count: 0,
sent_at: null,
opened_at: null,
clicked_at: null,
mailjet_uuid: null,
gift_token: null, // worker regenerates → clean click tracking
gift_link_clicked: false,
// Drop stale reminder lineage if the source was itself a reminder.
parent_campaign_id: undefined,
parent_row_index: undefined,
}))
const clone = {
id: newId,
name: `${baseName} (${n})`,
created_at: new Date().toISOString(),
status: 'draft',
cloned_from: src.id,
params: { ...(src.params || {}) },
recipients,
}
const saved = saveCampaign(clone)
log(`campaign cloned: ${src.id}${newId} (${recipients.length} recipients, draft)`)
return json(res, 201, saved)
}
// POST /campaigns/:id/reminder — clone the campaign for the non-clickers.
// Creates a brand-new draft campaign rooted at the same Giftbit shortlinks
// but with the reminder templates (gift-email-reminder-*) and an urgency

View File

@ -61,10 +61,13 @@ function getConversation (token) {
return conv
}
function addMessage (conv, { from, text, type = 'text', via = 'web', media = '', html = '', agent = '' }) {
function addMessage (conv, { from, text, type = 'text', via = 'web', media = '', html = '', agent = '', fromName = '', fromEmail = '', gmail_id = '' }) {
const msg = { id: crypto.randomUUID(), from, text, type, via, media, ts: new Date().toISOString() }
if (html) msg.html = html // courriel : HTML complet (rendu WYSIWYG iframe côté Ops)
if (from === 'agent' && agent) msg.agent = agent // identité de l'agent qui répond → stats « réponses par employé » (classement Inbox)
if (fromName) msg.fromName = fromName // expéditeur RÉEL (en-tête From) PAR message → fil multi-expéditeurs affiche le bon nom
if (fromEmail) msg.fromEmail = fromEmail
if (gmail_id) msg.gmail_id = gmail_id // mapping message↔Gmail (backfill / robustesse)
conv.messages.push(msg)
if (!conv.initiatedBy) conv.initiatedBy = from // qui a OUVERT le fil : 'agent' = nous (notif/relance → l'IA n'auto-répond pas) ; 'customer' = demande entrante
conv.lastActivity = msg.ts
@ -361,7 +364,7 @@ function listConversations ({ includeAll = false } = {}) {
out.push({
token: conv.token, phone: conv.phone, customer: conv.customer,
customerName: conv.customerName, agentEmail: conv.agentEmail, subject: conv.subject,
channel: conv.channel || 'sms', email: conv.email || null, noise: !!conv.noise,
channel: conv.channel || 'sms', email: conv.email || null, threadId: conv.threadId || null, noise: !!conv.noise,
queue: conv.queue || '', assignee: conv.assignee || null,
suggestedTicket: conv.suggestedTicket || null, linkedTickets: conv.linkedTickets || [],
status: expired ? 'expired' : conv.status, messageCount: conv.messages.length,
@ -375,7 +378,11 @@ function listConversations ({ includeAll = false } = {}) {
for (const c of out) {
const isEmail = c.channel === 'email'
const phoneKey = c.phone ? c.phone.replace(/\D/g, '').slice(-10) : '' // null-safe (les convs EMAIL n'ont pas de téléphone)
const baseKey = isEmail ? ('email:' + (c.email || c.token)) : (phoneKey || ('t:' + c.token))
// Regroupement courriel : par FIL GMAIL d'abord (client + réponses équipe + copies CC = même thread), sinon par courriel CLIENT (jamais une de NOS adresses → support@targointernet.com ne fusionne plus des clients distincts), sinon fil isolé.
const ownEmail = isEmail && c.email && OWN_DOMAINS.test(c.email)
const baseKey = isEmail
? (c.threadId ? ('thr:' + c.threadId) : (c.email && !ownEmail ? ('email:' + c.email) : ('t:' + c.token)))
: (phoneKey || ('t:' + c.token))
const key = c.status === 'active' ? `${baseKey}:active` : `${baseKey}:${c.createdAt.slice(0, 10)}`
if (!discussions[key]) {
const day = c.status === 'active' ? c.lastActivity.slice(0, 10) : c.createdAt.slice(0, 10)
@ -609,6 +616,17 @@ function findConversationByEmail (email) {
return best
}
// Regroupement PAR FIL GMAIL (thread_id) — la VRAIE clé d'une discussion : le client, nos réponses et les copies CC à support@ partagent le même thread_id Gmail, peu importe l'expéditeur de chaque message. Évite aussi de fusionner des fils distincts qui partagent une adresse interne (support@targointernet.com).
function findConversationByThreadId (threadId) {
if (!threadId) return null
let best = null
for (const c of conversations.values()) {
if (c.status === 'expired') continue
if (c.threadId && String(c.threadId) === String(threadId)) { if (!best || String(c.lastActivity) > String(best.lastActivity)) best = c }
}
return best
}
// Trouve la conversation liée à un ticket donné — pour rattacher une réponse « [Ticket #N] » au bon fil, même hors fil Gmail.
function findConversationByTicketName (name) {
if (!name) return null
@ -634,7 +652,10 @@ async function ingestEmail ({ from, subject, body, text, html, message_id, threa
// Réponse à un ticket promu : « [Ticket #N] » dans le sujet → rattache au fil exact (robuste même hors fil Gmail).
const tref = (String(subject || '').match(TICKET_REF_RX) || [])[1]
// On ne REGROUPE jamais par une de NOS adresses (support@targo.ca, *@targointernet.com…) : sinon tout le sortant ré-ingéré (From: support@targo.ca) se collapse dans un seul fil → des conversations sans lien fusionnent + héritent d'un mauvais nom. Voir OWN_DOMAINS.
let conv = (tref && findConversationByTicketName(tref)) || (email && !OWN_DOMAINS.test(email) ? findConversationByEmail(email) : null)
let conv = (tref && findConversationByTicketName(tref))
|| (thread_id && findConversationByThreadId(thread_id)) // MÊME fil Gmail = MÊME discussion (client + nos réponses + copies CC à support@), peu importe l'expéditeur de chaque message
|| (email && !OWN_DOMAINS.test(email) ? findConversationByEmail(email) : null)
const isNewConv = !conv
if (!conv) {
// Identité d'affichage = qui a VRAIMENT envoyé (en-tête From) en priorité, puis une fiche client liée PAR COURRIEL (fiable), puis le courriel.
// On n'utilise PAS le nom DEVINÉ par l'IA depuis le CONTENU (t.customer_name sans fiche liée) comme identité → sinon un courriel « gilles@ » s'affiche « Sylvie Juteau ».
@ -660,27 +681,36 @@ async function ingestEmail ({ from, subject, body, text, html, message_id, threa
}
}
// Corps = message du fil (le sujet est affiché comme EN-TÊTE → on ne le recolle PAS dans le corps : évite la double en-tête).
const emailMsg = addMessage(conv, { from: 'customer', text: content, via: 'email', html: html || '' })
// Expéditeur d'un de NOS domaines (membre de l'équipe qui répond via Gmail, en copie CC à support@) → message AGENT, pas « customer » (sinon il s'affiche comme un message du client dans le fil).
const senderIsOwn = !!(email && OWN_DOMAINS.test(email))
const emailMsg = addMessage(conv, { from: senderIsOwn ? 'agent' : 'customer', text: content, via: 'email', html: html || '', agent: senderIsOwn ? email : '', fromName: fromDisplayName(from), fromEmail: email, gmail_id: gmail_id || '' })
if (conv.linkedTickets && conv.linkedTickets.length) logToLinkedTicket(conv, emailMsg, subject).catch(() => {}) // chaîne : alimente le ticket lié
// ── MASQUAGE = un choix, pas une règle aveugle ──
// Auto-masque le bruit machine (no-reply/infra) et l'interne, MAIS JAMAIS un vrai lead détecté par l'IA (vente/support/facturation/installation…).
// Les RÈGLES utilisateur priment (allow = ne jamais masquer ; mask = toujours masquer) → « on ne masque que ce qu'on choisit ».
const LEAD_TYPES = ['vente', 'support', 'facturation', 'installation', 'telephonie', 'television']
const isLead = !!(t && !t.noise && LEAD_TYPES.includes(t.type))
let masked = !!(t && t.noise) || guard === 'automated' || (guard === 'internal' && !isLead)
const rule = matchInboxRule(email, subject) // 'allow' | 'mask' | null — règle définie par l'agent (« Filtrer comme ceci »)
if (rule === 'allow') masked = false
else if (rule === 'mask') masked = true
conv.noise = masked
if (t) {
conv.triage = t
if (!conv.noise && guard !== 'ticket_reply') { // visible et pas une réponse à un ticket existant → routage/suggestion auto OK
if (!conv.queue) conv.queue = QUEUE_OF[t.type] || '' // route vers le LABEL (file) ; réorientable d'un clic
if (t.suggest_ticket && !(conv.linkedTickets && conv.linkedTickets.length)) {
conv.suggestedTicket = { title: t.suggested_title, category: t.category, priority: 'Medium', reason: t.reason, is_customer: t.is_customer, ts: new Date().toISOString() }
// ── MASQUAGE + ROUTAGE : décidés UNE SEULE FOIS, à la CRÉATION du fil ──
// Sur une RÉPONSE à un fil existant (même thread Gmail), on NE ré-évalue PAS : sinon une réponse interne de l'équipe (guard 'internal') pourrait MASQUER un fil client visible, ou re-router / écraser le triage du client.
if (isNewConv) {
// Auto-masque le bruit machine (no-reply/infra) et l'interne, MAIS JAMAIS un vrai lead détecté par l'IA (vente/support/facturation/installation…). Les RÈGLES utilisateur priment.
const LEAD_TYPES = ['vente', 'support', 'facturation', 'installation', 'telephonie', 'television']
const isLead = !!(t && !t.noise && LEAD_TYPES.includes(t.type))
let masked = !!(t && t.noise) || guard === 'automated' || (guard === 'internal' && !isLead)
const rule = matchInboxRule(email, subject) // 'allow' | 'mask' | null
if (rule === 'allow') masked = false
else if (rule === 'mask') masked = true
conv.noise = masked
if (t) {
conv.triage = t
if (!conv.noise && guard !== 'ticket_reply') { // visible et pas une réponse à un ticket existant → routage/suggestion auto OK
if (!conv.queue) conv.queue = QUEUE_OF[t.type] || '' // route vers le LABEL (file) ; réorientable d'un clic
if (t.suggest_ticket && !(conv.linkedTickets && conv.linkedTickets.length)) {
conv.suggestedTicket = { title: t.suggested_title, category: t.category, priority: 'Medium', reason: t.reason, is_customer: t.is_customer, ts: new Date().toISOString() }
}
if (t.type === 'vente' && !conv.pipeline) conv.pipeline = { stage: 'Nouveau', value: 0, updatedAt: new Date().toISOString() } // lead → entre dans le pipeline
}
if (t.type === 'vente' && !conv.pipeline) conv.pipeline = { stage: 'Nouveau', value: 0, updatedAt: new Date().toISOString() } // lead → entre dans le pipeline
}
} else {
// Fil existant : une règle utilisateur EXPLICITE peut tout de même ré-afficher/masquer ; sinon on ne touche pas à la visibilité ni au routage.
const rule = matchInboxRule(email, subject)
if (rule === 'allow') conv.noise = false
else if (rule === 'mask') conv.noise = true
}
try { require('./coupon-triage').onEmail({ from, subject, body: content, conv }) } catch (e) { log('coupon onEmail: ' + e.message) } // Levier 1 : alerte coupons (ne bloque pas l'ingest)
saveToDisk()
@ -1033,6 +1063,36 @@ async function handle (req, res, method, p, url) {
return json(res, 200, { from, to, board, unattributed })
}
// POST /conversations/backfill-senders — rétro-remplit msg.fromName/fromEmail des courriels
// ingérés AVANT la capture per-message de l'en-tête From, en relisant Gmail. En-process
// (mute l'état mémoire + saveToDisk → pas de course de fichier). Idempotent : ne touche
// que les messages courriel SANS fromName. Mappe par msg.gmail_id, sinon par ordre via _gmailIds.
if (p === '/conversations/backfill-senders' && method === 'POST') {
const gmail = require('./gmail')
const { extractEmail } = require('./inbox-triage')
let patched = 0, convsTouched = 0
for (const [, conv] of conversations) {
if (conv.channel !== 'email') continue
const gids = conv._gmailIds || []
const emailMsgs = (conv.messages || []).filter(m => m.via === 'email' || m.html)
let touched = false
for (let i = 0; i < emailMsgs.length; i++) {
const m = emailMsgs[i]
if (m.fromName) continue
const gid = m.gmail_id || gids[i]
if (!gid) continue
try {
const gm = await gmail.getMessage(gid)
const nm = gm && gm.from ? fromDisplayName(gm.from) : ''
if (nm) { m.fromName = nm; m.fromEmail = extractEmail(gm.from); m.gmail_id = gid; patched++; touched = true }
} catch (e) { /* message Gmail introuvable → ignorer */ }
}
if (touched) convsTouched++
}
saveToDisk()
return json(res, 200, { ok: true, patched, convsTouched })
}
// Cherche les fiches Customer par téléphone → l'OPS propose de lier / de choisir parmi plusieurs.
if (p === '/conversations/resolve' && method === 'GET') {
const emailQ = url.searchParams.get('email')

View File

@ -99,9 +99,20 @@ async function getAttachment (msgId, attId, mb) {
// ── Écriture : envoyer / répondre / supprimer ──
function encodeHeader (s) { return /[^\x00-\x7F]/.test(String(s)) ? '=?UTF-8?B?' + Buffer.from(String(s)).toString('base64') + '?=' : String(s) } // RFC 2047 pour sujets accentués
const htmlToText = (html) => String(html || '').replace(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<br\s*\/?>(?=)/gi, '\n').replace(/<\/(p|div|li|tr|h[1-6])>/gi, '\n').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim()
// Encode le NOM d'affichage d'une adresse « Nom <email> » en RFC 2047 (accents),
// en laissant <email> intact. Sans <>, renvoie tel quel (email nu).
function encodeAddress (addr) {
const m = String(addr || '').match(/^(.*?)\s*<([^>]+)>\s*$/)
if (!m) return String(addr || '')
const name = m[1].trim().replace(/^"(.*)"$/, '$1')
return (name ? encodeHeader(name) + ' ' : '') + '<' + m[2] + '>'
}
const htmlToText = (html) => String(html || '')
.replace(/<!--[\s\S]*?-->/g, ' ') // commentaires HTML, incl. conditionnels MSO (<o:PixelsPerInch>96</o:…>)
.replace(/<head[\s\S]*?<\/head>/gi, ' ') // <head> entier (style/meta/xml/title) — hors du texte lisible
.replace(/<style[\s\S]*?<\/style>/gi, ' ').replace(/<br\s*\/?>(?=)/gi, '\n').replace(/<\/(p|div|li|tr|h[1-6])>/gi, '\n').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim()
function buildRfc822 ({ to, subject, body, html, inReplyTo, references, from }) {
const h = ['From: ' + (from || sendFrom()), 'To: ' + to, 'Subject: ' + encodeHeader(subject || ''), 'MIME-Version: 1.0']
const h = ['From: ' + encodeAddress(from || sendFrom()), 'To: ' + encodeAddress(to), 'Subject: ' + encodeHeader(subject || ''), 'MIME-Version: 1.0']
if (inReplyTo) h.push('In-Reply-To: ' + inReplyTo)
if (references || inReplyTo) h.push('References: ' + (references || inReplyTo))
if (html) { // multipart/alternative : repli texte + HTML (déliverabilité + clients sans HTML)
@ -143,7 +154,11 @@ const saveSeen = (s) => saveSeenSet(SEEN_FILE, s)
async function poll () {
// Exclut les courriels adressés à factures@ : ils sont traités par le pipeline FACTURE FOURNISSEUR (pas des conversations).
const invTo = (process.env.GMAIL_INVOICE_TO || 'factures@targointernet.com').trim()
const ids = await listMessages({ q: 'newer_than:3d' + (invTo ? ' -to:' + invTo : ''), max: 30 })
// `in:inbox` = on n'ingère QUE le courrier REÇU. Sans ça, la requête ramassait
// aussi le dossier Envoyés (ex. les 105 courriels d'une campagne partis depuis
// cette boîte) → fils parasites dans l'Inbox Ops. Les réponses des agents sont
// déjà ajoutées au fil à l'envoi, pas besoin de les ré-ingérer.
const ids = await listMessages({ q: 'in:inbox newer_than:3d' + (invTo ? ' -to:' + invTo : ''), max: 30 })
const seen = loadSeen(); let ingested = 0
const { ingestEmail } = require('./conversation')
for (const { id } of ids.slice().reverse()) {

View File

@ -261,6 +261,11 @@ async function handleModemRequest(req, res, path) {
if (!ip || !pass) {
return jsonResp(res, 400, { error: 'Missing required params: ip, pass' });
}
// Anti-SSRF : le bridge se connecte à cette IP → exiger une adresse de GESTION
// PRIVÉE (RFC1918 / CGNAT 100.64/10), jamais un hôte public/arbitraire/metadata.
if (!/^(?:10\.|172\.(?:1[6-9]|2\d|3[01])\.|192\.168\.|100\.(?:6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)\d{1,3}\.\d{1,3}$/.test(String(ip))) {
return jsonResp(res, 400, { error: 'IP de gestion privée requise (RFC1918/CGNAT)' });
}
// Ensure session exists
await ensureSession(ip, user, pass, loginPath);

View File

@ -34,6 +34,7 @@ const cfg = require('./config')
const { log, json, parseBody } = require('./helpers')
const erp = require('./erp')
const sse = require('./sse')
const { verifyJwt } = require('./magic-link')
// Stripe config from environment
const STRIPE_SECRET = cfg.STRIPE_SECRET_KEY
@ -50,7 +51,7 @@ else log(' No STRIPE_SECRET_KEY configured — payment endpoints will fail')
// ────────────────────────────────────────────
// Stripe API helper (raw HTTPS, no SDK needed)
// ────────────────────────────────────────────
function stripeRequest (method, path, params = {}) {
function stripeRequest (method, path, params = {}, idempotencyKey = null) {
return new Promise((resolve, reject) => {
const body = new URLSearchParams()
flattenParams(params, body, '')
@ -63,6 +64,7 @@ function stripeRequest (method, path, params = {}) {
headers: {
Authorization: 'Bearer ' + STRIPE_SECRET,
'Content-Type': 'application/x-www-form-urlencoded',
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}), // anti-double-charge : un retry du MÊME appel ne crée pas une 2e charge (fenêtre 24h Stripe)
...(bodyStr && method !== 'GET' ? { 'Content-Length': Buffer.byteLength(bodyStr) } : {}),
},
}
@ -103,7 +105,9 @@ function flattenParams (obj, params, prefix) {
// Webhook signature verification
// ────────────────────────────────────────────
function verifyWebhookSignature (rawBody, sigHeader) {
if (!STRIPE_WEBHOOK_SECRET) return true // Skip in dev if no secret configured
// Fail-closed : sans secret configuré on REFUSE le webhook (sinon un attaquant
// forge "paiement réussi"). Opt-in explicite réservé au dev local.
if (!STRIPE_WEBHOOK_SECRET) return process.env.ALLOW_UNSIGNED_WEBHOOKS === '1'
if (!sigHeader) return false
const parts = {}
@ -191,20 +195,32 @@ async function paymentEntryExists (referenceNo) {
// Stripe Customer management
// ────────────────────────────────────────────
async function ensureStripeCustomer (customerId) {
// Check if we already have a stripe_customer_id in Payment Method
const methods = await getPaymentMethods(customerId)
const stripeMethods = methods.filter(m => m.provider === 'Stripe' && m.stripe_customer_id)
if (stripeMethods.length) return stripeMethods[0].stripe_customer_id
// Look up customer info from ERPNext
const cust = await getCustomerDoc(customerId)
if (!cust) throw new Error('Customer not found: ' + customerId)
// ── ALIGNEMENT F↔OPS (CRITIQUE) ────────────────────────────────────────────
// Le client Stripe AUTORITAIRE est celui de F (gestionclient), synchronisé sur
// Customer.stripe_id (legacy-sync depuis account.stripe_id). On le RÉUTILISE
// toujours s'il existe — sinon OPS créerait un 2e client Stripe pour la même
// personne (cartes ET factures invisibles entre les deux systèmes → double
// facturation / idempotence inter-systèmes impossible). 599/600 clients Stripe
// sont ceux de F. cf reference_f_stripe_billing.
if (cust.stripe_id && /^cus_/.test(cust.stripe_id)) {
await saveStripeCustomerId(customerId, cust.stripe_id) // miroir dans Payment Method
return cust.stripe_id
}
// Pas de client F connu → mapping OPS existant, sinon recherche, sinon création.
const methods = await getPaymentMethods(customerId)
const existing = methods.find(m => m.provider === 'Stripe' && m.stripe_customer_id)
if (existing) return existing.stripe_customer_id
const email = cust.email_billing || cust.email_id || ''
const name = cust.customer_name || customerId
if (!email) throw new Error('Customer has no email — required for Stripe')
// Search Stripe by metadata
// Search Stripe by metadata (clé OPS)
const search = await stripeRequest('GET', '/customers/search', { query: `metadata['erp_id']:'${customerId}'` })
if (search.data?.length) {
const stripeId = search.data[0].id
@ -212,7 +228,7 @@ async function ensureStripeCustomer (customerId) {
return stripeId
}
// Create new Stripe customer
// Dernier recours : créer (client réellement nouveau, absent de F).
const customer = await stripeRequest('POST', '/customers', {
name,
email,
@ -239,6 +255,44 @@ async function saveStripeCustomerId (customerId, stripeId) {
}
}
// ────────────────────────────────────────────
// Per-customer authorization (dual-use routes)
// ────────────────────────────────────────────
// balance/methods/invoice/checkout/setup/portal/toggle-ppa are reachable by the
// customer portal WITHOUT the staff token (the hub gate lets them through as
// PUBLIC), so without this check anyone who knows a customer id could read PII
// (balance, saved cards, invoices) or abuse auto-debit. We authorize per-customer:
// - staff token (HUB_SERVICE_TOKEN) → full access (ops / impersonation)
// - customer magic-link JWT whose `sub` matches the target customer → self access
// PAYMENTS_AUTH=enforce blocks everything else (401). Until that flag is set
// (report/unset) we allow but log the would-be block, so stale portal sessions and
// the rare ERPNext-session path can be observed before the switch is flipped.
function authorizeCustomer (req, ownerCustomerId) {
const tok = (req.headers.authorization || '').replace(/^Bearer\s+/i, '')
if (process.env.HUB_SERVICE_TOKEN && tok === process.env.HUB_SERVICE_TOKEN) return { ok: true, via: 'staff' }
if (tok) {
const p = verifyJwt(tok)
if (p && p.scope === 'customer') {
if (p.sub && ownerCustomerId && String(p.sub) === String(ownerCustomerId)) return { ok: true, via: 'customer' }
return { ok: false, via: 'customer', reason: 'customer_mismatch' }
}
return { ok: false, via: 'anon', reason: 'invalid_token' }
}
return { ok: false, via: 'anon', reason: 'no_token' }
}
// Returns true if the caller should STOP (a 401 was sent). In report mode it never
// sends 401 — it only logs — so the route proceeds.
function denyIfUnauthorized (req, res, ownerCustomerId, route) {
const a = authorizeCustomer(req, ownerCustomerId)
if (a.ok) return false
const enforce = process.env.PAYMENTS_AUTH === 'enforce'
log(`[payments-auth] ${enforce ? 'BLOCK' : 'report'} route=${route} customer=${ownerCustomerId || '?'} via=${a.via} reason=${a.reason}`)
if (!enforce) return false
json(res, 401, { error: 'Unauthorized', detail: 'customer authentication required' })
return true
}
// ────────────────────────────────────────────
// Route handler
// ────────────────────────────────────────────
@ -247,7 +301,9 @@ async function handle (req, res, method, path, url) {
// GET /payments/balance/:customer
const balMatch = path.match(/^\/payments\/balance\/(.+)$/)
if (balMatch && method === 'GET') {
const result = await getCustomerBalance(decodeURIComponent(balMatch[1]))
const cid = decodeURIComponent(balMatch[1])
if (denyIfUnauthorized(req, res, cid, 'balance')) return
const result = await getCustomerBalance(cid)
return json(res, 200, result)
}
@ -255,6 +311,7 @@ async function handle (req, res, method, path, url) {
const methMatch = path.match(/^\/payments\/methods\/(.+)$/)
if (methMatch && method === 'GET') {
const customerId = decodeURIComponent(methMatch[1])
if (denyIfUnauthorized(req, res, customerId, 'methods')) return
const methods = await getPaymentMethods(customerId)
// Enrich Stripe methods with live card info from Stripe API
@ -293,6 +350,7 @@ async function handle (req, res, method, path, url) {
const invoiceName = decodeURIComponent(invMatch[1])
const inv = await getInvoiceDoc(invoiceName)
if (!inv) return json(res, 404, { error: 'Invoice not found' })
if (denyIfUnauthorized(req, res, inv.customer, 'invoice')) return
return json(res, 200, {
name: inv.name,
customer: inv.customer,
@ -312,6 +370,7 @@ async function handle (req, res, method, path, url) {
const body = await parseBody(req)
const { customer } = body
if (!customer) return json(res, 400, { error: 'customer required' })
if (denyIfUnauthorized(req, res, customer, 'checkout')) return
const { balance } = await getCustomerBalance(customer)
if (balance <= 0) return json(res, 400, { error: 'No outstanding balance', balance })
@ -351,6 +410,7 @@ async function handle (req, res, method, path, url) {
const body = await parseBody(req)
const { customer, invoice, save_card, payment_method } = body
if (!customer) return json(res, 400, { error: 'customer required' })
if (denyIfUnauthorized(req, res, customer, 'checkout-invoice')) return
if (!invoice) return json(res, 400, { error: 'invoice required' })
// Fetch invoice to get amount
@ -553,6 +613,7 @@ async function handle (req, res, method, path, url) {
const body = await parseBody(req)
const { customer } = body
if (!customer) return json(res, 400, { error: 'customer required' })
if (denyIfUnauthorized(req, res, customer, 'setup')) return
const stripeCustomerId = await ensureStripeCustomer(customer)
@ -575,6 +636,7 @@ async function handle (req, res, method, path, url) {
const body = await parseBody(req)
const { customer } = body
if (!customer) return json(res, 400, { error: 'customer required' })
if (denyIfUnauthorized(req, res, customer, 'portal')) return
const stripeCustomerId = await ensureStripeCustomer(customer)
@ -591,6 +653,7 @@ async function handle (req, res, method, path, url) {
const body = await parseBody(req)
const { customer, enabled } = body
if (!customer) return json(res, 400, { error: 'customer required' })
if (denyIfUnauthorized(req, res, customer, 'toggle-ppa')) return
const methods = await getPaymentMethods(customer)
const stripePm = methods.find(m => m.provider === 'Stripe')
@ -666,6 +729,10 @@ async function handle (req, res, method, path, url) {
const amountCents = Math.round(amount * 100)
// GARDE anti-double-charge : facture déjà chargée par OPS → on refuse (évite le double-clic / re-déclenchement).
if (invoiceName && await invoiceAlreadyChargedOps(invoiceName)) {
return json(res, 200, { ok: false, skipped: 'already_charged', invoice: invoiceName, message: 'Cette facture a déjà une charge OPS réussie.' })
}
// Create off-session PaymentIntent
const piMeta = { erp_customer: customer, type: 'ppa_charge' }
if (invoiceName) piMeta.erp_invoice = invoiceName
@ -678,7 +745,7 @@ async function handle (req, res, method, path, url) {
off_session: 'true',
confirm: 'true',
metadata: piMeta,
})
}, invoiceName ? 'ppa-' + invoiceName : 'ppachg-' + customer + '-' + amountCents) // clé d'idempotence (par facture, sinon par client+montant)
log(`PPA charge for ${customer}: ${pi.id} ($${amount}) — status: ${pi.status}`)
@ -1264,6 +1331,17 @@ async function updateCardInfo (customerId, stripeCustomerId, paymentMethod) {
// ────────────────────────────────────────────
// PPA Cron — Daily automated billing
// ────────────────────────────────────────────
// Garde anti-double-charge (cœur du correctif) : une facture déjà couverte par un PaymentIntent OPS RÉUSSI ne doit JAMAIS être rechargée — peu importe restart, jour, ou échec de recordPayment.
// FAIL-SAFE : en cas de doute (recherche Stripe en échec), on retourne TRUE (= NE PAS charger). C'est l'inverse du bug d'origine où l'échec menait à recharger.
async function invoiceAlreadyChargedOps (invoice) {
if (!invoice) return false
try {
const q = encodeURIComponent(`metadata['erp_invoice']:'${invoice}' AND metadata['type']:'ppa_charge' AND status:'succeeded'`)
const r = await stripeRequest('GET', `/payment_intents/search?query=${q}&limit=1`)
return !!(r && Array.isArray(r.data) && r.data.length)
} catch (e) { log(`invoiceAlreadyChargedOps(${invoice}) échec recherche → prudence: NE PAS charger (${e.message})`); return true }
}
async function runPPACron () {
log('PPA Cron: starting run...')
const results = { charged: [], failed: [], skipped: [], errors: [] }
@ -1302,6 +1380,8 @@ async function runPPACron () {
// Charge the oldest unpaid invoice (one at a time for cleaner reconciliation)
const oldestInv = invoices[0]
// GARDE anti-double-charge : si cette facture a déjà une charge OPS réussie, on saute (évite le re-débit quotidien qui a causé l'incident).
if (await invoiceAlreadyChargedOps(oldestInv.name)) { results.skipped.push({ customer, invoice: oldestInv.name, reason: 'already_charged_ops' }); continue }
const amount = oldestInv.grand_total || oldestInv.outstanding_amount // grand_total includes taxes
const amountCents = Math.round(amount * 100)
@ -1317,7 +1397,7 @@ async function runPPACron () {
erp_invoice: oldestInv.name,
type: 'ppa_charge',
},
})
}, 'ppa-' + oldestInv.name) // clé d'idempotence Stripe par facture
if (pi.status === 'succeeded') {
await recordPayment(customer, amount, pi.id, 'Stripe', oldestInv.name)
@ -1351,10 +1431,10 @@ async function runPPACron () {
}
// Start the PPA cron scheduler (called from server.js)
let _ppaCronStarted = false
function startPPACron () {
// Run daily at 06:00 EST (11:00 UTC)
const INTERVAL_MS = 24 * 60 * 60 * 1000 // 24 hours
if (_ppaCronStarted) { log('PPA Cron : déjà démarré dans ce process — 2e appel ignoré (anti-double-planification → évite N timers qui chargent N fois)'); return }
_ppaCronStarted = true
function scheduleNext () {
const now = new Date()
const target = new Date(now)

View File

@ -68,6 +68,7 @@ async function handle (req, res, method, path, url) {
if (!body.created_at) body.created_at = new Date()
if (!body.updated_at) body.updated_at = new Date()
const keys = Object.keys(body), vals = Object.values(body)
if (keys.some(k => !/^[a-z_][a-z0-9_]*$/i.test(k))) return json(res, 400, { error: 'Invalid field name' }) // anti-SQLi : clés=colonnes, on rejette tout identifiant non standard
const result = await pool.query(`INSERT INTO ${tableName} (${keys.join(', ')}) VALUES (${keys.map((_, i) => `$${i + 1}`).join(', ')}) RETURNING *`, vals)
log(`Telephony: created ${resource}/${result.rows[0].ref}`)
return json(res, 201, result.rows[0])
@ -77,6 +78,7 @@ async function handle (req, res, method, path, url) {
const body = await parseBody(req)
body.updated_at = new Date(); delete body.ref; delete body.created_at
const keys = Object.keys(body), vals = Object.values(body)
if (keys.some(k => !/^[a-z_][a-z0-9_]*$/i.test(k))) return json(res, 400, { error: 'Invalid field name' }) // anti-SQLi : clés=colonnes
vals.push(ref)
const result = await pool.query(`UPDATE ${tableName} SET ${keys.map((k, i) => `${k} = $${i + 1}`).join(', ')} WHERE ref = $${vals.length} RETURNING *`, vals)
if (!result.rows.length) return json(res, 404, { error: 'Not found' })

View File

@ -61,14 +61,36 @@ const server = http.createServer(async (req, res) => {
'/voice/inbound', '/voice/gather', '/voice/connect-agent', '/voice/twiml', '/voice/status',
'/api/checkout', '/api/catalog', '/api/order', '/api/address', '/api/otp',
'/api/referral', '/api/accept-for-client',
'/payments', // customer self-service (own auth) + ops (proxied)
// /payments : routes CLIENT/portail publiques (le portail apps/client appelle
// msg.gigafibre.ca en direct, sans token). Les routes AGENT à risque
// (charge/refund/ppa-run/send-link = mouvement d'argent / envoi) sont
// VOLONTAIREMENT absentes → elles tombent dans le gate et exigent le
// HUB_SERVICE_TOKEN (Ops passe par le proxy /ops/hub qui l'injecte).
// Phase 2 FAIT : balance/methods/invoice/checkout/setup/portal/toggle-ppa sont
// dual-usage → autorisées PAR CLIENT dans lib/payments.js (authorizeCustomer :
// staff token OU JWT magic-link dont `sub` == customer ciblé), bloquant 401 si
// PAYMENTS_AUTH=enforce. Le portail envoie son JWT (sessionStorage targo_portal_jwt).
// Toute NOUVELLE route /payments exposant des données client doit appeler denyIfUnauthorized.
'/payments/pay/', '/payments/return', '/payments/balance/', '/payments/methods/',
'/payments/invoice/', '/payments/checkout', '/payments/setup', '/payments/portal',
'/payments/toggle-ppa',
'/webhook/', '/campaigns/webhook',
'/dispatch/calendar/', // token-authed .ics feed
'/giftbit-demo', // démo sandbox testbed (validation Giftbit) — PUBLIC ; l'API /giftbit/* reste gatée
'/conversations/email-ingest', // ingestion courriel n8n — PUBLIC mais protégé par X-Mail-Token dans le handler
'/conversations/channel-ingest', // ingestion canal (web chat / 3CX / Facebook) — PUBLIC mais protégé par X-Mail-Token
]
const ALWAYS_ENFORCE = ['/devices', '/email-queue', '/campaigns', '/giftbit']
// Toujours exiger le token (indépendant de HUB_GATE) pour toute route qui
// bouge de l'argent, contrôle réseau/modems, envoie, ou expose PII/RBAC.
// Les carve-outs PUBLIC (convChat, /dispatch/calendar/, routes client /payments)
// gagnent quand même car `isPublic` court-circuite ce bloc en amont.
const ALWAYS_ENFORCE = [
'/devices', '/email-queue', '/campaigns', '/giftbit',
'/auth', '/gmail', '/modem', '/olt', '/traccar', '/admin', '/collab',
'/network', '/sync', '/legacy-payments', '/telephony', '/flow',
'/conversations', '/dispatch',
'/payments/charge', '/payments/refund', '/payments/ppa-run', '/payments/send-link',
]
// Web-chat client (lien magique /c/<token>) : le token EST le secret → routes CLIENT publiques.
// GET /conversations/<token>, GET .../sse, POST .../messages, POST .../push UNIQUEMENT.
// Exclut resolve/archive/discussion (réservées agent) ; le reste de /conversations reste gaté.
@ -289,9 +311,12 @@ server.listen(cfg.PORT, '0.0.0.0', () => {
catch (e) { log('outbox startup failed:', e.message) }
if (oktopusMqtt) oktopusMqtt.start()
else log('Oktopus MQTT monitor: skipped (disabled)')
// Start PPA (pre-authorized payment) cron scheduler
try { require('./lib/payments').startPPACron() }
catch (e) { log('PPA cron failed to start:', e.message) }
// Cron PPA (auto-paiement carte) — DÉSACTIVÉ par défaut. Politique : scheduler en pause jusqu'au cutover, F autoritaire pour la facturation.
// A causé des charges multiples (pas d'idempotence Stripe + le sync F→ERPNext remet le solde dû → recharge). Ne réactiver (PPA_CRON_ENABLED=on) qu'APRÈS le correctif d'idempotence.
if (String(process.env.PPA_CRON_ENABLED || '').toLowerCase() === 'on') {
try { require('./lib/payments').startPPACron() }
catch (e) { log('PPA cron failed to start:', e.message) }
} else { log('PPA cron DÉSACTIVÉ (PPA_CRON_ENABLED!=on) — auto-paiement carte coupé') }
// Pont legacy (osTicket) → Dispatch Job : tire les tickets « Tech Targo » à dispatcher
try { require('./lib/legacy-dispatch-sync').startSync() }
catch (e) { log('legacy-dispatch-sync failed to start:', e.message) }

View File

@ -0,0 +1,30 @@
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;background:#f4f6f8;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1e293b">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:40px 16px 28px">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;background:#ffffff;border-radius:10px;overflow:hidden;border:1px solid #e2e8f0">
<tr><td style="background:#ffffff;padding:24px 28px 6px;text-align:left"><img src="https://xqy3m.mjt.lu/img2/xqy3m/eed4d18c-8065-4c5f-b47c-58af63171cd0/content" alt="TARGO" width="140" style="width:140px;max-width:140px;height:auto;display:block;border:0"></td></tr>
<tr><td style="padding:28px">
<p style="margin:0 0 14px;font-size:15px">Hello {{firstname}},</p>
<p style="margin:0 0 14px;font-size:15px;line-height:1.6">
Due to an error in our new automatic payment system, some payments may have been charged more than once for a few customers, including you.
<strong>The issue has been resolved and the amounts overcharged have been refunded to your card.</strong>
</p>
<p style="margin:0 0 14px;font-size:15px;line-height:1.6">
We sincerely apologize for the inconvenience. Depending on your card issuer, it may take a few days for the refund to appear on your statement.
If you notice anything unusual or have any questions, you can write to us at
<a href="mailto:facturation@targo.ca" style="color:#2563eb;text-decoration:none">facturation@targo.ca</a>.
</p>
<p style="margin:20px 0 0;font-size:15px">
Thank you for your understanding,<br>
<strong>The Billing Team — TARGO</strong>
</p>
</td></tr>
<tr><td style="background:#f8fafc;padding:14px 28px;border-top:1px solid #eef2f7;font-size:12px;color:#94a3b8">&nbsp;</td></tr>
</table>
</td></tr></table>
</body>
</html>

View File

@ -0,0 +1,389 @@
{
"counters": {
"u_row": 3,
"u_column": 3,
"u_content_text": 5,
"u_content_image": 1,
"u_content_button": 0,
"u_content_divider": 0,
"u_content_html": 0
},
"body": {
"id": "u_body",
"rows": [
{
"id": "u_row_1",
"cells": [
1
],
"columns": [
{
"id": "u_column_1",
"contents": [
{
"id": "u_content_image_1",
"type": "image",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "28px 28px 10px",
"anchor": "",
"src": {
"url": "https://xqy3m.mjt.lu/img2/xqy3m/eed4d18c-8065-4c5f-b47c-58af63171cd0/content",
"width": 140,
"height": "auto",
"autoWidth": false,
"maxWidth": "140px"
},
"textAlign": "left",
"altText": "TARGO",
"action": {
"name": "web",
"values": {
"href": "",
"target": "_blank"
}
},
"_meta": {
"htmlID": "u_content_image_1",
"htmlClassNames": "u_content_image"
}
}
}
],
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"_meta": {
"htmlID": "u_column_1",
"htmlClassNames": "u_column"
},
"padding": "0px",
"border": {},
"borderRadius": "0px",
"backgroundColor": ""
}
}
],
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"columns": false,
"backgroundColor": "#ffffff",
"columnsBackgroundColor": "",
"backgroundImage": {
"url": "",
"fullWidth": true,
"repeat": "no-repeat",
"size": "custom",
"position": "center"
},
"padding": "0px",
"anchor": "",
"borderRadius": "",
"_meta": {
"htmlID": "u_row_1",
"htmlClassNames": "u_row"
}
}
},
{
"id": "u_row_2",
"cells": [
1
],
"columns": [
{
"id": "u_column_2",
"contents": [
{
"id": "u_content_text_1",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "20px 28px 0",
"anchor": "",
"fontWeight": 400,
"fontSize": "15px",
"color": "#1e293b",
"textAlign": "left",
"lineHeight": "160%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_1",
"htmlClassNames": "u_content_text"
},
"text": "Hello {{firstname}},"
}
},
{
"id": "u_content_text_2",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "14px 28px 0",
"anchor": "",
"fontWeight": 400,
"fontSize": "15px",
"color": "#1e293b",
"textAlign": "left",
"lineHeight": "160%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_2",
"htmlClassNames": "u_content_text"
},
"text": "Due to an error in our new automatic payment system, some payments may have been charged more than once for a few customers, including you. <strong>The issue has been resolved and the amounts overcharged have been refunded to your card.</strong>"
}
},
{
"id": "u_content_text_3",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "14px 28px 0",
"anchor": "",
"fontWeight": 400,
"fontSize": "15px",
"color": "#1e293b",
"textAlign": "left",
"lineHeight": "160%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_3",
"htmlClassNames": "u_content_text"
},
"text": "We sincerely apologize for the inconvenience. Depending on your card issuer, it may take a few days for the refund to appear on your statement. If you notice anything unusual or have any questions, you can write to us at <a href=\"mailto:facturation@targo.ca\">facturation@targo.ca</a>."
}
},
{
"id": "u_content_text_4",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "20px 28px 28px",
"anchor": "",
"fontWeight": 400,
"fontSize": "15px",
"color": "#1e293b",
"textAlign": "left",
"lineHeight": "160%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_4",
"htmlClassNames": "u_content_text"
},
"text": "Thank you for your understanding,<br><strong>The Billing Team — TARGO</strong>"
}
}
],
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"_meta": {
"htmlID": "u_column_2",
"htmlClassNames": "u_column"
},
"padding": "0px",
"border": {},
"borderRadius": "0px",
"backgroundColor": ""
}
}
],
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"columns": false,
"backgroundColor": "#ffffff",
"columnsBackgroundColor": "",
"backgroundImage": {
"url": "",
"fullWidth": true,
"repeat": "no-repeat",
"size": "custom",
"position": "center"
},
"padding": "0px",
"anchor": "",
"borderRadius": "",
"_meta": {
"htmlID": "u_row_2",
"htmlClassNames": "u_row"
}
}
},
{
"id": "u_row_3",
"cells": [
1
],
"columns": [
{
"id": "u_column_3",
"contents": [
{
"id": "u_content_text_5",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "14px 28px",
"anchor": "",
"fontWeight": 400,
"fontSize": "12px",
"color": "#94a3b8",
"textAlign": "left",
"lineHeight": "160%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_5",
"htmlClassNames": "u_content_text"
},
"text": "&nbsp;"
}
}
],
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"_meta": {
"htmlID": "u_column_3",
"htmlClassNames": "u_column"
},
"padding": "0px",
"border": {},
"borderRadius": "0px",
"backgroundColor": ""
}
}
],
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"columns": false,
"backgroundColor": "#f8fafc",
"columnsBackgroundColor": "",
"backgroundImage": {
"url": "",
"fullWidth": true,
"repeat": "no-repeat",
"size": "custom",
"position": "center"
},
"padding": "0px",
"anchor": "",
"borderRadius": "",
"_meta": {
"htmlID": "u_row_3",
"htmlClassNames": "u_row"
}
}
}
],
"values": {
"popupPosition": "center",
"popupWidth": "600px",
"popupHeight": "auto",
"borderRadius": "10px",
"contentAlign": "center",
"contentVerticalAlign": "center",
"contentWidth": "600px",
"fontFamily": {
"label": "Plus Jakarta Sans",
"value": "'Plus Jakarta Sans', sans-serif",
"url": "https://fonts.googleapis.com/css?family=Plus+Jakarta+Sans:400,500,600,700"
},
"textColor": "#1e293b",
"popupBackgroundColor": "#ffffff",
"backgroundColor": "#f4f6f8",
"preheaderText": "A refund has been applied to your account — our apologies.",
"linkStyle": {
"body": true,
"linkColor": "#2563eb",
"linkHoverColor": "#1d4ed8",
"linkUnderline": false,
"linkHoverUnderline": true
},
"_meta": {
"htmlID": "u_body",
"htmlClassNames": "u_body"
}
}
},
"schemaVersion": 16
}

View File

@ -0,0 +1,30 @@
<!doctype html>
<html lang="fr">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;background:#f4f6f8;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1e293b">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:40px 16px 28px">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;background:#ffffff;border-radius:10px;overflow:hidden;border:1px solid #e2e8f0">
<tr><td style="background:#ffffff;padding:24px 28px 6px;text-align:left"><img src="https://xqy3m.mjt.lu/img2/xqy3m/eed4d18c-8065-4c5f-b47c-58af63171cd0/content" alt="TARGO" width="140" style="width:140px;max-width:140px;height:auto;display:block;border:0"></td></tr>
<tr><td style="padding:28px">
<p style="margin:0 0 14px;font-size:15px">Bonjour {{firstname}},</p>
<p style="margin:0 0 14px;font-size:15px;line-height:1.6">
À la suite dune erreur dans notre nouveau système de paiement automatique, certains paiements peuvent avoir été prélevés plus dune fois pour quelques clients, dont vous.
<strong>La situation a été corrigée et les montants prélevés en trop ont été remboursés sur votre carte.</strong>
</p>
<p style="margin:0 0 14px;font-size:15px;line-height:1.6">
Nous nous excusons sincèrement pour ce désagrément. Selon lémetteur de votre carte, un délai de quelques jours peut être nécessaire avant que le remboursement apparaisse sur votre relevé.
Si vous remarquez quoi que ce soit dinhabituel ou si vous avez une question, vous pouvez nous écrire à
<a href="mailto:facturation@targo.ca" style="color:#2563eb;text-decoration:none">facturation@targo.ca</a>.
</p>
<p style="margin:20px 0 0;font-size:15px">
Merci de votre compréhension,<br>
<strong>Léquipe Facturation — TARGO</strong>
</p>
</td></tr>
<tr><td style="background:#f8fafc;padding:14px 28px;border-top:1px solid #eef2f7;font-size:12px;color:#94a3b8">&nbsp;</td></tr>
</table>
</td></tr></table>
</body>
</html>

View File

@ -0,0 +1,389 @@
{
"counters": {
"u_row": 3,
"u_column": 3,
"u_content_text": 5,
"u_content_image": 1,
"u_content_button": 0,
"u_content_divider": 0,
"u_content_html": 0
},
"body": {
"id": "u_body",
"rows": [
{
"id": "u_row_1",
"cells": [
1
],
"columns": [
{
"id": "u_column_1",
"contents": [
{
"id": "u_content_image_1",
"type": "image",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "28px 28px 10px",
"anchor": "",
"src": {
"url": "https://xqy3m.mjt.lu/img2/xqy3m/eed4d18c-8065-4c5f-b47c-58af63171cd0/content",
"width": 140,
"height": "auto",
"autoWidth": false,
"maxWidth": "140px"
},
"textAlign": "left",
"altText": "TARGO",
"action": {
"name": "web",
"values": {
"href": "",
"target": "_blank"
}
},
"_meta": {
"htmlID": "u_content_image_1",
"htmlClassNames": "u_content_image"
}
}
}
],
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"_meta": {
"htmlID": "u_column_1",
"htmlClassNames": "u_column"
},
"padding": "0px",
"border": {},
"borderRadius": "0px",
"backgroundColor": ""
}
}
],
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"columns": false,
"backgroundColor": "#ffffff",
"columnsBackgroundColor": "",
"backgroundImage": {
"url": "",
"fullWidth": true,
"repeat": "no-repeat",
"size": "custom",
"position": "center"
},
"padding": "0px",
"anchor": "",
"borderRadius": "",
"_meta": {
"htmlID": "u_row_1",
"htmlClassNames": "u_row"
}
}
},
{
"id": "u_row_2",
"cells": [
1
],
"columns": [
{
"id": "u_column_2",
"contents": [
{
"id": "u_content_text_1",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "20px 28px 0",
"anchor": "",
"fontWeight": 400,
"fontSize": "15px",
"color": "#1e293b",
"textAlign": "left",
"lineHeight": "160%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_1",
"htmlClassNames": "u_content_text"
},
"text": "Bonjour {{firstname}},"
}
},
{
"id": "u_content_text_2",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "14px 28px 0",
"anchor": "",
"fontWeight": 400,
"fontSize": "15px",
"color": "#1e293b",
"textAlign": "left",
"lineHeight": "160%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_2",
"htmlClassNames": "u_content_text"
},
"text": "À la suite dune erreur dans notre nouveau système de paiement automatique, certains paiements peuvent avoir été prélevés plus dune fois pour quelques clients, dont vous. <strong>La situation a été corrigée et les montants prélevés en trop ont été remboursés sur votre carte.</strong>"
}
},
{
"id": "u_content_text_3",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "14px 28px 0",
"anchor": "",
"fontWeight": 400,
"fontSize": "15px",
"color": "#1e293b",
"textAlign": "left",
"lineHeight": "160%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_3",
"htmlClassNames": "u_content_text"
},
"text": "Nous nous excusons sincèrement pour ce désagrément. Selon lémetteur de votre carte, un délai de quelques jours peut être nécessaire avant que le remboursement apparaisse sur votre relevé. Si vous remarquez quoi que ce soit dinhabituel ou si vous avez une question, vous pouvez nous écrire à <a href=\"mailto:facturation@targo.ca\">facturation@targo.ca</a>."
}
},
{
"id": "u_content_text_4",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "20px 28px 28px",
"anchor": "",
"fontWeight": 400,
"fontSize": "15px",
"color": "#1e293b",
"textAlign": "left",
"lineHeight": "160%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_4",
"htmlClassNames": "u_content_text"
},
"text": "Merci de votre compréhension,<br><strong>Léquipe Facturation — TARGO</strong>"
}
}
],
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"_meta": {
"htmlID": "u_column_2",
"htmlClassNames": "u_column"
},
"padding": "0px",
"border": {},
"borderRadius": "0px",
"backgroundColor": ""
}
}
],
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"columns": false,
"backgroundColor": "#ffffff",
"columnsBackgroundColor": "",
"backgroundImage": {
"url": "",
"fullWidth": true,
"repeat": "no-repeat",
"size": "custom",
"position": "center"
},
"padding": "0px",
"anchor": "",
"borderRadius": "",
"_meta": {
"htmlID": "u_row_2",
"htmlClassNames": "u_row"
}
}
},
{
"id": "u_row_3",
"cells": [
1
],
"columns": [
{
"id": "u_column_3",
"contents": [
{
"id": "u_content_text_5",
"type": "text",
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"containerPadding": "14px 28px",
"anchor": "",
"fontWeight": 400,
"fontSize": "12px",
"color": "#94a3b8",
"textAlign": "left",
"lineHeight": "160%",
"linkStyle": {
"inherit": true
},
"_meta": {
"htmlID": "u_content_text_5",
"htmlClassNames": "u_content_text"
},
"text": "&nbsp;"
}
}
],
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"_meta": {
"htmlID": "u_column_3",
"htmlClassNames": "u_column"
},
"padding": "0px",
"border": {},
"borderRadius": "0px",
"backgroundColor": ""
}
}
],
"values": {
"selectable": true,
"draggable": true,
"duplicatable": true,
"deletable": true,
"hideable": true,
"hideDesktop": false,
"displayCondition": null,
"columns": false,
"backgroundColor": "#f8fafc",
"columnsBackgroundColor": "",
"backgroundImage": {
"url": "",
"fullWidth": true,
"repeat": "no-repeat",
"size": "custom",
"position": "center"
},
"padding": "0px",
"anchor": "",
"borderRadius": "",
"_meta": {
"htmlID": "u_row_3",
"htmlClassNames": "u_row"
}
}
}
],
"values": {
"popupPosition": "center",
"popupWidth": "600px",
"popupHeight": "auto",
"borderRadius": "10px",
"contentAlign": "center",
"contentVerticalAlign": "center",
"contentWidth": "600px",
"fontFamily": {
"label": "Plus Jakarta Sans",
"value": "'Plus Jakarta Sans', sans-serif",
"url": "https://fonts.googleapis.com/css?family=Plus+Jakarta+Sans:400,500,600,700"
},
"textColor": "#1e293b",
"popupBackgroundColor": "#ffffff",
"backgroundColor": "#f4f6f8",
"preheaderText": "Remboursement appliqué à votre compte — nos excuses.",
"linkStyle": {
"body": true,
"linkColor": "#2563eb",
"linkHoverColor": "#1d4ed8",
"linkUnderline": false,
"linkHoverUnderline": true
},
"_meta": {
"htmlID": "u_body",
"htmlClassNames": "u_body"
}
}
},
"schemaVersion": 16
}