Skip to content

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:

  1. Go to https://myaccount.google.com/apppasswords
  2. Select Mail and Device → Other (custom name)
  3. Enter "Z2 Mail Poller" and generate
  4. 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

  1. Go to https://portal.azure.com
  2. Search for "App registrations" → New registration
  3. Name: "Hinata Mail Poller"
  4. Supported account types: Personal Microsoft accounts only (Consumers)
  5. Redirect URI: (leave blank for now — we're using refresh tokens)
  6. Click Register

Note the Application (client) ID.

Next, create a client secret:

  1. Go to Certificates & secrets
  2. New client secret
  3. Description: "Z2 Mail Poller"
  4. Expires: 24 months (can be extended or rotated)
  5. Copy the Value (not the ID)

Grant API permissions:

  1. Go to API permissions
  2. Add a permission → Microsoft Graph → Delegated permissions
  3. Search for "Mail.Read" and select it
  4. 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=Scope

1c. 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)
PYTHON

Option 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.json

Step 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.sh

The deploy script will:

  1. Create /opt/hinata/mail-poller/ directory structure
  2. Copy mail-poller.py to ct102
  3. Copy credentials from ct103 to ct102 (if needed)
  4. Create systemd service + timer
  5. Enable and start the timer
  6. 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 complete

Check 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:

"Graph API 401 Unauthorized"

Tokens may need refresh. Mail-poller does this automatically, but if initial token is invalid:

  1. Verify token on ct102:

    bash
    ssh z2 'pct exec 102 -- cat /opt/itachi/credentials/outlook-tokens-hotmail-michael-asolo.json'
  2. Regenerate tokens following Step 1c above

  3. 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

    bash
    ssh z2 'pct exec 102 -- journalctl -u hinata-mail-poller.service --since "24 hours ago" --no-pager'
  • Weekly: Verify archive growth

    bash
    ssh z2 'pct exec 102 -- find /opt/hinata/mail-poller/archive -newermt "7 days ago" -name "*.json" | wc -l'
  • Monthly: Check state file size

    bash
    ssh z2 'pct exec 102 -- ls -lh /opt/hinata/mail-poller/state.json'

What's Next?

After mail-poller is stable for 7 days:

  1. Decommission Mac poller: Disable LaunchAgent
  2. Update Pi cron: Point to Z2 instead of Mac
  3. Archive backups: Set up NFS mount or rsync from Z2 to Sandpit
  4. Classification pipeline: Once stable, integrate Heimerdinger NLP classifier (Phase 3)

See DEPLOYMENT_CHECKLIST.md for full Phase 1–3 verification.