Appearance
Mail Poller Installation Guide
This guide walks through a complete installation of mail-poller on Z2 ct102.
Prerequisites
Before starting, ensure:
- Z2 Proxmox VE 9.2 is online
- ct102 (Debian 12) is running
- ct103 (Debian 12) is running and contains credentials at
/opt/itachi/credentials/ - You can SSH to Z2:
ssh z2 'echo ok' - Python 3.11 is available on ct102:
ssh z2 'pct exec 102 -- python3 --version'
Step 1: Prepare Credentials on ct103
All 4 email accounts require credentials stored on ct103 at /opt/itachi/credentials/.
1a. Gmail (IMAP)
Generate an app-specific password:
- Go to https://myaccount.google.com/apppasswords
- Select Mail and Device → Other (custom name)
- Enter "Z2 Mail Poller" and generate
- Copy the 16-character password
Create /opt/itachi/credentials/mail_imap_credential.json:
bash
ssh z2 'pct exec 103 -- tee /opt/itachi/credentials/mail_imap_credential.json' << 'EOF'
{
"email": "michael.asolo1@gmail.com",
"password": "xxxx xxxx xxxx xxxx"
}
EOF
ssh z2 'pct exec 103 -- chmod 600 /opt/itachi/credentials/mail_imap_credential.json'1b. Outlook (Microsoft Graph API)
Register an app in Azure and get OAuth tokens.
Option A: Using Azure Portal UI
- Go to https://portal.azure.com
- Search for "App registrations" → New registration
- Name: "Hinata Mail Poller"
- Supported account types: Personal Microsoft accounts only (Consumers)
- Redirect URI: (leave blank for now — we're using refresh tokens)
- Click Register
Note the Application (client) ID.
Next, create a client secret:
- Go to Certificates & secrets
- New client secret
- Description: "Z2 Mail Poller"
- Expires: 24 months (can be extended or rotated)
- Copy the Value (not the ID)
Grant API permissions:
- Go to API permissions
- Add a permission → Microsoft Graph → Delegated permissions
- Search for "Mail.Read" and select it
- Grant admin consent
Create /opt/itachi/credentials/outlook-graph-credentials.json:
bash
ssh z2 'pct exec 103 -- tee /opt/itachi/credentials/outlook-graph-credentials.json' << 'EOF'
{
"client_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"client_secret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"tenant_id": "consumers"
}
EOF
ssh z2 'pct exec 103 -- chmod 600 /opt/itachi/credentials/outlook-graph-credentials.json'Option B: Using Azure CLI (faster if already installed)
bash
# Login to Azure
az login
# Register app
az ad app create --display-name "Hinata Mail Poller"
# Get client ID from output, then create secret
CLIENT_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
az ad app credential create --id $CLIENT_ID --display-name "Z2-poller"
# Grant permissions (requires admin consent)
az ad app permission add --id $CLIENT_ID --api 00000003-0000-0000-c000-000000000000 --api-permissions 810c84a8-4a9e-49e6-bf7d-12d183f40d01=Scope1c. Generate Initial OAuth2 Tokens
For each of the 3 Outlook accounts, you need initial access + refresh tokens.
Option 1: Using msal library (requires pip, one-time)
bash
# On Mac or ct103 (with Python 3.11 + pip):
pip install msal
python3 << 'PYTHON'
import msal
import json
CLIENT_ID = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
CLIENT_SECRET = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# Initiate device flow
app = msal.PublicClientApplication(
CLIENT_ID,
authority="https://login.microsoftonline.com/consumers"
)
# Device flow (no UI required)
flow = app.initiate_device_flow(scopes=["Mail.Read"])
print(flow["message"]) # Instructions
input("Press Enter after authenticating...")
# Exchange device code for tokens
result = app.acquire_token_by_device_flow(flow)
if "access_token" in result:
print(json.dumps({
"access_token": result["access_token"],
"refresh_token": result.get("refresh_token")
}, indent=2))
else:
print("Failed:", result)
PYTHONOption 2: Using curl (manual OAuth flow)
bash
# Step 1: Get device code
curl -X POST https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode \
-d "client_id=xxxxxxxx&scope=Mail.Read%20offline_access"
# Step 2: User visits device_url and enters device_code
# Step 3: Poll for token
curl -X POST https://login.microsoftonline.com/consumers/oauth2/v2.0/token \
-d "client_id=xxxxxxxx&client_secret=secret&device_code=code&grant_type=urn:ietf:params:oauth:grant-type:device_code"Store each token in its own file:
bash
# hotmail-michael-asolo
ssh z2 'pct exec 103 -- tee /opt/itachi/credentials/outlook-tokens-hotmail-michael-asolo.json' << 'EOF'
{
"access_token": "eyJhbGciOiJSUzI1NiIsImt...",
"refresh_token": "0.ARwA6q..."
}
EOF
# outlook-michael-nnamah
ssh z2 'pct exec 103 -- tee /opt/itachi/credentials/outlook-tokens-outlook-michael-nnamah.json' << 'EOF'
{
"access_token": "eyJhbGciOiJSUzI1NiIsImt...",
"refresh_token": "0.ARwA6q..."
}
EOF
# outlook-n-nnamah
ssh z2 'pct exec 103 -- tee /opt/itachi/credentials/outlook-tokens-outlook-n-nnamah.json' << 'EOF'
{
"access_token": "eyJhbGciOiJSUzI1NiIsImt...",
"refresh_token": "0.ARwA6q..."
}
EOF
# Restrict permissions
ssh z2 'pct exec 103 -- chmod 600 /opt/itachi/credentials/outlook-tokens-*.json'1d. Verify Credentials
bash
ssh z2 'pct exec 103 -- ls -l /opt/itachi/credentials/'
# Should show:
# -rw------- ... mail_imap_credential.json
# -rw------- ... outlook-graph-credentials.json
# -rw------- ... outlook-tokens-hotmail-michael-asolo.json
# -rw------- ... outlook-tokens-outlook-michael-nnamah.json
# -rw------- ... outlook-tokens-outlook-n-nnamah.jsonStep 2: Deploy to ct102
From your vault directory:
bash
cd projects/brain/mail-poller-z2/
# First, do a dry-run to verify setup
./deploy.sh --dry-run
# Then run actual deployment
./deploy.shThe deploy script will:
- Create
/opt/hinata/mail-poller/directory structure - Copy
mail-poller.pyto ct102 - Copy credentials from ct103 to ct102 (if needed)
- Create systemd service + timer
- Enable and start the timer
- Run a test execution
Step 3: Verify Deployment
Check Service Status
bash
ssh z2 'pct exec 102 -- systemctl status hinata-mail-poller.timer'
ssh z2 'pct exec 102 -- systemctl status hinata-mail-poller.service'Should show:
● hinata-mail-poller.timer - Hinata Mail Poller Timer
Loaded: loaded (/etc/systemd/system/hinata-mail-poller.timer; enabled; preset: enabled)
Active: active (waiting) since ...
Trigger: in X seconds, at ...Check Recent Execution
bash
ssh z2 'pct exec 102 -- journalctl -u hinata-mail-poller.service -n 50'Should show logs like:
[...] [INFO] mail-poller: Starting mail poller (dry_run=False)
[...] [INFO] mail-poller: [gmail] Starting IMAP poll...
[...] [INFO] mail-poller: [gmail] Connected to imap.gmail.com
[...] [INFO] mail-poller: [gmail] Found 5 new messages
[...] [INFO] mail-poller: [gmail] Poll complete: 5 new messages
[...] [INFO] mail-poller: [hotmail-michael-asolo] Starting Graph API poll...
[...] [INFO] mail-poller: Archived 5 messages
[...] [INFO] mail-poller: Mail poller completeCheck Archived Emails
bash
# Count emails
ssh z2 'pct exec 102 -- find /opt/hinata/mail-poller/archive -name "*.json" | wc -l'
# View sample
ssh z2 'pct exec 102 -- find /opt/hinata/mail-poller/archive -name "*.json" | head -1 | xargs cat | python3 -m json.tool'
# Check by account
ssh z2 'pct exec 102 -- du -sh /opt/hinata/mail-poller/archive/*/'View State Cursor
bash
ssh z2 'pct exec 102 -- cat /opt/hinata/mail-poller/state.json | python3 -m json.tool'Sample output:
json
{
"gmail": {
"folders": {
"INBOX": {"last_uid": 12345},
"Archive": {"last_uid": 9876}
},
"last_poll": "2026-06-05T10:30:45.123456"
},
"hotmail-michael-asolo": {
"last_received": "2026-06-05T10:30:45Z",
"last_poll": "2026-06-05T10:30:45.123456"
},
...
}Step 4: Troubleshooting
"Credential file not found"
Ensure credentials are on ct103 and accessible from ct102:
bash
# On ct103
ssh z2 'pct exec 103 -- ls -la /opt/itachi/credentials/'
# From ct102 (should be accessible)
ssh z2 'pct exec 102 -- ls -la /opt/itachi/credentials/ 2>/dev/null || echo "Not visible from ct102"'If not visible, credentials need to be copied via deploy.sh or mounted as a bind mount in Proxmox.
"IMAP Connection Failed"
Test IMAP manually:
bash
ssh z2 'pct exec 102 -- python3 << 'PYTHON'
import imaplib
import json
cred = json.load(open("/opt/itachi/credentials/mail_imap_credential.json"))
conn = imaplib.IMAP4_SSL("imap.gmail.com", 993)
conn.login(cred["email"], cred["password"])
status, folders = conn.list()
print(f"Connected. {len(folders)} folders found.")
conn.close()
PYTHON
'If fails:
- Verify app password is correct
- Check if password expired (happens if account password changed)
- Regenerate app password at https://myaccount.google.com/apppasswords
"Graph API 401 Unauthorized"
Tokens may need refresh. Mail-poller does this automatically, but if initial token is invalid:
Verify token on ct102:
bashssh z2 'pct exec 102 -- cat /opt/itachi/credentials/outlook-tokens-hotmail-michael-asolo.json'Regenerate tokens following Step 1c above
Copy to ct102:
bash./deploy.sh
"No new messages found (but there should be)"
Check the state cursor:
bash
ssh z2 'pct exec 102 -- jq .gmail.folders.INBOX /opt/hinata/mail-poller/state.json'Expected output:
json
{"last_uid": 12345}If last_uid is way in the past:
- Delete state.json to reset cursor
- Mail-poller will fetch all messages on next run (may take time)
bash
ssh z2 'pct exec 102 -- rm /opt/hinata/mail-poller/state.json'Step 5: Monitor Long-term
Schedule regular checks:
Daily: Check logs for errors
bashssh z2 'pct exec 102 -- journalctl -u hinata-mail-poller.service --since "24 hours ago" --no-pager'Weekly: Verify archive growth
bashssh z2 'pct exec 102 -- find /opt/hinata/mail-poller/archive -newermt "7 days ago" -name "*.json" | wc -l'Monthly: Check state file size
bashssh z2 'pct exec 102 -- ls -lh /opt/hinata/mail-poller/state.json'
What's Next?
After mail-poller is stable for 7 days:
- Decommission Mac poller: Disable LaunchAgent
- Update Pi cron: Point to Z2 instead of Mac
- Archive backups: Set up NFS mount or rsync from Z2 to Sandpit
- Classification pipeline: Once stable, integrate Heimerdinger NLP classifier (Phase 3)
See DEPLOYMENT_CHECKLIST.md for full Phase 1–3 verification.