#!/usr/bin/env bash # send-invitations.sh — batch-insert beta invitations from a validated # cohort CSV, generate unique invite codes, render personalised email # bodies, optionally dispatch via SMTP. # # Wraps the validate-cohort.sh sanity check + a transactional INSERT # into beta_invites + a per-recipient email render. Splits "generate # the codes + render the emails" from "actually send" so a dry-run # produces a flat directory of `.eml` files the operator can review # before dispatch. # # v1.0.10 Cluster 3.4. # # Usage : # # Step 1 : dry-run (default). Inserts beta_invites rows, emits # # eml files but does NOT send anything. # DATABASE_URL=postgres://... \ # bash scripts/soft-launch/send-invitations.sh path/to/cohort.csv # # # Step 2 : after reviewing the eml files, dispatch with msmtp / # # sendmail / aws-ses-cli (or whatever SEND_CMD points at). # SEND=1 SEND_CMD='msmtp -t' \ # bash scripts/soft-launch/send-invitations.sh path/to/cohort.csv # # Required env : # DATABASE_URL Postgres URL (read+write to beta_invites) # FRONTEND_URL base URL the invite link points at # (e.g. https://staging.veza.fr) # # Optional env : # SEND=1 actually dispatch ; otherwise dry-run (eml only) # SEND_CMD sendmail-compatible command (default: 'msmtp -t') # SENT_BY_EMAIL operator email for the beta_invites.sent_by FK ; # defaults to the value in the CSV's third column # FROM_ADDR From: header (default: invitations@veza.fr) # SUBJECT email subject (default: 'You're in for the Veza beta') # TEMPLATE path to eml template (default: # templates/email/beta_invite.html.template) # FORCE=1 skip validate-cohort.sh failures (use with care) # # Exit codes : # 0 — everything succeeded # 1 — cohort validation failed (see validate-cohort.sh) # 2 — DB transaction failed # 3 — required env missing # 4 — dispatch failed for at least one recipient (see logs) set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" CSV=${1:-} if [ -z "$CSV" ] || [ ! -f "$CSV" ]; then echo "usage: bash scripts/soft-launch/send-invitations.sh path/to/cohort.csv" >&2 exit 3 fi DATABASE_URL=${DATABASE_URL:-?} FRONTEND_URL=${FRONTEND_URL:-?} [ "$DATABASE_URL" = "?" ] && { echo "DATABASE_URL required" >&2; exit 3; } [ "$FRONTEND_URL" = "?" ] && { echo "FRONTEND_URL required" >&2; exit 3; } SEND=${SEND:-0} SEND_CMD=${SEND_CMD:-msmtp -t} FROM_ADDR=${FROM_ADDR:-invitations@veza.fr} SUBJECT=${SUBJECT:-Vous êtes admis dans la bêta Veza} TEMPLATE=${TEMPLATE:-$REPO_ROOT/templates/email/beta_invite.eml.template} FORCE=${FORCE:-0} SESSION_DATE="$(date +%Y%m%d-%H%M)" OUTDIR="$REPO_ROOT/scripts/soft-launch/out-${SESSION_DATE}" command -v psql >/dev/null 2>&1 || { echo "psql required" >&2; exit 3; } command -v openssl >/dev/null 2>&1 || { echo "openssl required" >&2; exit 3; } # Step 1 — validate the cohort. Bypass with FORCE=1 if needed. echo "→ validating cohort $CSV" if ! bash "$(dirname "$0")/validate-cohort.sh" "$CSV"; then if [ "$FORCE" != "1" ]; then echo "ERROR: cohort validation failed. Re-run with FORCE=1 to bypass (not recommended)." >&2 exit 1 fi echo "WARN : cohort validation reported issues but FORCE=1 set — proceeding." fi mkdir -p "$OUTDIR" echo "→ output dir $OUTDIR" # Step 2 — generate codes + insert rows + render emails. Each insert # is one transaction so a partial failure leaves consistent state. gen_code() { # 16-char base32-ish (no 0/1/I/L) so codes are paste-friendly. openssl rand -hex 16 | tr 'a-f0-9' 'a-z2-9' \ | tr -d 'oilOIL01' | head -c 16 } if [ ! -f "$TEMPLATE" ]; then echo "ERROR: template $TEMPLATE not found." >&2 exit 3 fi inserted=0 failed=0 failed_emails=() while IFS=, read -r email cohort sent_by_email; do email=$(echo "$email" | tr -d '\r' | xargs) cohort=$(echo "$cohort" | tr -d '\r' | xargs) sent_by_email=$(echo "$sent_by_email" | tr -d '\r' | xargs) code=$(gen_code) # Resolve sent_by user_id (may be NULL if operator email isn't a # registered user — e.g. ops shared mailbox). sent_by_id=$(psql "$DATABASE_URL" -A -t -c " SELECT id::text FROM users WHERE email = '$sent_by_email' LIMIT 1; " 2>/dev/null | tr -d ' ' || echo "") if [ -z "$sent_by_id" ]; then sent_by_clause="NULL" else sent_by_clause="'$sent_by_id'" fi if ! psql "$DATABASE_URL" -1 -c " INSERT INTO beta_invites (code, email, cohort, sent_by, expires_at) VALUES ('$code', '$email', '$cohort', $sent_by_clause, NOW() + INTERVAL '30 days'); " >/dev/null 2>&1; then failed=$(( failed + 1 )) failed_emails+=("$email") continue fi inserted=$(( inserted + 1 )) # Render the eml — operator-readable, ready for SEND_CMD. eml="$OUTDIR/${email//[^a-zA-Z0-9._-]/_}.eml" invite_url="$FRONTEND_URL/signup?invite=$code" sed \ -e "s|{{TO_ADDR}}|$email|g" \ -e "s|{{FROM_ADDR}}|$FROM_ADDR|g" \ -e "s|{{SUBJECT}}|$SUBJECT|g" \ -e "s|{{INVITE_URL}}|$invite_url|g" \ -e "s|{{INVITE_CODE}}|$code|g" \ -e "s|{{COHORT}}|$cohort|g" \ -e "s|{{FRONTEND_URL}}|$FRONTEND_URL|g" \ "$TEMPLATE" > "$eml" done < <(tail -n +2 "$CSV") echo "→ inserted $inserted invitations into beta_invites" echo "→ rendered $inserted emails to $OUTDIR" [ "$failed" -gt 0 ] && { echo "WARN : $failed insert(s) failed — see logs above" for e in "${failed_emails[@]}"; do echo " - $e"; done } # Step 3 — optionally dispatch. if [ "$SEND" != "1" ]; then echo "" echo "DRY-RUN — review the eml files in $OUTDIR before sending." echo "When ready :" echo " SEND=1 SEND_CMD='$SEND_CMD' bash scripts/soft-launch/send-invitations.sh $CSV" exit 0 fi echo "→ dispatching via : $SEND_CMD" dispatch_failed=0 for eml in "$OUTDIR"/*.eml; do if ! $SEND_CMD < "$eml" >>"$OUTDIR/dispatch.log" 2>&1; then dispatch_failed=$(( dispatch_failed + 1 )) echo " FAIL : $eml" | tee -a "$OUTDIR/dispatch.log" fi done echo "" if [ "$dispatch_failed" -gt 0 ]; then echo "WARN : $dispatch_failed dispatch(es) failed — see $OUTDIR/dispatch.log" exit 4 fi echo "PASS : all $inserted invitations dispatched." echo "Track redemption with :" echo " psql \"\$DATABASE_URL\" -c 'SELECT cohort, count(*) FILTER (WHERE used_at IS NOT NULL) AS redeemed, count(*) AS total FROM beta_invites GROUP BY cohort ORDER BY cohort;'"