← back to Cncp Domains
scripts/expiring-soon.sh
185 lines
#!/usr/bin/env bash
# expiring-soon.sh — alert Steve about CNCP domains expiring within 14 days.
# - reads ~/cncp-starter/cncp-config.json
# - filters domains with `expires` in the next 14 days from today
# - posts an HTML email via George Gmail bridge (localhost:9850)
# - on George failure, writes a markdown report to logs/ and exits 1
# - silent exit 0 when nothing is expiring
set -uo pipefail
CONFIG="${HOME}/cncp-starter/cncp-config.json"
PROJECT_DIR="${HOME}/Projects/cncp-domains"
LOG_DIR="${PROJECT_DIR}/logs"
GEORGE_URL="http://localhost:9850/api/send"
GEORGE_AUTH="${GEORGE_AUTH:?GEORGE_AUTH must be exported as admin:password (no longer hardcoded)}"
RECIPIENT="steveabramsdesigns@gmail.com"
WINDOW_DAYS=14
URGENT_DAYS=7
DRY_RUN="${DRY_RUN:-0}"
mkdir -p "${LOG_DIR}"
if [[ ! -f "${CONFIG}" ]]; then
echo "[expiring-soon] config not found: ${CONFIG}" >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "[expiring-soon] jq is required" >&2
exit 1
fi
TODAY="$(date -I)"
TODAY_EPOCH="$(date -j -f '%Y-%m-%d' "${TODAY}" +%s 2>/dev/null || date -d "${TODAY}" +%s)"
# Build a JSON array of expiring domains, each with a `daysLeft` field.
EXPIRING_JSON="$(
jq --arg today "${TODAY}" --argjson window "${WINDOW_DAYS}" '
def dayDiff(d):
((d | strptime("%Y-%m-%d") | mktime) - ($today | strptime("%Y-%m-%d") | mktime)) / 86400 | floor;
[
.domains[]
| select(.expires != null and .expires != "")
| . + {daysLeft: dayDiff(.expires)}
| select(.daysLeft >= 0 and .daysLeft <= $window)
]
| sort_by(.daysLeft)
' "${CONFIG}"
)"
COUNT="$(echo "${EXPIRING_JSON}" | jq 'length')"
if [[ "${COUNT}" -eq 0 ]]; then
# Nothing to alert about — silent exit per spec.
exit 0
fi
# ---------- build HTML body ----------
HTML_TABLE="$(
echo "${EXPIRING_JSON}" | jq -r --argjson urgent "${URGENT_DAYS}" '
def autorenew(n):
if n == null then "unknown"
elif (n | test("(?i)Auto-renew:\\s*ON")) then "ON"
elif (n | test("(?i)Auto-renew:\\s*OFF")) then "OFF"
else "unknown"
end;
def keepish(s): (s // "" | ascii_downcase) | startswith("keep");
def warnRow(d):
d.daysLeft < $urgent
and autorenew(d.note) == "OFF"
and keepish(d.suggestion);
map(
"<tr style=\"background:" +
(if warnRow(.) then "#fee2e2" else (if (.daysLeft|tonumber) < 7 then "#fef3c7" else "#ffffff" end) end) +
";\">" +
"<td style=\"padding:6px 10px;border:1px solid #e5e7eb;text-align:right;\">" +
(if warnRow(.) then "⚠ " else "" end) + (.daysLeft|tostring) +
"</td>" +
"<td style=\"padding:6px 10px;border:1px solid #e5e7eb;\"><b>" + (.name // "?") + "</b></td>" +
"<td style=\"padding:6px 10px;border:1px solid #e5e7eb;\">" + (.reg // "?") + "</td>" +
"<td style=\"padding:6px 10px;border:1px solid #e5e7eb;\">" + autorenew(.note) + "</td>" +
"<td style=\"padding:6px 10px;border:1px solid #e5e7eb;\">" + (.suggestion // "-") + "</td>" +
"<td style=\"padding:6px 10px;border:1px solid #e5e7eb;color:#555;\">" + (.expires // "") + "</td>" +
"</tr>"
) | join("\n")
'
)"
KEEP_COUNT="$(echo "${EXPIRING_JSON}" | jq '[.[] | select((.suggestion // "" | ascii_downcase) | startswith("keep"))] | length')"
TOSS_COUNT="$(echo "${EXPIRING_JSON}" | jq '[.[] | select((.suggestion // "" | ascii_downcase) | startswith("toss"))] | length')"
URGENT_COUNT="$(echo "${EXPIRING_JSON}" | jq --argjson u "${URGENT_DAYS}" '[.[] | select(.daysLeft < $u)] | length')"
REGISTRAR_BREAKDOWN="$(
echo "${EXPIRING_JSON}" | jq -r '
group_by(.reg // "Unknown")
| map("<li><b>" + (.[0].reg // "Unknown") + ":</b> " + (length|tostring) + "</li>")
| join("")
'
)"
HTML_BODY="$(cat <<HTMLEOF
<html><body style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;color:#111;">
<h2 style="margin:0 0 8px;">CNCP domains expiring in the next ${WINDOW_DAYS} days</h2>
<p style="margin:0 0 12px;color:#555;">As of ${TODAY} · <b>${COUNT}</b> expiring · <b>${KEEP_COUNT}</b> Keep · <b>${TOSS_COUNT}</b> Toss · <b>${URGENT_COUNT}</b> under ${URGENT_DAYS} days</p>
<h3 style="margin:14px 0 6px;">By registrar</h3>
<ul style="margin:0 0 12px 18px;">${REGISTRAR_BREAKDOWN}</ul>
<table style="border-collapse:collapse;border:1px solid #e5e7eb;font-size:13px;min-width:640px;">
<thead><tr style="background:#f3f4f6;">
<th style="padding:6px 10px;border:1px solid #e5e7eb;text-align:right;">Days Left</th>
<th style="padding:6px 10px;border:1px solid #e5e7eb;text-align:left;">Domain</th>
<th style="padding:6px 10px;border:1px solid #e5e7eb;text-align:left;">Registrar</th>
<th style="padding:6px 10px;border:1px solid #e5e7eb;text-align:left;">Auto-Renew</th>
<th style="padding:6px 10px;border:1px solid #e5e7eb;text-align:left;">Suggestion</th>
<th style="padding:6px 10px;border:1px solid #e5e7eb;text-align:left;">Expires</th>
</tr></thead>
<tbody>
${HTML_TABLE}
</tbody></table>
<p style="margin:14px 0 0;color:#888;font-size:12px;">Red rows = under ${URGENT_DAYS} days, auto-renew OFF, suggestion = Keep. Yellow rows = under 7 days but lower urgency.</p>
<p style="margin:6px 0 0;color:#888;font-size:12px;">Source: ~/cncp-starter/cncp-config.json · alert script: ~/Projects/cncp-domains/scripts/expiring-soon.sh</p>
</body></html>
HTMLEOF
)"
SUBJECT="CNCP domains expiring this week (${COUNT} in ${WINDOW_DAYS}d, ${URGENT_COUNT} urgent)"
# ---------- markdown fallback (always written for audit) ----------
LOG_FILE="${LOG_DIR}/expiring-${TODAY}.md"
{
echo "# CNCP domains expiring within ${WINDOW_DAYS} days"
echo ""
echo "_As of ${TODAY} — ${COUNT} expiring (${KEEP_COUNT} Keep, ${TOSS_COUNT} Toss, ${URGENT_COUNT} under ${URGENT_DAYS}d)_"
echo ""
echo "| Days Left | Domain | Registrar | Auto-Renew | Suggestion | Expires |"
echo "|---:|---|---|---|---|---|"
echo "${EXPIRING_JSON}" | jq -r --argjson u "${URGENT_DAYS}" '
def autorenew(n):
if n == null then "unknown"
elif (n | test("(?i)Auto-renew:\\s*ON")) then "ON"
elif (n | test("(?i)Auto-renew:\\s*OFF")) then "OFF"
else "unknown"
end;
def keepish(s): (s // "" | ascii_downcase) | startswith("keep");
.[] | (
(if (.daysLeft < $u and autorenew(.note) == "OFF" and keepish(.suggestion)) then "⚠ " else "" end)
+ (.daysLeft|tostring)
) as $d
| "| " + $d + " | " + (.name // "?") + " | " + (.reg // "?") + " | " + autorenew(.note) + " | " + (.suggestion // "-") + " | " + (.expires // "") + " |"
'
} > "${LOG_FILE}"
# ---------- DRY RUN: never actually email ----------
if [[ "${DRY_RUN}" == "1" ]]; then
echo "[expiring-soon] DRY RUN — would email ${RECIPIENT}"
echo "[expiring-soon] subject: ${SUBJECT}"
echo "[expiring-soon] markdown report: ${LOG_FILE}"
echo "[expiring-soon] expiring count: ${COUNT}"
echo "----- markdown table -----"
cat "${LOG_FILE}"
exit 0
fi
# ---------- POST to George ----------
PAYLOAD="$(jq -nc \
--arg to "${RECIPIENT}" \
--arg subject "${SUBJECT}" \
--arg body "${HTML_BODY}" \
'{to:$to, subject:$subject, body:$body}')"
HTTP_OUT="$(curl -sS -o "${LOG_DIR}/last-george.json" -w '%{http_code}' \
--connect-timeout 5 --max-time 30 \
-X POST "${GEORGE_URL}" \
-u "${GEORGE_AUTH}" \
-H 'content-type: application/json' \
--data-binary "${PAYLOAD}" 2>>"${LOG_DIR}/last-george.err")"
# curl's -w '%{http_code}' already emits "000" on connection failure; no fallback needed.
if [[ "${HTTP_OUT}" =~ ^2 ]]; then
echo "[expiring-soon] sent ${COUNT} expiring domains to ${RECIPIENT} (HTTP ${HTTP_OUT})"
exit 0
fi
echo "[expiring-soon] George send FAILED (HTTP ${HTTP_OUT}); markdown report kept at ${LOG_FILE}" >&2
exit 1