feat(campaigns): MVP gift campaign sender (Node CLI + FR email template)
User context: needs to send Giftbit gift cards to 203 customers with a
branded French email instead of Giftbit's English-only default delivery.
Giftbit's own UI/API can issue the gifts but its email is English; this
MVP bridges the gap by taking the gift URLs back from Giftbit, pairing
them with our contact CSV, and sending personalized FR emails through
the Mailjet SMTP that's already wired up for ERPNext invoice mail.
Three files in scripts/campaigns/:
1. send_gift_campaign.js — Node CLI. Two CSV inputs (gifts + contacts),
matches by row order (default) or email key, renders the HTML
template with mustache-style {{firstname}} / {{gift_url}} / etc.,
sends via nodemailer with configurable SMTP + throttle.
--dry-run writes per-recipient previews to disk for visual review
before flipping to live mode. Results CSV with per-row status
(sent / failed / dry-run) + error message + timestamp is written
next to the script for follow-up on failures.
2. templates/gift-email-fr.html — branded French email. Table-based
layout (the only thing that renders consistently in Gmail / Outlook /
iOS Mail / Apple Mail / Bell Sympatico). Indigo gradient header,
centered CTA button, contextual {{description}} line citing the
service address, support contact in the footer, no inline images
(defers to text + colour blocks to dodge image-blocking).
3. contacts_from_legacy.py — replaces the ad-hoc /tmp Python I ran
earlier with a proper repo'd version. Same multi-email handling
options (first / split / skip) as I offered the user; defaults to
"first" = 1 gift per household, which is what they chose. Title-
cases the address with French article rules (de / du / la / aux
stay lowercase, 1re / 2e ordinals stay lowercase too).
4. README.md — end-to-end usage with the actual SMTP env vars from
/opt/targo-hub/.env and the matching strategy decision matrix.
Validated end-to-end with a 5-row dry run: matching works, accents
preserved (Amélie, Geneviève, Marc-André), {{firstname}} interpolates,
gift URLs land in the rendered button href, address shows in the
contextual footer line. Previews written to disk for visual QA.
NOT in this MVP (out of scope, can come next if we end up running
gift campaigns regularly):
- No persistence to ERPNext doctype (no Gift Campaign / Recipient
records — pure CLI, results CSV is the audit trail)
- No click-tracking redirect (the gift_url goes verbatim to the
recipient; Giftbit's own API/dashboard reports redemption status,
which is the more relevant signal than "clicked the link")
- No ops UI page (CLI is fine for one-shot; if this becomes regular
we wrap it in services/targo-hub/lib/gift-campaign.js + a Vue page)
This commit is contained in:
parent
a6974e2443
commit
37896421c3
142
scripts/campaigns/README.md
Normal file
142
scripts/campaigns/README.md
Normal file
|
|
@ -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-<timestamp>.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 <noreply@gigafibre.ca>" \
|
||||
--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 <noreply@gigafibre.ca>" \
|
||||
--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-<timestamp>.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.
|
||||
157
scripts/campaigns/contacts_from_legacy.py
Normal file
157
scripts/campaigns/contacts_from_legacy.py
Normal file
|
|
@ -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)
|
||||
300
scripts/campaigns/send_gift_campaign.js
Normal file
300
scripts/campaigns/send_gift_campaign.js
Normal file
|
|
@ -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 <noreply@gigafibre.ca>" \
|
||||
* --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) })
|
||||
99
scripts/campaigns/templates/gift-email-fr.html
Normal file
99
scripts/campaigns/templates/gift-email-fr.html
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Un cadeau de Gigafibre</title>
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background:#f5f6fa; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; color:#1f2937; line-height:1.5;">
|
||||
|
||||
<!-- Spacer above the card -->
|
||||
<div style="height:32px;"></div>
|
||||
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
|
||||
<tr>
|
||||
<td align="center">
|
||||
|
||||
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0"
|
||||
style="max-width:600px; background:#ffffff; border-radius:14px; overflow:hidden; box-shadow:0 6px 24px rgba(15,23,42,0.07);">
|
||||
|
||||
<!-- Header band -->
|
||||
<tr>
|
||||
<td style="background:linear-gradient(135deg,#4f46e5 0%, #7c3aed 100%); padding:36px 32px 28px; text-align:center; color:#ffffff;">
|
||||
<div style="font-size:0.78rem; font-weight:700; letter-spacing:0.12em; text-transform:uppercase; opacity:0.85;">
|
||||
Gigafibre · Récompense
|
||||
</div>
|
||||
<div style="font-size:2.2rem; line-height:1.1; margin-top:10px; font-weight:800;">
|
||||
🎁 Un cadeau pour vous
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Body -->
|
||||
<tr>
|
||||
<td style="padding:36px 36px 12px;">
|
||||
<p style="margin:0 0 16px; font-size:1.05rem;">Bonjour {{firstname}},</p>
|
||||
|
||||
<p style="margin:0 0 16px;">
|
||||
Merci de faire partie de la famille Gigafibre. Pour vous remercier
|
||||
de votre fidélité, voici une carte-cadeau d'une valeur de
|
||||
<strong>{{amount}}</strong>, utilisable sur les marchands de votre choix.
|
||||
</p>
|
||||
|
||||
<!-- CTA button -->
|
||||
<div style="text-align:center; margin:32px 0 28px;">
|
||||
<a href="{{gift_url}}"
|
||||
style="display:inline-block; padding:16px 36px; background:#4f46e5; color:#ffffff;
|
||||
text-decoration:none; font-weight:700; font-size:1.05rem;
|
||||
border-radius:10px; box-shadow:0 4px 12px rgba(79,70,229,0.35);">
|
||||
Récupérer mon cadeau →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p style="margin:0 0 6px; font-size:0.9rem; color:#6b7280;">
|
||||
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).
|
||||
</p>
|
||||
{{#expiry}}
|
||||
<p style="margin:6px 0 0; font-size:0.85rem; color:#9ca3af;">
|
||||
⏰ Le lien expire le <strong>{{expiry}}</strong>.
|
||||
</p>
|
||||
{{/expiry}}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Why this email -->
|
||||
<tr>
|
||||
<td style="padding:0 36px 28px;">
|
||||
<div style="border-top:1px solid #e5e7eb; padding-top:20px; font-size:0.82rem; color:#6b7280;">
|
||||
Vous recevez ce cadeau parce que vous êtes client(e) Gigafibre à
|
||||
l'adresse <strong style="color:#374151;">{{description}}</strong>.
|
||||
Si vous avez la moindre question, écrivez-nous à
|
||||
<a href="mailto:facturation@targointernet.com" style="color:#4f46e5;">facturation@targointernet.com</a>
|
||||
ou appelez-nous au <a href="tel:5142421500" style="color:#4f46e5;">514 242-1500</a>.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer band -->
|
||||
<tr>
|
||||
<td style="background:#f9fafb; padding:18px 32px; text-align:center; border-top:1px solid #e5e7eb;">
|
||||
<div style="font-size:0.75rem; color:#9ca3af;">
|
||||
Gigafibre — Internet fibre optique au Québec<br>
|
||||
<a href="https://www.gigafibre.ca" style="color:#9ca3af; text-decoration:underline;">www.gigafibre.ca</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Spacer below the card -->
|
||||
<div style="height:48px;"></div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user