One-way sync tool for Windows Sticky Notes to iOS Notes app via Microsoft Graph API and Gmail IMAP.
Microsoft Cloud (Sticky Notes)
| read via Graph API
sync_notes.py
| push via IMAP
Gmail "Notes" folder
| auto-sync
iOS Notes app
Additions (modifications, deletions) in Sticky Notes are reflected in iOS Notes on the next sync.
- Python 3.10+
- A Microsoft account with Sticky Notes
- A dedicated Gmail account used only for this sync (see Security β strongly recommended, not your primary Gmail)
- That dedicated Gmail account configured in iOS Settings > Mail with the Notes toggle enabled
git clone https://github.com/peter2380123/sticky-notes-sync-win2ios.git
cd sync_ios_win_sticky_notes
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements-lock.txt # reproducible, fully pinned
# or: pip install -r requirements.txt # top-level deps onlyAfter cloning, install the pre-commit hook that blocks accidental commits of secret files:
cp scripts/pre-commit-hook.sh .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit- Go to Azure Portal > App registrations > New registration
- Name:
Sticky Notes Sync(or anything) - Supported account types: Personal Microsoft accounts only
- Redirect URI: leave blank
- Click Register
- Go to Authentication > Add a platform > Mobile and desktop applications
- Check
https://login.microsoftonline.com/common/oauth2/nativeclient> Configure - Scroll to Advanced settings > set Allow public client flows to Yes > Save
- Go to API permissions > Add a permission > Microsoft Graph > Delegated permissions > add
Mail.Read - Copy the Application (client) ID from the overview page
A Gmail app password grants full IMAP and SMTP access to the account β reading all mail and sending as the account owner. Do not use your primary Gmail for this. Create a throwaway one instead.
- In a fresh browser session, sign up at accounts.google.com/signup (e.g.
yourname.stickynotes@gmail.com). - Go to myaccount.google.com/security and enable 2-Step Verification (required before you can create app passwords).
- Don't use this account for anything else β no mail, no contacts, no signups. It exists purely as a sync relay.
- Signed into the dedicated account, go to myaccount.google.com/apppasswords.
- Create a new app password named e.g.
Sticky Notes Sync. - Copy the 16-character password (spaces are cosmetic; either form works).
cp config.example.json config.json
chmod 600 config.jsonEdit config.json:
{
"gmail_address": "yourname.stickynotes@gmail.com",
"gmail_app_password": "xxxxxxxxxxxxxxxx",
"ms_client_id": "your-azure-app-client-id"
}The chmod 600 restricts the file to your user only β other local accounts cannot read your app password.
On your iPhone:
- Go to Settings > Mail > Accounts > Add Account > Google
- Sign in with the dedicated Gmail account (using its regular password + 2FA code β not the app password; iOS uses OAuth for its own Mail client)
- On the toggle screen, turn Notes ON. Turn Mail/Contacts/Calendar OFF unless you want them.
- Optional: Settings > Notes > Default Account β keep as iCloud so your personal notes still default there.
Synced notes appear in the Notes app under a section labeled with the dedicated Gmail address.
source .venv/bin/activate
python sync_notes.py loginFollow the on-screen instructions to sign in via browser. The token is cached locally in ms_token_cache.json and auto-refreshes for ~90 days. Restrict this file too:
chmod 600 ms_token_cache.jsonsource .venv/bin/activate
# Sync notes once
python sync_notes.py
# List notes without connecting to IMAP
python sync_notes.py --list
# Preview what would sync (including deletions)
python sync_notes.py --dry-run
# Re-sync all notes, ignoring previous state
python sync_notes.py --force
# Run as a daemon, polling every 2 minutes
python sync_notes.py watch
# Custom polling interval (seconds)
python sync_notes.py watch --interval 60
# Re-authenticate with Microsoft
python sync_notes.py login
# Use local SQLite DB instead of Graph API (fallback)
python sync_notes.py --source local- Authenticates with Microsoft via device code flow (MSAL).
- Reads Sticky Notes from the Graph API (
/me/mailfolders/notes/messages). - Compares against local sync state to find new, modified, and deleted notes.
- Pushes additions/updates to Gmail's IMAP
Notesfolder as RFC 2822 messages with theX-Uniform-Type-Identifier: com.apple.mail-noteheader. Each message carries anX-Sticky-Note-IDheader used to locate it later for updates or deletion. - Expunges the IMAP message for any note that has disappeared from Sticky Notes since the last sync.
- iOS automatically syncs the Gmail Notes folder to the Notes app.
The watch command uses Microsoft Graph delta queries to efficiently detect changes. Instead of fetching all notes each cycle, it asks the API "what changed since last time?" β when nothing changed, the response is nearly empty. A non-empty delta triggers a full sync, which picks up additions, edits, and deletions in one pass.
The windows/ directory contains scripts to register the watch daemon as a Windows Task Scheduler job so it starts on login.
- Open PowerShell as your normal user (not admin) and run:
powershell -ExecutionPolicy Bypass -File windows\setup_task.ps1
- The task is named
StickyNotesSyncand runswindows\run_watch.vbs, which invokes the daemon inside WSL without a visible console. - Logs are written to
watch.login the project root. The daemon auto-rotates this file when it exceeds 1 MB, keeping the most recent 500 lines (see Log output).
Useful management commands:
Start-ScheduledTask -TaskName 'StickyNotesSync' # run now
Get-ScheduledTask -TaskName 'StickyNotesSync' # check status
Unregister-ScheduledTask -TaskName 'StickyNotesSync' # removeThe windows\run_watch.bat file runs the same command with a visible console and is useful for debugging.
Because the Task Scheduler job runs without a visible console, the daemon surfaces problems two ways:
1. heartbeat.json β written every poll cycle, inspectable any time:
{
"last_success_ts": 1776560978.14,
"error_state": false,
"last_error": null,
"last_error_ts": null,
"last_toast_ts": null
}If error_state is true or last_success_ts is older than a couple of poll intervals, the daemon is stuck.
2. Windows toast notifications β when the daemon hits an unrecoverable auth error (expired MS refresh token, revoked Gmail app password), it fires a Windows 10/11 toast titled "Sticky Notes Sync: login needed" naming the fix. Toasts are rate-limited to once per hour per error state so a dead credential doesn't spam you. On the first successful cycle after an error, you'll get a "Sticky Notes Sync recovered" toast.
If you see the login-needed toast, run:
source .venv/bin/activate
python sync_notes.py loginThe daemon writes to watch.log via the Task Scheduler shell redirect. Two behaviors keep this log useful instead of noisy:
- Auto-rotation. When
watch.logcrosses 1 MB (typically many months of runtime), the daemon trims it in place to the most recent 500 lines and prepends a--- log rotated at ... ---marker. No externallogrotateor cron job is needed. - No-change cycles are suppressed in daemon mode. When stdout is redirected (Task Scheduler / VBS) the daemon is silent on cycles where nothing changed β heartbeat.json already records those. Interactive runs (
run_watch.bator a terminal) still print per-cycle status so you can see the daemon is alive.
Sync events, errors, and initial startup always log regardless of mode.
Read this before committing anything or adopting this tool more widely.
A Gmail App Password is not IMAP-only β it grants full IMAP and SMTP access. An attacker with this password can read all mail in the inbox and send mail as you. Using a dedicated account collapses that blast radius to an empty inbox and an identity nobody recognizes.
These are gitignored by default and blocked by the pre-commit hook β verify before any git add -A:
| File | Contains |
|---|---|
config.json |
Gmail app password, Azure client ID |
ms_token_cache.json |
Microsoft OAuth access + refresh tokens |
sync_state.json |
Note IDs and last-modified timestamps |
delta_link.json |
Opaque Graph delta cursor |
heartbeat.json |
Daemon health state |
watch.log |
Daemon log, may include note subjects |
Restrict them to your user:
chmod 600 config.json ms_token_cache.json sync_state.json delta_link.json heartbeat.json watch.logscripts/pre-commit-hook.sh rejects commits that stage any of the files above, or whose diffs contain a gmail_app_password, access_token, or refresh_token value. Install on a fresh clone with:
cp scripts/pre-commit-hook.sh .git/hooks/pre-commit
chmod +x .git/hooks/pre-commitThe hook turns a careless git add -A && git commit from a leak into a loud failure. Bypass with --no-verify only if you are absolutely certain it's a false positive.
requirements-lock.txt pins the full transitive dependency tree, not just msal. Use it for installs to reduce exposure to supply-chain compromises in indirect dependencies. Regenerate after deliberate upgrades with pip freeze > requirements-lock.txt.
Note IDs from the Graph API are validated against a URL/base64-safe character set before they reach imap.search(), to prevent any future IMAP-search injection vector from malformed IDs. This is defense-in-depth β Microsoft issues the IDs, so no current exploit path exists.
Keep it private, or audit the .gitignore carefully before every commit. A single accidental git add config.json published to a public repo will leak the app password within seconds to credential scrapers.
Linux chmod 600 does not protect against reads from the Windows side via \\wsl$\Ubuntu\... β Windows uses its own ACLs there. On Windows, enable BitLocker on the system drive so an attacker with physical access cannot pull the disk out and read secrets offline. Single-user laptops are fine with default BitLocker; shared Windows machines want distinct Windows accounts per user as well.
- The Microsoft Graph scope is
Mail.Readβ read-only access to mail (Sticky Notes included). Microsoft does not offer a narrower scope for Sticky Notes specifically. - If the dedicated Gmail app password is ever exposed, revoke it at myaccount.google.com/apppasswords β this is near-instant and you can generate a replacement.
- If the MS token cache is ever exposed, sign out of all sessions at account.microsoft.com to invalidate refresh tokens.
- Rotate the MS refresh token every ~60 days. Run
python sync_notes.py loginon a reminder. The refresh token itself is valid for roughly 90 days; proactive rotation shortens any window where a stolen token is usable. - Periodically check
heartbeat.json. Iflast_success_tsis stale orerror_stateistrue, the daemon needs attention even if you missed the toast. - Audit
git log --diff-filter=Abefore making the repo public (if you ever consider doing so). The pre-commit hook prevents future accidents; it can't retroactively un-leak historical ones.
If you prefer not to use the Graph API, you can read directly from the local Sticky Notes database:
python sync_notes.py --source localThis reads from plum.sqlite in the Windows Sticky Notes app data folder. Note that the local DB may not reflect recent changes if the app hasn't flushed its write-ahead log β the Graph API source is more reliable.