gigafibre-fsm/scripts/campaigns
louispaulb b37270c11d feat(campaigns/editor): MJML mode — proper email-focused visual builder
Pivot the template editor toward email-marketing-grade visual editing
by replacing grapesjs-preset-newsletter (permissive HTML, fails to parse
nested table structures) with grapesjs-mjml (the industry-standard
email markup language used by Mailchimp/Sendgrid/Twilio).

Why MJML: it was specifically designed to solve the "visual editor +
email-safe HTML" problem. You write semantic <mj-section>, <mj-column>,
<mj-button>, <mj-image> components — MJML compiles them to the gnarly
email-safe HTML with Outlook fallbacks + responsive media queries
auto-generated. Source is 3x more compact than hand-written HTML and
parses cleanly in visual editors.

Backend (lib/campaigns.js):

- Add `mjml` (v5, async) dependency. Compilation happens server-side
  at SAVE time only; the send-worker reads pre-compiled .html (no
  per-recipient compile cost).
- Each template can now be in 'mjml' or 'html' format. Detection by
  file extension on disk: .mjml present → format='mjml', otherwise
  format='html'. Source of truth for MJML templates = .mjml file;
  .html is the auto-compiled output kept alongside for the send-worker.
- GET /campaigns/templates → returns { name, format, size } per template.
- GET /campaigns/templates/:name → returns { format, mjml?, html }
  (mjml field present only when format=mjml; html always present).
- PUT /campaigns/templates/:name accepts:
    { mjml: "<mjml>..." }  → compile to HTML, save both .mjml + .html
    { html: "..." }        → save .html only (legacy path, unchanged)
  Compilation errors return 400 with details (MJML validation soft mode).
  Both files backed up as .bak-<ts>.<ext> before overwrite.

Frontend (TemplateEditorPage.vue):

- Detect format from API response on load.
- For format='mjml': swap grapesjs-preset-newsletter for grapesjs-mjml
  plugin. Editor's getHtml() returns MJML source (not compiled HTML);
  Save POSTs the MJML, hub compiles + persists both files.
- For format='html': existing behavior unchanged.
- Editor is destroyed + reinitialized when format changes (different
  plugin sets).
- Custom variable blocks ({{firstname}}, {{amount}}, etc.) work for
  both formats — they're text content, format-agnostic.

API client (apps/ops/src/api/campaigns.js):

- saveTemplate(name, content, { format }) routes to the right PUT body
  shape based on format param.

Prototype: gift-email-fr-mjml — full MJML conversion of the simple
variant, ~7.5 KB MJML source compiling to ~32 KB email-safe HTML with
0 validation errors. All 6 Mustache variables preserved through
compilation (firstname, amount, gift_url, description, commitment_months,
year). User compares the MJML editor experience to the existing HTML
templates and decides whether to migrate the others.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 22:29:42 -04:00
..
templates feat(campaigns/editor): MJML mode — proper email-focused visual builder 2026-05-21 22:29:42 -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
create_giftbit_campaign.js feat(campaigns): add Giftbit API client + validate end-to-end with sandbox 2026-05-21 16:20:28 -04:00
package-lock.json feat(campaigns): TARGO rebrand + Mustache sections + Mailjet webhook setup 2026-05-21 19:07:20 -04:00
package.json feat(campaigns): TARGO rebrand + Mustache sections + Mailjet webhook setup 2026-05-21 19:07:20 -04:00
README.md feat(campaigns): TARGO rebrand + Mustache sections + Mailjet webhook setup 2026-05-21 19:07:20 -04:00
send_gift_campaign.js feat(campaigns): TARGO rebrand + Mustache sections + Mailjet webhook setup 2026-05-21 19:07:20 -04:00
setup_mailjet_webhook.js feat(campaigns): TARGO rebrand + Mustache sections + Mailjet webhook setup 2026-05-21 19:07:20 -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 (two-stage pipeline)

The campaign is split into two scripts you run in sequence:

contacts_from_legacy.py        # (one-time) extract clean contacts from legacy CSV
       ↓
   contacts.csv
       ↓
create_giftbit_campaign.js     # POST to Giftbit API → SHORTLINK gifts back
       ↓
   gifts.csv  +  contacts.csv
       ↓
send_gift_campaign.js          # personalized FR emails via Mailjet
       ↓
   results-<timestamp>.csv     # per-row status for follow-up

Critical: the create script passes delivery_type=SHORTLINK to Giftbit so they generate the redemption links but DO NOT send their own English emails. We then deliver French personalized mail through Mailjet, the same SMTP wired up for ERPNext invoices.

The Giftbit redemption landing page (where the recipient picks a brand) is controlled by Giftbit — when creating the campaign through their dashboard for the first time, set the language to fr-CA so the page shows in French. The API exposes a language field too but it wasn't fully exposed on our sandbox account; verify with the campaign you created in the Giftbit dashboard.

Setup

cd scripts/campaigns
npm init -y                 # one-time, creates package.json
npm install nodemailer      # only dependency (create_giftbit_campaign.js
                            # uses Node built-ins, no http library needed)

Stage 1 — create the Giftbit campaign

# Sandbox test (all recipients are rerouted to louis@targo.ca for safety):
export GIFTBIT_TOKEN="<your testbed token>"
node create_giftbit_campaign.js \
  --contacts ./test-contacts.csv \
  --amount-cents 5000 \
  --brand-codes amazonca,timhortonsca,walmart \
  --expiry 2026-12-31 \
  --subject "Cadeau Gigafibre" \
  --message "Merci d'être client" \
  --sandbox \
  --id "test-q4-2026"

# Production (real recipient emails, real gifts charged from your balance):
export GIFTBIT_TOKEN="<your prod token>"
node create_giftbit_campaign.js \
  --contacts ./contacts.csv \
  --amount-cents 5000 \
  --brand-codes amazonca,timhortonsca,walmart \
  --expiry 2026-12-31 \
  --subject "Cadeau Gigafibre" \
  --message "Merci d'être client" \
  --id "q4-2026-loyalty"

Output: giftbit-gifts-<id>.csv with columns:

firstname,lastname,email,gift_url,giftbit_uuid,gift_value_cents,internal_id
Alice,Tremblay,louis@targo.ca,http://gtbt.co/7TKGFDBNVZq,bdb28566...,500,TEST-001

internal_id is your contact's account_id column passed through to join the response back to ERPNext customer records.

The --sandbox flag does TWO things:

  • Points the API at api-testbed.giftbit.com instead of api.giftbit.com
  • Replaces every recipient email with louis@targo.ca as a safety net so the test gifts (non-redeemable in sandbox) don't actually land in any real customer inbox

--from sender

Mailjet validates senders individually, not at the domain level — each mailbox (noreply@, support@, etc.) must be approved separately in the Mailjet console even when SPF/DKIM/DMARC are published at the domain level. The two known-validated senders on this account are:

Sender Used by Status
noreply@targo.ca hub transactional (invoices, magic links) ✓ validated
support@targointernet.com gift campaigns ✓ validated

The default for gift campaigns:

--from "TARGO <support@targointernet.com>"

Reasoning for support@ over noreply@: campaigns INVITE a reply (questions about the gift, "I didn't get mine", "the link doesn't work"). noreply@ is for transactional system mail where there's nothing useful for a human to reply to. Different intent → different sender.

Common gotcha: SMTP returns 250 OK even when Mailjet later refuses to deliver because the sender isn't validated. So the script will say "sent" but the recipient never sees the message. Always verify a single test arrives in inbox before doing a bulk send with a new sender. We hit this with support@targo.ca (caught after the fact, fixed by switching to support@targointernet.com).

If you need a new sender (e.g. recompenses@gigafibre.ca), add it in the Mailjet console → Sender Domain Verification → Add a sender, then click the verification link mailed to that address. Per-sender approval takes minutes once you control the inbox.

Stage 2 — send the personalized French emails

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 TARGO" \
  --amount   "60 $" \
  --expiry   "31 décembre 2026" \
  --commitment-months 3 \
  --from     "TARGO <support@targointernet.com>" \
  --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 TARGO" \
  --amount   "60 $" \
  --expiry   "31 décembre 2026" \
  --commitment-months 3 \
  --from     "TARGO <support@targointernet.com>" \
  --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")
{{commitment_months}} --commitment-months CLI flag (default 3) — used in the "Condition" pill and prorata-refund disclaimer of the retention offer

The template uses a Mustache-style {{#expiry}} ... {{/expiry}} block for the optional expiry line. The renderer keeps the contents when the matching variable is truthy and drops them entirely otherwise — so if you omit --expiry from the CLI, the "Le lien expire le …" sentence disappears cleanly with no orphan tags showing.

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.

Known issues to resolve before production

  1. Customer matching from the source CSV is only 25% — the id emplacement column in selectionAdressesMap*.csv is NOT a legacy_delivery_id. Of 216 source rows, only 54 resolve to a Service Location via that column. The other 162 use a different ID space (50000+ range, while migrated SLs are 1-17307). Before going to production, we need to either:

    • Match by address (street + civic + postal_code) to find the correct Service Location → Customer
    • Or have the Map tool include the actual Service Location name (LOC-XXXXX) in its export The current account_id column in our contacts CSV is approximated; for accurate Customer audit-trail we need this fixed.
  2. The Giftbit testbed token in our hub .env is sandbox-only. Production access requires Giftbit-side KYC + account funding + API approval. While waiting, all testing happens with the testbed token and the --sandbox flag — gift URLs work in their test webapp but represent no real money.

  3. recipient_name collapses when emails are duplicated. In sandbox we send all 3 test gifts to louis@targo.ca, and Giftbit's API returns the same recipient_name for all of them (apparently they dedup by email). In production with distinct emails per contact, each gift has the right name. This is a sandbox-only quirk, not a script bug.