gigafibre-fsm/scripts/campaigns
louispaulb 37896421c3 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)
2026-05-21 15:51:01 -04:00
..
templates feat(campaigns): MVP gift campaign sender (Node CLI + FR email template) 2026-05-21 15:51:01 -04:00
contacts_from_legacy.py feat(campaigns): MVP gift campaign sender (Node CLI + FR email template) 2026-05-21 15:51:01 -04:00
README.md feat(campaigns): MVP gift campaign sender (Node CLI + FR email template) 2026-05-21 15:51:01 -04:00
send_gift_campaign.js feat(campaigns): MVP gift campaign sender (Node CLI + FR email template) 2026-05-21 15:51:01 -04:00

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

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)

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

# 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:

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:

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.