423 lines
15 KiB
Bash
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
|