Initial commit: Multi-account IMAP sync with OAuth2 support
This commit is contained in:
@@ -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
|
||||
@@ -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=[email protected]
|
||||
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=[email protected]
|
||||
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=[email protected]
|
||||
ACCOUNT_2_SOURCE_AUTH=password
|
||||
ACCOUNT_2_SOURCE_PASS=your_source_password
|
||||
|
||||
ACCOUNT_2_DEST_HOST=mail.hoelee.com
|
||||
ACCOUNT_2_DEST_USER=[email protected]
|
||||
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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
+26
@@ -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
|
||||
*~
|
||||
+69
@@ -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 <[email protected]>"
|
||||
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"]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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=[email protected]
|
||||
ACCOUNT_3_SOURCE_AUTH=password
|
||||
ACCOUNT_3_SOURCE_PASS=source_password
|
||||
|
||||
ACCOUNT_3_DEST_HOST=mail.example.net
|
||||
ACCOUNT_3_DEST_USER=[email protected]
|
||||
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=[email protected]
|
||||
SMTP_PASS=your_smtp_password
|
||||
SMTP_FROM=[email protected]
|
||||
SMTP_TO=[email protected],[email protected]
|
||||
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=[email protected]
|
||||
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=[email protected]
|
||||
ACCOUNT_1_DEST_PASS=your_mailcow_password
|
||||
```
|
||||
|
||||
### Example 2: Gmail → Mailcow (OAuth2)
|
||||
|
||||
```env
|
||||
ACCOUNT_1_SOURCE_HOST=imap.gmail.com
|
||||
ACCOUNT_1_SOURCE_USER=[email protected]
|
||||
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=[email protected]
|
||||
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=[email protected]
|
||||
ACCOUNT_1_DEST_PASS=password1
|
||||
|
||||
# Account 2: Gmail -> Mailcow
|
||||
ACCOUNT_2_SOURCE_HOST=imap.gmail.com
|
||||
ACCOUNT_2_SOURCE_USER=[email protected]
|
||||
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=[email protected]
|
||||
ACCOUNT_2_DEST_PASS=password2
|
||||
|
||||
# Account 3: Legacy IMAP -> Mailcow (password auth)
|
||||
ACCOUNT_3_SOURCE_HOST=imap.oldprovider.com
|
||||
ACCOUNT_3_SOURCE_USER=[email protected]
|
||||
ACCOUNT_3_SOURCE_AUTH=password
|
||||
ACCOUNT_3_SOURCE_PASS=oldpassword
|
||||
ACCOUNT_3_DEST_HOST=mail.hoelee.com
|
||||
ACCOUNT_3_DEST_USER=[email protected]
|
||||
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 [email protected] \
|
||||
--oauthaccesstoken1 /tokens/you_hotmail.token \
|
||||
--host2 mail.hoelee.com --user2 [email protected] \
|
||||
--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=[email protected] \
|
||||
-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=[email protected] \
|
||||
-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
|
||||
+24
@@ -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
|
||||
@@ -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"
|
||||
@@ -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