Previous commit (380f3bc) incorrectly claimed Mailjet verified targo.ca
at the domain level. It doesn't — Mailjet validates senders ONE BY ONE,
even when SPF/DKIM/DMARC are correctly published at the domain. The
mistake: SMTP returned `250 OK` on a send-test from support@targo.ca,
but the message was silently dropped on Mailjet's side because that
specific mailbox hadn't been approved.
Validated senders on this Mailjet account:
✓ noreply@targo.ca — hub transactional (invoices etc.)
✓ support@targointernet.com — gift campaigns
`support@targo.ca` is NOT validated, despite being on a domain whose
sibling (`noreply@targo.ca`) is.
Updated:
- README default --from value
- The "sender" section now explains per-sender validation (not
domain-level) and the SMTP-250-but-not-delivered gotcha
- Listed both validated senders explicitly with usage intent
The script itself (send_gift_campaign.js) was already
sender-agnostic — only README guidance changed. New senders are added
in Mailjet console → Sender Domain Verification → Add sender, with
the verification link mailed to the new address.
265 lines
10 KiB
Markdown
265 lines
10 KiB
Markdown
# 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
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
# 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 "Gigafibre Support <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)
|
|
|
|
```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 Support <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
|
|
|
|
```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 Support <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"`) |
|
|
|
|
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.
|
|
|
|
## 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.
|