Files
email-sync-oauth2/sync.sh
T

423 lines
15 KiB
Bash

#!/bin/bash
set -Eeuo pipefail
# IMAP Sync Manager - multi-account OAuth2 and password authentication.
TOKEN_DIR="${TOKEN_DIR:-/tokens}"
LOG_DIR="${LOG_DIR:-/var/log/imapsync}"
PID_DIR="${PID_DIR:-/tmp/imapsync}"
HEARTBEAT_FILE="${LOG_DIR}/.heartbeat"
NUM_ACCOUNTS="${NUM_ACCOUNTS:-1}"
SYNC_INTERVAL="${SYNC_INTERVAL:-21600}" # six hours
SYNC_CRON="${SYNC_CRON:-}"
ONCE="${ONCE:-false}"
# Notifications are disabled unless NOTIFY_MODE is explicitly set.
# Valid modes: none, batch_success, account_success, failure.
NOTIFY_MODE="${NOTIFY_MODE:-none}"
SMTP_HOST="${SMTP_HOST:-}"
SMTP_PORT="${SMTP_PORT:-587}"
SMTP_USER="${SMTP_USER:-}"
SMTP_PASS="${SMTP_PASS:-}"
SMTP_FROM="${SMTP_FROM:-}"
SMTP_TO="${SMTP_TO:-}"
SMTP_TLS="${SMTP_TLS:-starttls}"
NOTIFICATION_FAILURE_FILE="${LOG_DIR}/.notification_failed"
NOTIFICATION_LOG="${LOG_DIR}/notification.log"
mkdir -p "$LOG_DIR" "$PID_DIR" "$TOKEN_DIR"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
}
get_var() {
local varname="$1"
echo "${!varname:-}"
}
oauth_authorization_invalid() {
local logfile="$1"
tail -n 200 "$logfile" 2>/dev/null | grep -Eqi \
'invalid_grant|expired.*(token|credential)|token.*expired|refresh token.*(expired|revoked|invalid)|authorization.*(expired|revoked)|AADSTS700082|AADSTS700084|AUTHENTICATE failed|authentication.*failed|authentication unsuccessful|invalid credentials'
}
log_oauth_authorization_guidance() {
local i="$1"
local provider="$2"
local logfile="$3"
if oauth_authorization_invalid "$logfile"; then
log "Account $i: OAuth2 authorization is expired, revoked, or rejected by $provider. Reauthorize with: docker compose run --rm imapsync authorize $i"
log "Account $i: After reauthorization, run immediately with: docker compose exec imapsync sync.sh sync $i"
fi
}
validate_settings() {
if ! [[ "$NUM_ACCOUNTS" =~ ^[1-9][0-9]*$ ]]; then
log "Configuration error: NUM_ACCOUNTS must be a positive integer"
return 1
fi
if [ -z "$SYNC_CRON" ] && ! [[ "$SYNC_INTERVAL" =~ ^[1-9][0-9]*$ ]]; then
log "Configuration error: SYNC_INTERVAL must be a positive number of seconds"
return 1
fi
case "$NOTIFY_MODE" in
none|batch_success|account_success|failure) ;;
*)
log "Configuration error: NOTIFY_MODE must be none, batch_success, account_success, or failure"
return 1
;;
esac
if [ "$NOTIFY_MODE" != "none" ]; then
if [ -z "$SMTP_HOST" ] || [ -z "$SMTP_FROM" ] || [ -z "$SMTP_TO" ]; then
log "Configuration error: SMTP_HOST, SMTP_FROM, and SMTP_TO are required when notifications are enabled"
return 1
fi
if ! [[ "$SMTP_PORT" =~ ^[1-9][0-9]*$ ]]; then
log "Configuration error: SMTP_PORT must be a valid port number"
return 1
fi
if { [ -n "$SMTP_USER" ] && [ -z "$SMTP_PASS" ]; } || { [ -z "$SMTP_USER" ] && [ -n "$SMTP_PASS" ]; }; then
log "Configuration error: SMTP_USER and SMTP_PASS must either both be set or both be empty"
return 1
fi
case "$SMTP_TLS" in starttls|tls|none) ;; *)
log "Configuration error: SMTP_TLS must be starttls, tls, or none"
return 1
;;
esac
fi
}
send_notification() {
local subject="$1"
local body="$2"
local config
local -a recipients
if [ "$NOTIFY_MODE" = "none" ]; then
return 0
fi
config="$(mktemp "${PID_DIR}/msmtp.XXXXXX")"
chmod 600 "$config"
{
printf 'defaults\n'
printf 'host %s\n' "$SMTP_HOST"
printf 'port %s\n' "$SMTP_PORT"
printf 'from %s\n' "$SMTP_FROM"
if [ -n "$SMTP_USER" ]; then
printf 'auth on\nuser %s\npassword %s\n' "$SMTP_USER" "$SMTP_PASS"
else
printf 'auth off\n'
fi
case "$SMTP_TLS" in
starttls) printf 'tls on\ntls_starttls on\n' ;;
tls) printf 'tls on\ntls_starttls off\n' ;;
none) printf 'tls off\n' ;;
esac
} > "$config"
IFS=',' read -r -a recipients <<< "$SMTP_TO"
if printf 'From: %s\nTo: %s\nSubject: %s\n\n%s\n' "$SMTP_FROM" "$SMTP_TO" "$subject" "$body" \
| msmtp --file="$config" "${recipients[@]}" >> "$NOTIFICATION_LOG" 2>&1; then
rm -f "$config" "$NOTIFICATION_FAILURE_FILE"
log "Notification sent: $subject"
return 0
fi
rm -f "$config"
printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$subject" > "$NOTIFICATION_FAILURE_FILE"
log "Notification delivery failed; container will remain unhealthy until a notification is sent successfully. Check $NOTIFICATION_LOG"
return 1
}
source_oauth_provider() {
case "$1" in
office365|outlook|hotmail) echo "office365" ;;
gmail|google) echo "gmail" ;;
*) return 1 ;;
esac
}
sync_account() {
local i="$1"
local src_host="$(get_var "ACCOUNT_${i}_SOURCE_HOST")"
local src_user="$(get_var "ACCOUNT_${i}_SOURCE_USER")"
local src_auth="$(get_var "ACCOUNT_${i}_SOURCE_AUTH")"
local src_pass="$(get_var "ACCOUNT_${i}_SOURCE_PASS")"
local src_token="$(get_var "ACCOUNT_${i}_SOURCE_TOKEN")"
local src_provider="$(get_var "ACCOUNT_${i}_SOURCE_PROVIDER")"
local dest_host="$(get_var "ACCOUNT_${i}_DEST_HOST")"
local dest_user="$(get_var "ACCOUNT_${i}_DEST_USER")"
local dest_pass="$(get_var "ACCOUNT_${i}_DEST_PASS")"
local delete_opt="$(get_var "ACCOUNT_${i}_DELETE")"
local dry_run="$(get_var "ACCOUNT_${i}_DRY_RUN")"
local extra_opts="$(get_var "ACCOUNT_${i}_EXTRA_OPTS")"
local logfile="${LOG_DIR}/account${i}.log"
local pidfile="${PID_DIR}/account${i}.pid"
local oauth_provider
local oauth_log
local oauth_authorization_detected=false
local sync_exit
local -a args
SYNC_ACCOUNT_RESULT="skipped"
if [ -z "$src_host" ] && [ -z "$dest_host" ]; then
log "Account $i: SKIP (not configured)"
return 0
fi
if [ -z "$src_host" ] || [ -z "$src_user" ] || [ -z "$dest_host" ] || [ -z "$dest_user" ] || [ -z "$dest_pass" ]; then
log "Account $i: CONFIGURATION FAILED (source host/user and destination host/user/password are required)"
SYNC_ACCOUNT_RESULT="failure"
echo "$(date +%s)" > "${LOG_DIR}/account${i}.last_failure"
return 1
fi
if [ -f "$pidfile" ]; then
local old_pid
old_pid="$(cat "$pidfile")"
if kill -0 "$old_pid" 2>/dev/null; then
log "Account $i: SKIP (already running, PID $old_pid)"
return 0
fi
fi
echo $$ > "$pidfile"
log "Account $i: SYNC START - $src_user ($src_host) -> $dest_user ($dest_host)"
args=(
--host1 "$src_host" --user1 "$src_user" --ssl1
--host2 "$dest_host" --user2 "$dest_user" --password2 "$dest_pass" --ssl2
--noauthmd5
)
case "$src_auth" in
oauth2)
if ! oauth_provider="$(source_oauth_provider "$src_provider")"; then
log "Account $i: CONFIGURATION FAILED (OAuth2 provider must be office365, outlook, hotmail, gmail, or google)"
SYNC_ACCOUNT_RESULT="failure"
echo "$(date +%s)" > "${LOG_DIR}/account${i}.last_failure"
rm -f "$pidfile"
return 1
fi
if [ -z "$src_token" ]; then
log "Account $i: CONFIGURATION FAILED (ACCOUNT_${i}_SOURCE_TOKEN is required for OAuth2)"
SYNC_ACCOUNT_RESULT="failure"
echo "$(date +%s)" > "${LOG_DIR}/account${i}.last_failure"
rm -f "$pidfile"
return 1
fi
if [ ! -s "$src_token" ]; then
log "Account $i: OAuth2 token is missing. Authorize with: docker compose run --rm imapsync authorize $i"
SYNC_ACCOUNT_RESULT="failure"
echo "$(date +%s)" > "${LOG_DIR}/account${i}.last_failure"
rm -f "$pidfile"
return 1
fi
# The helper prints access and refresh tokens. Keep that output in a
# short-lived, root-only file instead of the mounted account log.
oauth_log="$(mktemp "${PID_DIR}/oauth.XXXXXX")"
chmod 600 "$oauth_log"
# The upstream helper opens an interactive browser flow when refresh
# fails. A scheduled container must never wait indefinitely for that.
timeout --kill-after=5s 90s oauth2_imap --provider "$oauth_provider" --application imapsync --token_file "$src_token" "$src_user" > "$oauth_log" 2>&1 || true
if oauth_authorization_invalid "$oauth_log"; then
log_oauth_authorization_guidance "$i" "$src_provider" "$oauth_log"
oauth_authorization_detected=true
fi
rm -f "$oauth_log"
if [ ! -s "$src_token" ]; then
log "Account $i: OAuth2 token refresh or authorization failed"
log_oauth_authorization_guidance "$i" "$src_provider" "$logfile"
SYNC_ACCOUNT_RESULT="failure"
echo "$(date +%s)" > "${LOG_DIR}/account${i}.last_failure"
rm -f "$pidfile"
return 1
fi
args+=(--oauthaccesstoken1 "$src_token")
;;
password)
if [ -z "$src_pass" ]; then
log "Account $i: CONFIGURATION FAILED (ACCOUNT_${i}_SOURCE_PASS is required for password authentication)"
SYNC_ACCOUNT_RESULT="failure"
echo "$(date +%s)" > "${LOG_DIR}/account${i}.last_failure"
rm -f "$pidfile"
return 1
fi
args+=(--password1 "$src_pass")
;;
*)
log "Account $i: CONFIGURATION FAILED (ACCOUNT_${i}_SOURCE_AUTH must be oauth2 or password)"
SYNC_ACCOUNT_RESULT="failure"
echo "$(date +%s)" > "${LOG_DIR}/account${i}.last_failure"
rm -f "$pidfile"
return 1
;;
esac
[ "$delete_opt" = "true" ] && args+=(--delete2)
[ "$dry_run" = "true" ] && args+=(--dry)
if [ -n "$extra_opts" ]; then
# Intentional option splitting: this variable supplies extra CLI flags.
# shellcheck disable=SC2206
args+=($extra_opts)
fi
if imapsync "${args[@]}" >> "$logfile" 2>&1; then
log "Account $i: SYNC OK"
echo "$(date +%s)" > "${LOG_DIR}/account${i}.last_success"
SYNC_ACCOUNT_RESULT="success"
rm -f "$pidfile"
return 0
fi
sync_exit=$?
log "Account $i: SYNC FAILED (exit $sync_exit)"
echo "$(date +%s)" > "${LOG_DIR}/account${i}.last_failure"
if [ "$src_auth" = "oauth2" ] && [ "$oauth_authorization_detected" = "false" ]; then
log_oauth_authorization_guidance "$i" "$src_provider" "$logfile"
fi
SYNC_ACCOUNT_RESULT="failure"
rm -f "$pidfile"
return "$sync_exit"
}
sync_all() {
local i
local configured=0
local failures=0
local notification_failures=0
local successes=""
local failed_accounts=""
log "=== Sync cycle start ==="
for ((i = 1; i <= NUM_ACCOUNTS; i++)); do
if sync_account "$i"; then
if [ "$SYNC_ACCOUNT_RESULT" = "success" ]; then
configured=$((configured + 1))
successes="${successes}${successes:+, }${i}"
if [ "$NOTIFY_MODE" = "account_success" ]; then
send_notification "IMAP sync succeeded for account $i" "Account $i completed successfully at $(date '+%Y-%m-%d %H:%M:%S')." || notification_failures=1
fi
fi
else
configured=$((configured + 1))
failures=$((failures + 1))
failed_accounts="${failed_accounts}${failed_accounts:+, }${i}"
fi
done
if [ "$NOTIFY_MODE" = "batch_success" ] && [ "$configured" -gt 0 ] && [ "$failures" -eq 0 ]; then
send_notification "IMAP sync batch succeeded" "All configured accounts completed successfully at $(date '+%Y-%m-%d %H:%M:%S'): $successes." || notification_failures=1
fi
if [ "$NOTIFY_MODE" = "failure" ] && [ "$failures" -gt 0 ]; then
send_notification "IMAP sync batch failed" "Sync failed for account(s) $failed_accounts at $(date '+%Y-%m-%d %H:%M:%S'). Check ${LOG_DIR}/accountN.log for details." || notification_failures=1
fi
log "=== Sync cycle complete (success: ${successes:-none}; failed: ${failed_accounts:-none}) ==="
[ "$failures" -eq 0 ] && [ "$notification_failures" -eq 0 ]
}
start_heartbeat() {
echo "$(date +%s)" > "$HEARTBEAT_FILE"
while true; do
echo "$(date +%s)" > "$HEARTBEAT_FILE"
sleep 60
done &
HEARTBEAT_PID=$!
}
run_cron_scheduler() {
local wrapper="${PID_DIR}/cron-sync-all.sh"
local crontab_file="${PID_DIR}/sync.crontab"
# Cron starts commands with a minimal environment. Preserve this container's
# configuration in a root-only Bash wrapper so arbitrary ACCOUNT_N_* values work.
{
printf '#!/bin/bash\n'
export -p
printf 'exec /sync.sh sync-all\n'
} > "$wrapper"
chmod 700 "$wrapper"
printf '%s %s >> /proc/1/fd/1 2>&1\n' "$SYNC_CRON" "$wrapper" > "$crontab_file"
if ! crontab "$crontab_file"; then
log "Configuration error: SYNC_CRON is not a valid cron expression: $SYNC_CRON"
return 1
fi
log "Starting cron schedule: $SYNC_CRON"
cron -f &
CRON_PID=$!
wait "$CRON_PID"
}
run_once() {
log "Running in ONCE mode"
local status=0
sync_all || status=$?
log "One-shot sync complete"
return "$status"
}
run_daemon() {
log "Starting daemon mode (accounts: $NUM_ACCOUNTS)"
start_heartbeat
trap 'kill "${HEARTBEAT_PID:-}" "${CRON_PID:-}" 2>/dev/null || true' EXIT
if [ -n "$SYNC_CRON" ]; then
run_cron_scheduler
return
fi
while true; do
sync_all || true
log "Sleeping ${SYNC_INTERVAL}s before the next sync cycle"
sleep "$SYNC_INTERVAL"
done
}
case "${1:-}" in
sync)
shift
validate_settings
sync_account "${1:-1}"
;;
sync-all)
validate_settings
sync_all
;;
authorize)
shift
i="${1:-1}"
src_user="$(get_var "ACCOUNT_${i}_SOURCE_USER")"
src_token="$(get_var "ACCOUNT_${i}_SOURCE_TOKEN")"
src_provider="$(get_var "ACCOUNT_${i}_SOURCE_PROVIDER")"
validate_settings
if ! provider="$(source_oauth_provider "$src_provider")" || [ -z "$src_user" ] || [ -z "$src_token" ]; then
log "Account $i: authorization requires a supported OAuth2 provider, source user, and token path"
exit 1
fi
log "Manual authorize: $src_user"
exec oauth2_imap --startover --provider "$provider" --application imapsync --token_file "$src_token" "$src_user"
;;
shell|bash|sh)
exec /bin/bash
;;
imapsync)
shift
exec imapsync "$@"
;;
*)
validate_settings
if [ "$ONCE" = "true" ]; then
run_once
else
run_daemon
fi
;;
esac