From 482298371eecb475a8ae82ccc65bb734d3905c17 Mon Sep 17 00:00:00 2001 From: hoelee Date: Mon, 10 Aug 2026 05:54:52 +0800 Subject: [PATCH] Initial commit: Multi-account IMAP sync with OAuth2 support --- .dockerignore | 28 ++ .env.example | 93 ++++ .github/ISSUE_TEMPLATE/bug_report.md | 35 ++ .github/ISSUE_TEMPLATE/feature_request.md | 20 + .github/workflows/docker.yml | 73 +++ .gitignore | 26 ++ Dockerfile | 69 +++ LICENSE | 21 + Makefile | 73 +++ README.md | 525 ++++++++++++++++++++++ SECURITY.md | 24 + docker-compose.yml | 27 ++ sync.sh | 422 +++++++++++++++++ 13 files changed, 1436 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/workflows/docker.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 docker-compose.yml create mode 100644 sync.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0706733 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +# Git +.git +.gitignore + +# Docker +docker-compose.yml +Dockerfile +.dockerignore + +# Documentation +README.md +LICENSE + +# Tokens (sensitive) +tokens/ +*.token + +# Logs +logs/ +*.log + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d03398c --- /dev/null +++ b/.env.example @@ -0,0 +1,93 @@ +# ============================================================ +# Email Sync OAuth2 - Docker Compose Configuration +# ============================================================ +# Copy this file to .env and edit your settings. +# ============================================================ + +# ------------------------------------------------------------ +# Global settings +# ------------------------------------------------------------ + +# Number of accounts to sync +NUM_ACCOUNTS=2 + +# Sync interval in seconds (default: 21600 = 6 hours). +# Used when SYNC_CRON is empty. +SYNC_INTERVAL=21600 + +# Optional standard five-field cron expression. When set, it overrides +# SYNC_INTERVAL. Example: 0 */6 * * * (every six hours) +SYNC_CRON= + +# Run once and exit (true/false) +ONCE=false + +# Token and log directories inside container +TOKEN_DIR=/tokens +LOG_DIR=/var/log/imapsync + +# ------------------------------------------------------------ +# Account 1: Hotmail / Office 365 -> Mailcow (OAuth2) +# ------------------------------------------------------------ + +ACCOUNT_1_SOURCE_HOST=outlook.office365.com +ACCOUNT_1_SOURCE_USER=user1@hotmail.com +ACCOUNT_1_SOURCE_AUTH=oauth2 +ACCOUNT_1_SOURCE_PROVIDER=office365 +ACCOUNT_1_SOURCE_TOKEN=/tokens/user1_hotmail.token + +ACCOUNT_1_DEST_HOST=mail.hoelee.com +ACCOUNT_1_DEST_USER=user1@mailcow.com +ACCOUNT_1_DEST_PASS=your_mailcow_password + +# Delete emails on destination that don't exist on source (true/false) +ACCOUNT_1_DELETE=false + +# Dry run - log what would happen without actually syncing (true/false) +ACCOUNT_1_DRY_RUN=false + +# Extra imapsync options (optional) +ACCOUNT_1_EXTRA_OPTS= + +# ------------------------------------------------------------ +# Account 2: Any standard IMAP source (password authentication) +# ------------------------------------------------------------ + +ACCOUNT_2_SOURCE_HOST=imap.example.com +ACCOUNT_2_SOURCE_USER=user2@example.com +ACCOUNT_2_SOURCE_AUTH=password +ACCOUNT_2_SOURCE_PASS=your_source_password + +ACCOUNT_2_DEST_HOST=mail.hoelee.com +ACCOUNT_2_DEST_USER=user2@mailcow.com +ACCOUNT_2_DEST_PASS=your_mailcow_password + +ACCOUNT_2_DELETE=false +ACCOUNT_2_DRY_RUN=false +ACCOUNT_2_EXTRA_OPTS= + +# ------------------------------------------------------------ +# Account 3+: copy an account block and increase NUM_ACCOUNTS. +# All ACCOUNT_N_* variables from .env are passed into the container. +# ------------------------------------------------------------ + +# ------------------------------------------------------------ +# Optional SMTP notifications +# ------------------------------------------------------------ + +# none | batch_success | account_success | failure +# batch_success: one email after every fully successful sync batch +# account_success: one email for every successful account +# failure: one email when one or more accounts fail in a batch +NOTIFY_MODE=none + +# Required only when NOTIFY_MODE is not none +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +SMTP_FROM= +# One or more comma-separated recipients +SMTP_TO= +# starttls (port 587), tls (implicit TLS, usually port 465), or none +SMTP_TLS=starttls diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..09d67c3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,35 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[BUG] " +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Which account (source/destination)? +2. Configuration used (redact passwords!) +3. Command run + +**Expected behavior** +What you expected to happen. + +**Logs** +``` +Relevant log output from logs/accountN.log or container logs +``` + +**Environment:** + - Docker version: + - Host OS: + - Source email provider: + - Destination IMAP server: + - Number of accounts: + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..8403b86 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[FEATURE] " +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..a4c8ca6 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,73 @@ +name: Build & Push to Docker Hub + +on: + push: + branches: [main] + tags: ["v*"] + pull_request: + branches: [main] + +env: + REGISTRY: docker.io + IMAGE_NAME: ${{ vars.DOCKERHUB_USERNAME }}/email-sync-oauth2 + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + if: github.event_name != "pull_request" + uses: docker/login-action@v3 + with: + username: ${{ vars.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: ${{ github.event_name != "pull_request" }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Run healthcheck test + if: github.event_name == 'pull_request' + run: | + set -euo pipefail + trap 'docker compose down -v' EXIT + docker compose config --quiet + docker compose up -d + for _ in $(seq 1 18); do + status="$(docker inspect --format='{{.State.Health.Status}}' email-sync-oauth2)" + if [ "$status" = "healthy" ]; then + docker compose exec -T imapsync bash -n /sync.sh + exit 0 + fi + sleep 10 + done + docker compose logs + echo "Container did not become healthy" >&2 + exit 1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ba31cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Logs +logs/ +*.log + +# Tokens (sensitive - never commit) +tokens/ +*.token + +# Environment variables (may contain passwords) +.env + +# Docker +.docker/ + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db + +# Temp files +*.tmp +*.swp +*~ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c47c5af --- /dev/null +++ b/Dockerfile @@ -0,0 +1,69 @@ +FROM alpine/git:2.47.2@sha256:062a01ad7a0eb17cff382bc5e26086b4d710e56dfdfdf001109a49b6d9bd378c AS imapsync-source + +ARG IMAPSYNC_REV=93654c6025ff7814f983ab74dd300f9bed9282d9 +RUN git clone https://github.com/imapsync/imapsync.git /src \ + && git -C /src checkout "$IMAPSYNC_REV" \ + && test "$(git -C /src rev-parse HEAD)" = "$IMAPSYNC_REV" + +FROM perl:5.38-slim + +LABEL maintainer="Hoelee " +LABEL description="Multi-account IMAP sync with OAuth2 support for Office 365, Hotmail, Outlook.com, and Gmail" + +# Install imapsync + dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + libauthen-ntlm-perl \ + libdbd-sqlite3-perl \ + libdist-checkconflicts-perl \ + libfile-copy-recursive-perl \ + libemail-address-perl \ + libhttp-daemon-ssl-perl \ + libio-compress-perl \ + libio-socket-inet6-perl \ + libio-socket-ssl-perl \ + libio-tee-perl \ + libjson-pp-perl \ + libmail-imapclient-perl \ + libmodule-runtime-perl \ + libnet-dbus-perl \ + libnet-dns-perl \ + libpackage-stash-perl \ + libpar-dist-perl \ + libproc-processtable-perl \ + libproc-simple-perl \ + libregexp-common-perl \ + libssl-dev \ + libtest-fatal-perl \ + libtest-mock-guard-perl \ + libtest-nowarnings-perl \ + libtest-pod-perl \ + libtest-requires-perl \ + libtest-simple-perl \ + libunicode-string-perl \ + liburi-perl \ + libwww-perl \ + cron \ + msmtp \ + netcat-openbsd \ + procps \ + && rm -rf /var/lib/apt/lists/* + +# Install one immutable, verified upstream revision of imapsync and its current +# OAuth helper. The old helper URLs no longer exist upstream. +COPY --from=imapsync-source /src/imapsync /usr/local/bin/imapsync +COPY --from=imapsync-source /src/oauth2/oauth2_imap/oauth2_imap /usr/local/bin/oauth2_imap +RUN chmod +x /usr/local/bin/imapsync /usr/local/bin/oauth2_imap + +# Copy entrypoint +COPY sync.sh /sync.sh +RUN chmod +x /sync.sh + +# Health is liveness plus notification delivery. Sync freshness is exposed by +# accountN.last_success / accountN.last_failure files for the chosen schedule. +HEALTHCHECK --interval=5m --timeout=10s --start-period=60s --retries=3 \ + CMD test -f "${LOG_DIR:-/var/log/imapsync}/.heartbeat" && \ + test ! -f "${LOG_DIR:-/var/log/imapsync}/.notification_failed" && \ + test "$(cat "${LOG_DIR:-/var/log/imapsync}/.heartbeat")" -gt "$(date -d '5 minutes ago' +%s)" || \ + { echo "Health check failed: daemon heartbeat is stale or SMTP notification delivery failed. Check container logs."; exit 1; } + +ENTRYPOINT ["/sync.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..544f1e5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Hoelee Enterprise + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cf17e14 --- /dev/null +++ b/Makefile @@ -0,0 +1,73 @@ +.PHONY: build up down logs status health authorize sync sync-all shell clean + +# Build the Docker image +build: + docker compose build + +# Start the sync daemon +up: + docker compose up -d + @echo "Container started. Use 'make logs' to follow logs." + +# Stop the container +down: + docker compose down + +# Follow logs +logs: + docker logs -f email-sync-oauth2 + +# Show status of all accounts +status: + @echo "=== Container Status ===" + @docker inspect --format='Status: {{.State.Status}}' email-sync-oauth2 2>/dev/null || echo "Container not running" + @echo "" + @echo "=== Health Status ===" + @docker inspect --format='Health: {{.State.Health.Status}}' email-sync-oauth2 2>/dev/null || echo "No healthcheck" + @echo "" + @echo "=== Last Sync Times ===" + @for f in logs/*.last_success; do \ + [ -f "$$f" ] && echo "$$(basename $$f .last_success): $$(date -d @$$(cat $$f) 2>/dev/null || echo 'unknown')"; \ + done + @echo "" + @echo "=== Last Failures ===" + @for f in logs/*.last_failure; do \ + [ -f "$$f" ] && echo "$$(basename $$f .last_failure): $$(date -d @$$(cat $$f) 2>/dev/null || echo 'unknown')"; \ + done + +# Check health +health: + @docker inspect --format='{{json .State.Health}}' email-sync-oauth2 | jq + +# Authorize account (usage: make authorize ACCOUNT=1) +authorize: + @if [ -z "$(ACCOUNT)" ]; then \ + echo "Usage: make authorize ACCOUNT=1"; \ + exit 1; \ + fi + docker compose run --rm imapsync authorize $(ACCOUNT) + +# Force sync account (usage: make sync ACCOUNT=1) +sync: + @if [ -z "$(ACCOUNT)" ]; then \ + echo "Usage: make sync ACCOUNT=1"; \ + exit 1; \ + fi + docker compose exec imapsync sync.sh sync $(ACCOUNT) + +# Force a full batch now (all configured accounts) +sync-all: + docker compose exec imapsync sync.sh sync-all + +# Enter container shell +shell: + docker compose exec imapsync sync.sh shell + +# Clean up +clean: + docker compose down -v + rm -rf logs/*.log logs/*.last_* logs/.heartbeat logs/.notification_failed + +# One-shot sync (build, run once, exit) +once: + ONCE=true docker compose up --build --abort-on-container-exit diff --git a/README.md b/README.md new file mode 100644 index 0000000..a2e058c --- /dev/null +++ b/README.md @@ -0,0 +1,525 @@ +# Email Sync OAuth2 + +Dockerized multi-account IMAP synchronization tool with **OAuth2 support** for Office 365, Hotmail, Outlook.com, and Gmail. + +## Problem This Solves + +Microsoft retired **Basic Authentication** for Exchange Online (IMAP/SMTP), and Google is pushing XOAUTH2 as the only authentication method. This breaks traditional IMAP sync tools that rely on username/password. + +**Mailcow's built-in sync job** and many other tools don't support OAuth2, making it impossible to migrate emails from Hotmail/Outlook.com/Gmail into self-hosted mail servers. + +This project solves that problem by combining: +- **[imapsync](https://imapsync.lamiral.info/)** — battle-tested IMAP migration tool +- **imapsync's OAuth2 helper** — token authorization and refresh for Microsoft and Google accounts +- **Docker** — portable, reproducible, easy to deploy + +## Features + +| Feature | Status | +|---------|--------| +| Office 365 / Hotmail / Outlook.com sync | ✅ OAuth2 | +| Gmail / Google Workspace sync | ✅ OAuth2 | +| Any IMAP server (password auth) | ✅ | +| Multiple accounts in one container | ✅ Unlimited | +| Incremental sync (only new emails) | ✅ | +| Folder structure preservation | ✅ | +| Scheduled sync via interval | ✅ | +| Healthcheck endpoint | ✅ | +| One-shot sync mode | ✅ | +| Dry run mode | ✅ | +| Delete propagation (`--delete2`) | ✅ | +| Custom imapsync flags per account | ✅ | +| Persistent token storage | ✅ | +| Automatic token refresh | ✅ | +| SMTP sync notifications | ✅ Optional | +| Interval or cron scheduling | ✅ | + +## Authentication Requirements + +| Provider | Auth Method | Token Lifetime | Auto-Refresh | +|----------|-------------|----------------|--------------| +| **Hotmail / Outlook.com / Personal Microsoft** | OAuth2 | 90 days (hard limit) | ✅ until day 90 | +| **Office 365 / Microsoft 365 (Work/School)** | OAuth2 | 90 days (configurable by admin) | ✅ indefinitely | +| **Gmail / Google Workspace** | OAuth2 | 7 days (testing) / 90 days (verified) | ✅ | +| **Other IMAP** | Password | N/A | N/A | + +> ⚠️ **Personal Microsoft accounts** (hotmail.com, outlook.com, live.com) have a hard 90-day refresh token limit. You must reauthorize every ~80 days. Work/school accounts can have indefinite tokens if configured by Azure AD admin. + +--- + +## Quick Start + +### 1. Clone and configure + +```bash +git clone https://github.com/yourusername/email-sync-oauth2.git +cd email-sync-oauth2 +cp .env.example .env +nano .env # edit your settings +``` + +### 2. First-time OAuth authorization (requires browser) + +Run the container interactively for each account: + +```bash +# For Office 365 / Hotmail (Account 1) +docker compose run --rm imapsync authorize 1 +# → Paste URL in browser, log in to Microsoft, click "Allow" +# → Token saved to ./tokens/user1_hotmail.token + +# For Account 2 +docker compose run --rm imapsync authorize 2 +# → Same process for this account +``` + +### 3. Start syncing + +```bash +docker compose up -d +``` + +### 4. Monitor + +```bash +docker logs -f email-sync-oauth2 +tail -f logs/account1.log +``` + +--- + +## Configuration + +All configuration is done through environment variables. Edit `.env` or pass directly to `docker-compose.yml`. + +### Global Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `NUM_ACCOUNTS` | `1` | Number of accounts to sync | +| `SYNC_INTERVAL` | `21600` (6h) | Seconds between sync cycles | +| `SYNC_CRON` | empty | Optional five-field cron expression; overrides `SYNC_INTERVAL` | +| `ONCE` | `false` | Run once and exit (no daemon) | +| `TOKEN_DIR` | `/tokens` | Directory inside container for token files | +| `LOG_DIR` | `/var/log/imapsync` | Directory inside container for log files | + +### Per-Account Variables (ACCOUNT_1_, ACCOUNT_2_, ...) + +| Variable | Required | Description | +|----------|----------|-------------| +| `ACCOUNT_N_SOURCE_HOST` | ✅ | Source IMAP server (e.g., `outlook.office365.com`) | +| `ACCOUNT_N_SOURCE_USER` | ✅ | Source email address | +| `ACCOUNT_N_SOURCE_AUTH` | ✅ | `oauth2` or `password` | +| `ACCOUNT_N_SOURCE_PROVIDER` | If OAuth2 | `office365`, `hotmail`, `outlook`, `gmail`, `google` | +| `ACCOUNT_N_SOURCE_TOKEN` | If OAuth2 | Path to token file (e.g., `/tokens/account1.token`) | +| `ACCOUNT_N_SOURCE_PASS` | If password | Source password | +| `ACCOUNT_N_DEST_HOST` | ✅ | Destination IMAP server (e.g., `mail.hoelee.com`) | +| `ACCOUNT_N_DEST_USER` | ✅ | Destination email/username | +| `ACCOUNT_N_DEST_PASS` | ✅ | Destination password | +| `ACCOUNT_N_DELETE` | No | `true` = delete emails on destination not on source | +| `ACCOUNT_N_DRY_RUN` | No | `true` = log only, don't actually sync | +| `ACCOUNT_N_EXTRA_OPTS` | No | Additional imapsync flags (optional) | + +### Password authentication is still supported + +OAuth2 is required only for providers that require it. Any normal IMAP source +can still use a password by setting `ACCOUNT_N_SOURCE_AUTH=password` and +`ACCOUNT_N_SOURCE_PASS`. The destination currently uses password +authentication for every provider. + +```env +ACCOUNT_3_SOURCE_HOST=imap.example.com +ACCOUNT_3_SOURCE_USER=source@example.com +ACCOUNT_3_SOURCE_AUTH=password +ACCOUNT_3_SOURCE_PASS=source_password + +ACCOUNT_3_DEST_HOST=mail.example.net +ACCOUNT_3_DEST_USER=destination@example.net +ACCOUNT_3_DEST_PASS=destination_password +``` + +### Scheduling + +By default, a batch runs immediately when the container starts and then every +six hours (`SYNC_INTERVAL=21600`). Set the interval in seconds to use a +different fixed frequency: + +```env +SYNC_INTERVAL=3600 +SYNC_CRON= +``` + +For calendar-based scheduling, set `SYNC_CRON` to a standard five-field cron +expression. It overrides `SYNC_INTERVAL`. + +```env +# Every six hours, on the hour +SYNC_CRON=0 */6 * * * + +# Every day at 02:30 +# SYNC_CRON=30 2 * * * +``` + +All `ACCOUNT_N_*` variables in `.env` are passed to the container. To add an +account, copy an account block, use the next number, and increase +`NUM_ACCOUNTS`—there is no two-account Compose limit. + +### SMTP notifications + +Notifications are off by default. Set `NOTIFY_MODE` to one of these values: + +| Value | When an email is sent | +|---|---| +| `batch_success` | Once after every batch where every configured account succeeds | +| `account_success` | Once for each account that succeeds | +| `failure` | Once after a batch where one or more accounts fail | + +Configure SMTP whenever notifications are enabled: + +```env +NOTIFY_MODE=batch_success +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=alerts@example.com +SMTP_PASS=your_smtp_password +SMTP_FROM=alerts@example.com +SMTP_TO=admin@example.com,ops@example.com +SMTP_TLS=starttls +``` + +`SMTP_TLS` accepts `starttls` (normally port 587), `tls` (implicit TLS, +normally port 465), or `none`. If a notification cannot be delivered, the +container writes the reason to `logs/notification.log`, logs the failure to the +container output, and becomes unhealthy until a notification sends +successfully. + +--- + +## Complete Examples + +### Example 1: Hotmail → Mailcow (OAuth2) + +```env +NUM_ACCOUNTS=1 +SYNC_INTERVAL=21600 + +ACCOUNT_1_SOURCE_HOST=outlook.office365.com +ACCOUNT_1_SOURCE_USER=you@hotmail.com +ACCOUNT_1_SOURCE_AUTH=oauth2 +ACCOUNT_1_SOURCE_PROVIDER=office365 +ACCOUNT_1_SOURCE_TOKEN=/tokens/you_hotmail.token + +ACCOUNT_1_DEST_HOST=mail.hoelee.com +ACCOUNT_1_DEST_USER=you@mailcow.com +ACCOUNT_1_DEST_PASS=your_mailcow_password +``` + +### Example 2: Gmail → Mailcow (OAuth2) + +```env +ACCOUNT_1_SOURCE_HOST=imap.gmail.com +ACCOUNT_1_SOURCE_USER=you@gmail.com +ACCOUNT_1_SOURCE_AUTH=oauth2 +ACCOUNT_1_SOURCE_PROVIDER=gmail +ACCOUNT_1_SOURCE_TOKEN=/tokens/you_gmail.token +``` + +### Example 3: Multiple accounts + +```env +NUM_ACCOUNTS=3 + +# Account 1: Hotmail -> Mailcow +ACCOUNT_1_SOURCE_HOST=outlook.office365.com +ACCOUNT_1_SOURCE_USER=alice@hotmail.com +ACCOUNT_1_SOURCE_AUTH=oauth2 +ACCOUNT_1_SOURCE_PROVIDER=office365 +ACCOUNT_1_SOURCE_TOKEN=/tokens/alice_hotmail.token +ACCOUNT_1_DEST_HOST=mail.hoelee.com +ACCOUNT_1_DEST_USER=alice@mailcow.com +ACCOUNT_1_DEST_PASS=password1 + +# Account 2: Gmail -> Mailcow +ACCOUNT_2_SOURCE_HOST=imap.gmail.com +ACCOUNT_2_SOURCE_USER=bob@gmail.com +ACCOUNT_2_SOURCE_AUTH=oauth2 +ACCOUNT_2_SOURCE_PROVIDER=gmail +ACCOUNT_2_SOURCE_TOKEN=/tokens/bob_gmail.token +ACCOUNT_2_DEST_HOST=mail.hoelee.com +ACCOUNT_2_DEST_USER=bob@mailcow.com +ACCOUNT_2_DEST_PASS=password2 + +# Account 3: Legacy IMAP -> Mailcow (password auth) +ACCOUNT_3_SOURCE_HOST=imap.oldprovider.com +ACCOUNT_3_SOURCE_USER=carol@oldprovider.com +ACCOUNT_3_SOURCE_AUTH=password +ACCOUNT_3_SOURCE_PASS=oldpassword +ACCOUNT_3_DEST_HOST=mail.hoelee.com +ACCOUNT_3_DEST_USER=carol@mailcow.com +ACCOUNT_3_DEST_PASS=password3 +``` + +### Example 4: One-shot sync (cron-friendly) + +```bash +# .env +ONCE=true +NUM_ACCOUNTS=1 + +# Add to crontab: +# 0 */6 * * * cd /opt/email-sync-oauth2 && docker compose up --abort-on-container-exit +``` + +--- + +## Makefile Commands (Optional) + +A `Makefile` is included for convenience: + +```bash +# Build and start +make build +make up + +# Check status and health +make status +make health + +# Authorize an account (requires browser) +make authorize ACCOUNT=1 + +# Force sync now +make sync ACCOUNT=1 + +# Force a batch for every configured account +make sync-all + +# Follow logs +make logs + +# Enter container shell +make shell + +# Run once and exit (one-shot mode) +make once + +# Clean up +make clean +``` + +--- + +## Manual Operations + +### Force sync a specific account immediately + +```bash +docker compose exec imapsync sync.sh sync 1 +``` + +### Reauthorize OAuth tokens + +```bash +docker compose run --rm imapsync authorize 1 +``` + +### Enter container shell + +```bash +docker compose exec imapsync sync.sh shell +``` + +### Run imapsync manually with custom flags + +```bash +docker compose exec imapsync sync.sh imapsync \ + --host1 outlook.office365.com --user1 you@hotmail.com \ + --oauthaccesstoken1 /tokens/you_hotmail.token \ + --host2 mail.hoelee.com --user2 you@mailcow.com \ + --password2 yourpassword \ + --ssl1 --ssl2 --dry +``` + +--- + +## Monitoring & Healthcheck + +The container includes a Docker **healthcheck** based on a heartbeat mechanism: + +- A background process writes a heartbeat file every **60 seconds** +- Docker checks if the heartbeat is less than **5 minutes old** +- If no heartbeat, the container is marked unhealthy and can be auto-restarted by orchestrators + +It also becomes unhealthy if a configured SMTP notification fails to send. The +reason is logged in `logs/notification.log` and in the container output. + +The heartbeat confirms that the sync daemon is running; it does not mean every +mailbox was able to authenticate or sync. If an OAuth refresh token expires or +is revoked, the container log includes a reauthorization command, for example: + +```text +Account 1: OAuth2 authorization is expired, revoked, or rejected by office365. Reauthorize with: docker compose run --rm imapsync authorize 1 +``` + +After completing the browser authorization, start that account immediately with: + +```bash +docker compose exec imapsync sync.sh sync 1 +``` + +```bash +# Check health status +docker inspect --format='{{.State.Health.Status}}' email-sync-oauth2 + +# View healthcheck logs (useful for debugging) +docker inspect --format='{{json .State.Health}}' email-sync-oauth2 | jq + +# Watch live heartbeat +docker exec email-sync-oauth2 cat /var/log/imapsync/.heartbeat +``` + +### Log files + +``` +./logs/ +├── .heartbeat # Healthcheck timestamp (updated every 60s) +├── account1.log # Full imapsync output for account 1 +├── account1.last_success # Unix timestamp of last successful sync +├── account1.last_failure # Unix timestamp of last failed sync +├── account2.log +├── notification.log # SMTP notification delivery output +├── .notification_failed # Present only while notification delivery is failing +└── ... +``` + +### Check sync status + +```bash +# When did account 1 last succeed? +date -d @"$(cat logs/account1.last_success)" + +# When did it last fail? +date -d @"$(cat logs/account1.last_failure)" +``` + +### Reauthorize reminders + +For personal Microsoft accounts (90-day token limit), set a calendar reminder or monitoring check: + +```bash +# Check if reauthorization is needed (token file older than 80 days) +find ./tokens -name "*.token" -mtime +80 -ls +``` + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ Docker Container │ +│ │ +│ ┌──────────────┐ ┌──────────────────────────┐ │ +│ │ sync.sh │────▶│ imapsync (per account) │ │ +│ │ (daemon) │ └──────────┬───────────────┘ │ +│ │ │ │ │ +│ │ Interval: │ ┌──────────▼───────────────┐ │ +│ │ 6h default │ │ OAuth2 token helpers │ │ +│ │ │ │ oauth2_office365_with_ │ │ +│ │ Healthcheck │ │ imap / oauth2_imap │ │ +│ └──────────────┘ └──────────┬───────────────┘ │ +│ │ │ +│ ┌──────────▼───────────────┐ │ +│ │ /tokens/ (mounted) │ │ +│ │ account1.token │ │ +│ │ account2.token │ │ +│ └──────────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────┐ │ +│ │ /var/log/imapsync/ (mounted) │ │ +│ │ account1.log, account1.last_success, etc. │ │ +│ └────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────┘ + + │ │ + ▼ ▼ +┌──────────────┐ ┌──────────────┐ +│ Source IMAP │ │ Dest IMAP │ +│ (OAuth2) │ │ (password) │ +│ outlook. │ │ mail.hoelee │ +│ office365 │ │ .com │ +└──────────────┘ └──────────────┘ +``` + +--- + +## FAQ + +**Q: Do I need to reauthorize every 90 days?** +A: Only for personal Microsoft accounts (hotmail.com, outlook.com, live.com). Work/school Microsoft 365 accounts can have indefinite tokens. Gmail tokens last 7-90 days depending on app verification status. + +**Q: Can I use this for a one-time migration?** +A: Yes! Set `ONCE=true` in `.env` and run `docker compose up`. + +**Q: Is the sync incremental?** +A: Yes. imapsync only transfers emails that don't already exist on the destination. + +**Q: Can I sync from any source to any destination?** +A: Any IMAP-compatible server. The source needs OAuth2 if it's Microsoft/Google. The destination always uses password auth. + +**Q: How many accounts can I sync?** +A: Unlimited. Just increase `NUM_ACCOUNTS` and add `ACCOUNT_N_*` variables. + +**Q: Can I use this with Synology DSM?** +A: Yes. Point Docker volume paths to `/volume1/docker/email-sync-oauth2/`. + +**Q: What if my source is Exchange with ActiveSync?** +A: ActiveSync doesn't work well with mailcow (Microsoft disabled basic auth for ActiveSync). Use IMAP with OAuth2 instead. + +--- + +## Building from Source + +```bash +docker build -t email-sync-oauth2:latest . + +docker run -d \ + --name email-sync-oauth2 \ + --restart unless-stopped \ + -e NUM_ACCOUNTS=1 \ + -e SYNC_INTERVAL=21600 \ + -e ACCOUNT_1_SOURCE_HOST=outlook.office365.com \ + -e ACCOUNT_1_SOURCE_USER=you@hotmail.com \ + -e ACCOUNT_1_SOURCE_AUTH=oauth2 \ + -e ACCOUNT_1_SOURCE_PROVIDER=office365 \ + -e ACCOUNT_1_SOURCE_TOKEN=/tokens/you_hotmail.token \ + -e ACCOUNT_1_DEST_HOST=mail.hoelee.com \ + -e ACCOUNT_1_DEST_USER=you@mailcow.com \ + -e ACCOUNT_1_DEST_PASS=yourpassword \ + -v $(pwd)/tokens:/tokens \ + -v $(pwd)/logs:/var/log/imapsync \ + email-sync-oauth2:latest +``` + +--- + +## License + +MIT License. See [LICENSE](LICENSE) for details. + +## Credits + +- [imapsync](https://imapsync.lamiral.info/) by Gilles Lamiral — the core migration engine +- [imapsync OAuth2 helper](https://github.com/imapsync/imapsync/tree/master/oauth2/oauth2_imap) — OAuth2 authorization and refresh + +--- + +## Contributing + +Pull requests welcome! Please test with your own accounts before submitting. + +1. Fork the repo +2. Create your feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..3d6431b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,24 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +| ------- | ------------------ | +| latest | :white_check_mark: | + +## Reporting a Vulnerability + +If you discover a security vulnerability, please report it by opening a GitHub issue with the `security` label. Do NOT open a public issue for security flaws that could expose user data. + +We will acknowledge your report within 48 hours and work on a fix. + +## Security Considerations for Users + +⚠️ **This tool handles OAuth tokens and email passwords. Please follow these security best practices:** + +1. **Never commit `.env` files** — they contain passwords and token paths +2. **Protect the `tokens/` directory** — OAuth tokens grant access to your email +3. **Use file permissions**: `chmod 600 tokens/*.token` +4. **Never share log files** — they may contain email content snippets +5. **Rotate tokens** by deleting the token file and reauthorizing periodically +6. **Use a dedicated Azure App** — don't reuse OAuth credentials from other apps diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cf53bd2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +# ============================================================ +# Email Sync OAuth2 - Docker Compose +# ============================================================ +# Multi-account IMAP sync with OAuth2 support for +# Office 365, Hotmail, Outlook.com, and Gmail. +# ============================================================ + +services: + imapsync: + build: + context: . + dockerfile: Dockerfile + container_name: email-sync-oauth2 + restart: unless-stopped + # Pass every ACCOUNT_N_* entry from .env, not only accounts 1 and 2. + env_file: + - path: .env + required: false + volumes: + - ./tokens:${TOKEN_DIR:-/tokens} + - ./logs:${LOG_DIR:-/var/log/imapsync} + # Healthcheck is defined in Dockerfile + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" diff --git a/sync.sh b/sync.sh new file mode 100644 index 0000000..0e1ddb0 --- /dev/null +++ b/sync.sh @@ -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