#!/usr/bin/env bash # Read-only curl wrapper for the Zoho Mail API. # # Usage: # zoho-curl.sh # e.g. zoho-curl.sh /accounts # zoho-curl.sh -i # include curl's -i (response headers) # zoho-curl.sh -o file.json # write body to file # # Reads credentials from ../../dolibarr/.env (the shared canonical file). # Required vars: # ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, ZOHO_REFRESH_TOKEN, ZOHO_DC # # Token strategy: each invocation refreshes a short-lived access_token from # the refresh_token (Zoho access_tokens live 1h; the cost of refreshing on # every call is ~150 ms and avoids state on disk). On 401 from the mail API # we re-refresh once and retry (covers refresh-token rotation cases). # # Exits non-zero on HTTP >= 400 and writes body to stdout + a short message # to stderr — same shape as dol-curl.sh / bank-curl.sh. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ENV_FILE="${SCRIPT_DIR}/../../dolibarr/.env" if [[ ! -f "${ENV_FILE}" ]]; then echo "zoho-curl.sh: missing ${ENV_FILE}" >&2 echo " Required vars: ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, ZOHO_REFRESH_TOKEN, ZOHO_DC." >&2 echo " See arcodange-email-ingest/SKILL.md for the OAuth setup." >&2 exit 2 fi set -a; source "${ENV_FILE}"; set +a : "${ZOHO_CLIENT_ID:?zoho-curl.sh: ZOHO_CLIENT_ID not set in .env}" : "${ZOHO_CLIENT_SECRET:?zoho-curl.sh: ZOHO_CLIENT_SECRET not set in .env}" : "${ZOHO_REFRESH_TOKEN:?zoho-curl.sh: ZOHO_REFRESH_TOKEN not set in .env}" : "${ZOHO_DC:=eu}" ACCOUNTS_BASE="https://accounts.zoho.${ZOHO_DC}" MAIL_BASE="https://mail.zoho.${ZOHO_DC}/api" # Parse pass-through curl args (everything before the last positional) PASSTHRU=() while [[ $# -gt 1 ]]; do PASSTHRU+=("$1"); shift done if [[ $# -lt 1 ]]; then echo "zoho-curl.sh: missing API path. Example: zoho-curl.sh /accounts" >&2 exit 2 fi API_PATH="$1" # Cache access_token in tmpfs to avoid hitting OAuth rate limits on every # zoho-curl invocation. Zoho access_tokens live 1h; we refresh after 50 min. CACHE_FILE="${TMPDIR:-/tmp}/zoho-access-$(whoami)" CACHE_TTL_SECONDS=$((50 * 60)) get_access_token() { if [[ -f "${CACHE_FILE}" ]]; then local age age=$(( $(date +%s) - $(stat -f %m "${CACHE_FILE}" 2>/dev/null || stat -c %Y "${CACHE_FILE}") )) if [[ ${age} -lt ${CACHE_TTL_SECONDS} ]]; then cat "${CACHE_FILE}" return 0 fi fi local token if ! token=$(curl -sS -X POST "${ACCOUNTS_BASE}/oauth/v2/token" \ --max-time 15 \ -d "grant_type=refresh_token" \ -d "client_id=${ZOHO_CLIENT_ID}" \ -d "client_secret=${ZOHO_CLIENT_SECRET}" \ -d "refresh_token=${ZOHO_REFRESH_TOKEN}" \ | python3 -c " import json, sys try: d = json.load(sys.stdin) except: sys.exit('failed to parse OAuth response') if 'access_token' not in d: sys.exit(f'OAuth refresh failed: {d}') print(d['access_token'])"); then return 1 fi if [[ -z "${token}" ]]; then return 1 fi # Store cache (mode 600) only on success printf '%s' "${token}" > "${CACHE_FILE}" chmod 600 "${CACHE_FILE}" printf '%s' "${token}" } do_call() { local token="$1" local body_file="$2" local headers_file="$3" curl -sS \ -H "Authorization: Zoho-oauthtoken ${token}" \ -H "Accept: application/json" \ --max-time 30 \ -o "${body_file}" \ -D "${headers_file}" \ -w "%{http_code}" \ ${PASSTHRU[@]+"${PASSTHRU[@]}"} \ "${MAIL_BASE}${API_PATH}" } ACCESS_TOKEN=$(get_access_token) [[ -z "${ACCESS_TOKEN}" ]] && { echo "zoho-curl.sh: empty access_token" >&2; exit 1; } BODY_FILE="$(mktemp -t zohocurl.XXXXXX)" HEADERS_FILE="$(mktemp -t zohohdr.XXXXXX)" trap 'rm -f "${BODY_FILE}" "${HEADERS_FILE}"' EXIT HTTP_CODE=$(do_call "${ACCESS_TOKEN}" "${BODY_FILE}" "${HEADERS_FILE}") # Retry once on 401 with a fresh token (handles edge cases of refresh-token rotation) if [[ "${HTTP_CODE}" == "401" ]]; then ACCESS_TOKEN=$(get_access_token) HTTP_CODE=$(do_call "${ACCESS_TOKEN}" "${BODY_FILE}" "${HEADERS_FILE}") fi cat "${BODY_FILE}" if [[ "${HTTP_CODE}" -ge 400 ]]; then echo "zoho-curl.sh: HTTP ${HTTP_CODE} on ${API_PATH}" >&2 exit 1 fi