Initial commit: Multi-account IMAP sync with OAuth2 support
This commit is contained in:
@@ -0,0 +1,422 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user