diff --git a/scripts/campaigns/README.md b/scripts/campaigns/README.md new file mode 100644 index 0000000..c44f8c9 --- /dev/null +++ b/scripts/campaigns/README.md @@ -0,0 +1,142 @@ +# Gift Campaign β€” Personalized French email sender + +One-shot tool to send Giftbit gift cards to a list of contacts with a branded +French email, bypassing Giftbit's English-only built-in delivery. + +## How it works + +1. You generate the gifts in Giftbit (UI or API) and export/receive a CSV + containing one `gift_url` per recipient (one of the standard Giftbit + column names: `gift_url`, `gift_link`, `url`, `link`, `redemption_url`). +2. You produce a CSV of contacts with columns + `firstname, lastname, email, description`. The repo has a Python helper + for this β€” see how `giftbit-contacts-A-first-email.csv` was generated. +3. This script matches the two CSVs (by row order, the default) and sends + one personalized French email per recipient via Mailjet SMTP. +4. A `results-.csv` is written next to the script with per-row + `status` (`sent` / `failed` / `dry-run`), error message, and timestamp. + +The Giftbit redemption landing page (where the recipient picks a brand) is +controlled by Giftbit β€” set the campaign language to `fr-CA` in their UI +or via their API so the page itself is French. + +## Setup + +```bash +cd scripts/campaigns +npm init -y # one-time, creates package.json +npm install nodemailer # the only dependency +``` + +## Dry run (no emails sent, HTML written for preview) + +```bash +node send_gift_campaign.js \ + --gifts /path/to/giftbit-gifts.csv \ + --contacts /path/to/giftbit-contacts-A-first-email.csv \ + --template ./templates/gift-email-fr.html \ + --subject "🎁 Un cadeau pour vous, de la part de Gigafibre" \ + --amount "50 $" \ + --expiry "31 dΓ©cembre 2026" \ + --from "Gigafibre " \ + --dry-run +``` + +A `preview-YYYY-MM-DD-HH-MM/` directory will be created with one HTML +file per recipient (numbered + email-suffixed). Open a few in a browser +to validate the rendering on real data, then drop the `--dry-run` flag +to actually send. + +## Live send + +```bash +# Pull SMTP creds from the hub env (same Mailjet account as ERPNext) +source <(ssh root@96.125.196.67 'grep -E "^SMTP_" /opt/targo-hub/.env' | sed 's/^/export /') + +node send_gift_campaign.js \ + --gifts /path/to/giftbit-gifts.csv \ + --contacts /path/to/giftbit-contacts-A-first-email.csv \ + --template ./templates/gift-email-fr.html \ + --subject "🎁 Un cadeau pour vous, de la part de Gigafibre" \ + --amount "50 $" \ + --expiry "31 dΓ©cembre 2026" \ + --from "Gigafibre " \ + --smtp-host in-v3.mailjet.com --smtp-port 587 \ + --smtp-user "$SMTP_USER" --smtp-pass "$SMTP_PASS" \ + --throttle-ms 600 +``` + +`--throttle-ms 600` = roughly 100 emails/minute, safely below the Mailjet +free-plan ceiling of ~120/min. Adjust upward to 250 ms if you're on a +paid Mailjet plan. + +## Matching strategies + +| `--match-by` | Behaviour | +| --- | --- | +| `row` (default) | Line N of the gifts CSV pairs with line N of the contacts CSV. Use when Giftbit issued the gifts in the same order as your contacts. | +| `email` | Join by `email` column present in both CSVs. Use when Giftbit included emails in their export (more robust to ordering mistakes). | + +## Template variables + +The HTML template at `templates/gift-email-fr.html` uses `{{var}}` syntax. +Variables resolved at send time: + +| Variable | Source | +| --- | --- | +| `{{firstname}}` | contacts CSV `firstname` column (falls back to "cher client") | +| `{{lastname}}` | contacts CSV `lastname` | +| `{{email}}` | contacts CSV `email` | +| `{{description}}` | contacts CSV `description` (we put the service address there) | +| `{{gift_url}}` | matched from the gifts CSV | +| `{{amount}}` | `--amount` CLI flag (e.g. `"50 $"`) | +| `{{expiry}}` | `--expiry` CLI flag (e.g. `"31 dΓ©cembre 2026"`) | + +The template uses a vintage `{{#expiry}} ... {{/expiry}}` block for the +optional expiry line β€” currently rendered as plain text (the script's +simple `{{var}}` renderer doesn't strip the tags). If you don't want the +expiry sentence, edit the template directly to remove that block. + +## Source data β€” the two CSVs + +### Contacts (what we send to) + +Generated from a service-address selection by `scripts/campaigns/contacts_from_legacy.py` +(or by hand). One row per recipient: + +```csv +firstname,lastname,email,description +Marc-AndrΓ©,Boileau,boileau.marcandre@gmail.com,15 Rue des Hirondelles +Maryse,Roy,roy.maryse@hotmail.com,32 Rue des Hirondelles +``` + +### Gifts (output from Giftbit) + +Whatever shape Giftbit gives you. The script auto-detects the URL column +from the common naming conventions. Typically: + +```csv +gift_id,gift_url,amount +gb_abc123,https://app.giftbit.com/g/x7K2N9...,5000 +gb_def456,https://app.giftbit.com/g/p2H8M4...,5000 +``` + +## After sending + +Check `results-.csv`: + +- `status=sent` rows landed in Mailjet's outbound queue (delivery to the + recipient's mailbox is not guaranteed β€” see Mailjet console for bounces). +- `status=failed` rows have the SMTP error in the `error` column. Common + causes: malformed email address, hard bounce from a stale legacy email. +- Re-run only the failed rows by filtering the results CSV and feeding it + back through the script. + +## What's NOT in this script (intentional MVP scope) + +- No persistence to ERPNext doctype (no `Gift Campaign` records created) +- No click tracking β€” the `gift_url` is included verbatim. Giftbit gives + you redemption status via their own API/dashboard. +- No ops UI β€” pure CLI. If we end up running gift campaigns regularly, + wrap this in a `services/targo-hub/lib/gift-campaign.js` endpoint and + add a page in ops. For now, one-shot CLI is sufficient. diff --git a/scripts/campaigns/contacts_from_legacy.py b/scripts/campaigns/contacts_from_legacy.py new file mode 100644 index 0000000..10cc13f --- /dev/null +++ b/scripts/campaigns/contacts_from_legacy.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +""" +contacts_from_legacy.py β€” Build a Giftbit-compatible contacts CSV from +the legacy "adresses postales" export (selectionAdressesMap*.csv). + +The legacy export is pipe-delimited with a 1-line title preamble. It +contains messy fields (multi-email cells, mixed-case addresses, etc.). +This script extracts the columns Giftbit needs, normalizes them, and +writes a clean comma-separated CSV ready for either: + - Upload to Giftbit's UI for English delivery, or + - Pairing with this folder's `send_gift_campaign.js` for French delivery. + +Multi-email handling (when a cell contains "a@x.com;b@y.com" β€” typically +a couple living at the same address): + --multi=first (default) β€” keep the first email, 1 gift per household + --multi=split β€” split into multiple rows, 1 gift per person + (note: both rows share the same name) + --multi=skip β€” drop those rows for manual review + +Usage: + python3 contacts_from_legacy.py \\ + --src ~/Downloads/selectionAdressesMap9-12.csv \\ + --out ~/Downloads/giftbit-contacts.csv \\ + --multi first +""" +import argparse +import csv +import re +import sys +from pathlib import Path + +LOWER_WORDS = {"de", "du", "des", "la", "le", "les", "au", "aux", "Γ ", + "et", "sur", "en"} +EMAIL_SPLIT = re.compile(r"\s*[;,]\s*") + + +def split_name(full): + """First whitespace-delimited token = firstname, rest = lastname.""" + full = (full or "").strip() + if not full: + return "", "" + parts = full.split(None, 1) + return (parts[0], parts[1].strip()) if len(parts) > 1 else (parts[0], "") + + +def title_address(addr): + """Title-case an address with French article rules. + + '25 Rue Des Hirondelles ' -> '25 Rue des Hirondelles' + 'chemin de la 1Re-Concession' -> 'Chemin de la 1re-Concession' + """ + if not addr: + return "" + out = [] + for i, word in enumerate(addr.split()): + lw = word.lower() + if i > 0 and lw in LOWER_WORDS: + out.append(lw) + elif "-" in word: + chunks = [] + for c in word.split("-"): + cl = c.lower() + if cl in LOWER_WORDS: + chunks.append(cl) + else: + chunks.append(c[:1].upper() + c[1:].lower()) + out.append("-".join(chunks)) + else: + out.append(word[:1].upper() + word[1:].lower()) + return " ".join(out) + + +def is_email(s): + return bool(s and "@" in s and "." in s.split("@")[-1]) + + +def pick_emails(row): + """Pull clean email list from the first non-empty source field.""" + for k in ("email au compte", "email Γ  l'adresse"): + raw = (row.get(k) or "").strip() + if not raw: + continue + emails = [e.strip().lower() for e in EMAIL_SPLIT.split(raw) if is_email(e.strip())] + if emails: + return emails + return [] + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--src", required=True, help="Source legacy CSV (pipe-delimited).") + p.add_argument("--out", required=True, help="Output Giftbit-compatible CSV.") + p.add_argument("--multi", choices=("first", "split", "skip"), + default="first", + help="How to handle cells with multiple emails.") + args = p.parse_args() + + src = Path(args.src).expanduser() + out = Path(args.out).expanduser() + + rows = [] + with open(src, encoding="utf-8") as f: + next(f) # skip "adresses postales" title line + reader = csv.DictReader(f, delimiter="|") + reader.fieldnames = [fn.strip() for fn in reader.fieldnames] + for r in reader: + rows.append({(k.strip() if k else k): (v.strip() if v else v) + for k, v in r.items()}) + + contacts, seen = [], set() + skipped_no_email = skipped_no_name = skipped_multi = 0 + + for r in rows: + emails = pick_emails(r) + if not emails: + skipped_no_email += 1 + continue + if len(emails) > 1: + if args.multi == "skip": + skipped_multi += 1 + continue + if args.multi == "first": + emails = emails[:1] + full = (r.get("nom au compte") or r.get("nom Γ  l'adresse") or "").strip() + if not full: + skipped_no_name += 1 + continue + firstname, lastname = split_name(full) + desc = title_address(r.get("adresse dans F") or "") + for em in emails: + if em in seen: + continue + seen.add(em) + contacts.append({ + "firstname": firstname, + "lastname": lastname, + "email": em, + "description": desc, + }) + + with open(out, "w", encoding="utf-8", newline="") as f: + w = csv.DictWriter(f, fieldnames=["firstname", "lastname", "email", "description"], + quoting=csv.QUOTE_MINIMAL) + w.writeheader() + for c in contacts: + w.writerow(c) + + print(f" source rows: {len(rows)}") + print(f" contacts written: {len(contacts)}") + print(f" skipped (no email): {skipped_no_email}") + print(f" skipped (multi-email, --skip): {skipped_multi}") + print(f" skipped (no name): {skipped_no_name}") + print(f" β†’ {out}") + + +if __name__ == "__main__": + sys.exit(main() or 0) diff --git a/scripts/campaigns/send_gift_campaign.js b/scripts/campaigns/send_gift_campaign.js new file mode 100644 index 0000000..8b00480 --- /dev/null +++ b/scripts/campaigns/send_gift_campaign.js @@ -0,0 +1,300 @@ +#!/usr/bin/env node +'use strict' +/** + * send_gift_campaign.js β€” Personalized French email sender for Giftbit campaigns. + * + * The Giftbit UI/API generates one gift_url per recipient. Their built-in + * delivery emails are English-only and lack tracking. This script bridges + * the gap: take a CSV of contacts (firstname, lastname, email, description) + * and a CSV of gifts from Giftbit (one column with the gift URL), match + * them row-by-row, and send a branded French email via Mailjet (the SMTP + * already configured on the hub). + * + * Usage: + * node send_gift_campaign.js \ + * --gifts ./giftbit-gifts.csv \ + * --contacts ./giftbit-contacts-A-first-email.csv \ + * --template ./templates/gift-email-fr.html \ + * --subject "Votre cadeau Gigafibre" \ + * --amount "50 $" \ + * --expiry "31 dΓ©cembre 2026" \ + * --from "Gigafibre " \ + * --smtp-host in-v3.mailjet.com --smtp-port 587 \ + * --smtp-user $SMTP_USER --smtp-pass $SMTP_PASS \ + * --throttle-ms 600 \ + * --dry-run + * + * Remove --dry-run to actually send. With --dry-run the script writes + * the rendered HTML for each recipient to ./preview/ for visual review. + * + * Output: ./results-YYYYMMDD-HHMM.csv with columns + * firstname,lastname,email,gift_url,status,error,timestamp + * + * Match strategy is row-order by default (line N of gifts pairs with line + * N of contacts). Override with --match-by email if your gifts CSV has + * an `email` column to use as the join key (more robust against ordering + * mistakes, but requires Giftbit to include emails in their export). + */ + +const fs = require('fs') +const path = require('path') +const readline = require('readline') + +// Light deps β€” kept zero so this script runs anywhere with just node. +// (nodemailer is the only required external; install at use time.) + +function parseArgs (argv) { + const out = {} + for (let i = 2; i < argv.length; i++) { + const a = argv[i] + if (a.startsWith('--')) { + const k = a.slice(2) + const next = argv[i + 1] + if (!next || next.startsWith('--')) { out[k] = true } // boolean flag + else { out[k] = next; i++ } + } + } + return out +} + +const args = parseArgs(process.argv) + +const REQUIRED = ['gifts', 'contacts', 'template', 'subject', 'from'] +for (const k of REQUIRED) { + if (!args[k]) { + console.error(`Missing required --${k}`) + console.error('See header comment for usage.') + process.exit(1) + } +} + +const DRY_RUN = !!args['dry-run'] +const MATCH_BY = args['match-by'] || 'row' // 'row' | 'email' +const THROTTLE_MS = parseInt(args['throttle-ms'] || '600', 10) +const AMOUNT = args.amount || '50 $' +const EXPIRY = args.expiry || '' +const SUBJECT = args.subject +const FROM = args.from + +// ── CSV parsing ──────────────────────────────────────────────────────────── +// Minimal RFC-4180-ish parser. Handles quoted fields with embedded commas +// and escaped double-quotes. Falls back to comma delimiter; auto-detect for +// tab/pipe if the header doesn't contain a comma. Good enough for the +// Giftbit + contacts CSVs we're feeding it. +function parseCsv (text) { + const sample = text.split(/\r?\n/, 1)[0] || '' + const delim = sample.includes(',') ? ',' : (sample.includes('\t') ? '\t' : '|') + const rows = [] + let row = [], field = '', inQuotes = false + for (let i = 0; i < text.length; i++) { + const c = text[i] + if (inQuotes) { + if (c === '"' && text[i + 1] === '"') { field += '"'; i++ } + else if (c === '"') inQuotes = false + else field += c + } else { + if (c === '"') inQuotes = true + else if (c === delim) { row.push(field); field = '' } + else if (c === '\n' || c === '\r') { + if (field !== '' || row.length) { row.push(field); rows.push(row); row = []; field = '' } + if (c === '\r' && text[i + 1] === '\n') i++ + } else field += c + } + } + if (field !== '' || row.length) { row.push(field); rows.push(row) } + if (!rows.length) return [] + const header = rows[0].map(h => h.trim()) + return rows.slice(1).filter(r => r.some(c => c !== '')).map(r => { + const obj = {} + header.forEach((h, i) => { obj[h] = (r[i] || '').trim() }) + return obj + }) +} + +function readCsv (filepath) { + const text = fs.readFileSync(filepath, 'utf8').replace(/^ο»Ώ/, '') + return parseCsv(text) +} + +// Find the "gift URL" column in the Giftbit export. Giftbit's column +// naming varies between exports; check the common spellings. +function findUrlColumn (row) { + const keys = Object.keys(row) + const candidates = ['gift_url', 'gift link', 'gift_link', 'url', 'link', + 'redemption_url', 'gift', 'giftbit_link', 'campaign_link'] + for (const c of candidates) { + const hit = keys.find(k => k.toLowerCase().replace(/\s+/g, '_') === c) + if (hit) return hit + } + // Fallback: first column that looks like a URL in the first row + for (const k of keys) { + if ((row[k] || '').match(/^https?:\/\//)) return k + } + return null +} + +// ── Matching ─────────────────────────────────────────────────────────────── +function matchRowOrder (gifts, contacts, urlCol) { + const matched = [] + const n = Math.min(gifts.length, contacts.length) + for (let i = 0; i < n; i++) { + matched.push({ contact: contacts[i], gift_url: gifts[i][urlCol] }) + } + return { + matched, + leftover_gifts: gifts.length - n, + leftover_contacts: contacts.length - n, + } +} + +function matchByEmail (gifts, contacts, urlCol) { + const emailCol = Object.keys(gifts[0] || {}).find(k => k.toLowerCase().includes('email')) + if (!emailCol) { + throw new Error('--match-by email requested but no email column found in gifts CSV') + } + const giftByEmail = new Map(gifts.map(g => [(g[emailCol] || '').trim().toLowerCase(), g])) + const matched = [] + let unmatched = 0 + for (const c of contacts) { + const g = giftByEmail.get((c.email || '').trim().toLowerCase()) + if (g) matched.push({ contact: c, gift_url: g[urlCol] }) + else { unmatched++ } + } + return { matched, leftover_gifts: gifts.length - matched.length, leftover_contacts: unmatched } +} + +// ── Template rendering ───────────────────────────────────────────────────── +function render (tpl, vars) { + return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, k) => { + const v = vars[k] + return v == null ? '' : String(v) + }) +} + +// ── Mailer (lazy require so dry-run works without nodemailer installed) ─── +function makeTransporter () { + let nodemailer + try { nodemailer = require('nodemailer') } catch (e) { + console.error('ERROR: nodemailer not installed. Run:') + console.error(' npm install nodemailer') + process.exit(1) + } + return nodemailer.createTransport({ + host: args['smtp-host'] || 'in-v3.mailjet.com', + port: parseInt(args['smtp-port'] || '587', 10), + secure: false, + auth: { + user: args['smtp-user'] || process.env.SMTP_USER, + pass: args['smtp-pass'] || process.env.SMTP_PASS, + }, + }) +} + +function sleep (ms) { return new Promise(r => setTimeout(r, ms)) } + +// ── Main flow ────────────────────────────────────────────────────────────── +async function main () { + console.log(`\n── Gift campaign send β€” ${DRY_RUN ? 'DRY RUN' : 'LIVE'} ──`) + console.log(` gifts: ${args.gifts}`) + console.log(` contacts: ${args.contacts}`) + console.log(` template: ${args.template}`) + console.log(` subject: "${SUBJECT}"`) + console.log(` amount: ${AMOUNT}`) + console.log(` match: ${MATCH_BY}`) + + const gifts = readCsv(args.gifts) + const contacts = readCsv(args.contacts) + const tpl = fs.readFileSync(args.template, 'utf8') + + console.log(`\n loaded ${gifts.length} gifts, ${contacts.length} contacts`) + + if (!gifts.length) { console.error('No gifts loaded.'); process.exit(1) } + if (!contacts.length) { console.error('No contacts loaded.'); process.exit(1) } + + const urlCol = findUrlColumn(gifts[0]) + if (!urlCol) { + console.error('Cannot detect gift URL column. Available columns:', Object.keys(gifts[0])) + process.exit(1) + } + console.log(` gift URL column: "${urlCol}"`) + + const { matched, leftover_gifts, leftover_contacts } = MATCH_BY === 'email' + ? matchByEmail(gifts, contacts, urlCol) + : matchRowOrder(gifts, contacts, urlCol) + + console.log(` matched: ${matched.length} (gifts left over: ${leftover_gifts}, contacts skipped: ${leftover_contacts})`) + if (!matched.length) { console.error('No matches.'); process.exit(1) } + + // Prepare output paths + const stamp = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 16) + const resultsPath = path.resolve(`results-${stamp}.csv`) + const previewDir = path.resolve(`preview-${stamp}`) + if (DRY_RUN) fs.mkdirSync(previewDir, { recursive: true }) + + const transporter = DRY_RUN ? null : makeTransporter() + if (transporter) { + await transporter.verify().catch(e => { + console.error('SMTP verify failed:', e.message); process.exit(1) + }) + console.log(' SMTP connection OK') + } + + // Open results CSV (write header first) + const out = fs.createWriteStream(resultsPath, { encoding: 'utf8' }) + out.write('firstname,lastname,email,gift_url,status,error,timestamp\n') + + let sent = 0, failed = 0 + for (let i = 0; i < matched.length; i++) { + const { contact, gift_url } = matched[i] + const vars = { + firstname: contact.firstname || 'cher client', + lastname: contact.lastname || '', + email: contact.email, + description: contact.description || '', + gift_url, + amount: AMOUNT, + expiry: EXPIRY, + } + const html = render(tpl, vars) + const ts = new Date().toISOString() + + try { + if (DRY_RUN) { + const safeEmail = contact.email.replace(/[^a-z0-9._@-]/gi, '_') + fs.writeFileSync(path.join(previewDir, `${String(i + 1).padStart(3, '0')}-${safeEmail}.html`), html) + out.write(`${csvCell(contact.firstname)},${csvCell(contact.lastname)},${csvCell(contact.email)},${csvCell(gift_url)},dry-run,,${ts}\n`) + sent++ + } else { + await transporter.sendMail({ + from: FROM, + to: `"${contact.firstname} ${contact.lastname}".trim() <${contact.email}>`.replace('""', '"'), + subject: SUBJECT, + html, + }) + out.write(`${csvCell(contact.firstname)},${csvCell(contact.lastname)},${csvCell(contact.email)},${csvCell(gift_url)},sent,,${ts}\n`) + sent++ + await sleep(THROTTLE_MS) + } + if ((i + 1) % 25 === 0) process.stdout.write(` ${i + 1}/${matched.length} done\n`) + } catch (e) { + out.write(`${csvCell(contact.firstname)},${csvCell(contact.lastname)},${csvCell(contact.email)},${csvCell(gift_url)},failed,${csvCell(e.message)},${ts}\n`) + failed++ + console.error(` βœ— ${contact.email}: ${e.message}`) + } + } + + out.end() + console.log(`\n βœ“ ${sent} ${DRY_RUN ? 'previewed' : 'sent'}, ${failed} failed`) + console.log(` β†’ results: ${resultsPath}`) + if (DRY_RUN) console.log(` β†’ previews: ${previewDir}/`) +} + +function csvCell (s) { + s = String(s == null ? '' : s) + if (s.includes(',') || s.includes('"') || s.includes('\n')) { + return '"' + s.replace(/"/g, '""') + '"' + } + return s +} + +main().catch(e => { console.error('Fatal:', e); process.exit(1) }) diff --git a/scripts/campaigns/templates/gift-email-fr.html b/scripts/campaigns/templates/gift-email-fr.html new file mode 100644 index 0000000..dd6d46e --- /dev/null +++ b/scripts/campaigns/templates/gift-email-fr.html @@ -0,0 +1,99 @@ + + + + + +Un cadeau de Gigafibre + + + + +
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+ Gigafibre · RΓ©compense +
+
+ 🎁 Un cadeau pour vous +
+
+

Bonjour {{firstname}},

+ +

+ Merci de faire partie de la famille Gigafibre. Pour vous remercier + de votre fidΓ©litΓ©, voici une carte-cadeau d'une valeur de + {{amount}}, utilisable sur les marchands de votre choix. +

+ + + + +

+ Le lien vous mène à une page sécurisée où vous pourrez choisir la + marque qui vous fait plaisir (Amazon, Tim Hortons, SAQ, App Store, + et plusieurs autres). +

+ {{#expiry}} +

+ ⏰ Le lien expire le {{expiry}}. +

+ {{/expiry}} +
+
+ Vous recevez ce cadeau parce que vous Γͺtes client(e) Gigafibre Γ  + l'adresse {{description}}. + Si vous avez la moindre question, Γ©crivez-nous Γ  + facturation@targointernet.com + ou appelez-nous au 514 242-1500. +
+
+
+ Gigafibre β€” Internet fibre optique au QuΓ©bec
+ www.gigafibre.ca +
+
+ +
+ + +
+ + +