@clawhub-entzclaw-6663f389e3
Automated email assistant for Apple Mail. Runs on a schedule, scores priority, drafts AI replies, and emails you a report. Manage your inbox from Telegram or...
---
name: email-checker-for-mac
description: Automated email assistant for Apple Mail. Runs on a schedule, scores priority, drafts AI replies, and emails you a report. Manage your inbox from Telegram or WhatsApp — never open the bot inbox again.
homepage: https://github.com/entzclaw/email-checker-for-mac
metadata: {"clawdbot":{"emoji":"📬","os":["darwin"],"requires":{"bins":["python3","osascript"],"apps":["Mail.app"]}}}
---
# Email Checker for Mac
Automated email assistant for Apple Mail on macOS. Runs on a schedule, scores
your unread emails by priority, drafts AI replies, and sends you a report —
so you can manage your inbox from Telegram or WhatsApp without ever opening Mail.app.
## Installation
```bash
git clone https://github.com/entzclaw/email-checker-for-mac
cd email-checker-for-mac
bash setup.sh
```
## Setup
The wizard handles everything:
1. Auto-discovers your Mail.app accounts
2. Prompts for name, report email, trusted senders
3. Picks your LLM provider (LM Studio, Ollama, OpenAI, or skip)
4. Tests the connection and writes `config/settings.json`
5. Installs the crontab
## Usage
```bash
# Run a manual check
python3 scripts/email/checker.py
# Send a reply
python3 scripts/email/send_reply.py \
--to [email protected] \
--subject "Re: Something" \
--content "Your reply here"
# Check logs
tail -f logs/email_check.log
```
## OpenClaw Integration
Tell OpenClaw via Telegram or WhatsApp:
> _"Run the email checker now"_
> _"Send the draft reply to Alice"_
> _"Add @company.com to my trusted senders"_
## Supported LLM Providers
| Provider | Notes |
|---|---|
| LM Studio | Local or remote vLLM endpoint |
| Ollama | Local |
| OpenAI | Requires API key |
| None | Reports without AI drafts |
## Requirements
- macOS (tested on Tahoe 26.3, Apple Silicon)
- Python 3
- Mail.app with at least one configured account
- Automation permission: System Settings → Privacy & Security → Automation → Terminal → Mail
FILE:README.md
# Email Checker by EntzAI
An automated email assistant for Apple Mail on macOS. Runs on a schedule,
scores your unread emails by priority, drafts AI replies, and sends you a
report — so you can manage your inbox from Telegram or WhatsApp without ever
opening Mail.app.
> **Requires:** macOS · Python 3 · Mail.app · Optional: local or remote LLM
---
## How It Works
1. Runs on a schedule (hourly by default, fully configurable)
2. Fetches unread emails from your Mail.app INBOX
3. Scores each email **HIGH / MEDIUM / LOW** based on keywords and trusted senders
4. Drafts a contextual AI reply for each email (or skips if LLM is disabled)
5. Emails you a report with previews and draft replies
6. Marks processed emails as read
---
## Use Cases
### The OpenClaw Setup _(recommended)_
This is the workflow the app was designed for:
- You have a **dedicated machine or VM running OpenClaw** (your AI assistant)
- That machine has its own **bot email account** in Mail.app — this is the inbox it watches
- Email Checker runs on that machine **every hour via cron**
- When emails arrive: checker scores them, drafts replies, and **sends a report to your personal email**
- You read the report on your phone — you see each email's priority, a preview, and an AI-drafted reply
- If you like a draft, you tell OpenClaw via **Telegram or WhatsApp**: _"Send that reply to Angelo"_
- **You never open the bot's inbox.** The whole flow lives in your phone's chat
This works great on macOS Tahoe (26.3) on Apple Silicon with OpenClaw as your always-on assistant.
### Personal Productivity
- Run on your main Mac, connected to your own Mail.app account
- Get hourly digest reports with AI-drafted replies in your inbox
- Review and approve drafts without context-switching into Mail.app
- Send replies via `send_reply.py` or by telling OpenClaw
### Low-Tech Mode _(no LLM)_
- Set `"provider": "none"` in `settings.json`
- Still scores and previews every unread email by priority
- Report shows subject, sender, preview — no AI drafts
- Useful for a smart email digest with zero LLM dependency
---
## Quick Start
### 1. Prerequisites
- macOS (tested on Tahoe 26.3, Apple Silicon)
- Python 3 — `brew install python` if missing
- Mail.app configured with at least one account
### 2. Get the project
```bash
git clone https://github.com/entzclaw/email-checker-for-mac
cd email-checker-for-mac
```
### 3. Run setup
```bash
bash setup.sh
```
The wizard will:
- Auto-discover your Mail.app accounts — pick yours from a numbered list
- Prompt for your name, bot name, report destination email, and trusted senders
- Let you pick an LLM provider (LM Studio, Ollama, OpenAI, or skip)
- Test the LLM connection before saving
- Write `config/settings.json` (gitignored — never leaves your machine)
- Optionally install the crontab and run a first test
### 4. Grant Mail.app permissions
**System Settings → Privacy & Security → Automation**
→ Allow **Terminal** to control **Mail**
If cron jobs fail, also add Terminal under **Full Disk Access**.
### 5. Test
```bash
python3 scripts/email/checker.py
```
---
## Configuration
All config lives in `config/settings.json` — created by `setup.sh`, gitignored.
See `config/settings.example.json` for the full structure.
### Trusted Senders
Trusted senders get a **+2 priority score boost**. Each entry is matched as a
**case-insensitive substring** of the sender's full `From:` field.
The `From:` field contains both the display name and address, e.g.:
```
Alice Smith <[email protected]>
```
| Entry | What it matches | Use when |
|---|---|---|
| `"Alice"` | Display name (partial) | You know their first name |
| `"@company.com"` | Everyone from a domain | Trust a whole organisation |
| `"[email protected]"` | That exact address only | Specific person, any display name |
**Example:**
```json
"trusted_senders": ["Alice", "bob", "@mycompany.com", "[email protected]"]
```
You can update this list by:
- Editing `config/settings.json` directly, or
- Telling OpenClaw via Telegram/WhatsApp: _"Add [email protected] to trusted senders"_
### LLM Providers
| Provider | `provider` | `base_url` | Notes |
|---|---|---|---|
| LM Studio | `lm_studio` | Your LM Studio URL | Local or remote vLLM |
| Ollama | `ollama` | `http://localhost:11434/v1` | Local, set by setup.sh |
| OpenAI | `openai` | `https://api.openai.com/v1` | Requires API key |
| Disabled | `none` | — | Reports without drafts |
### Cron Schedule
Default: every hour. Change by editing crontab (`crontab -e`):
```
# Every hour (default)
0 * * * * /abs/path/to/scripts/email/checker_wrapper.sh
# Every 30 minutes
*/30 * * * * /abs/path/to/scripts/email/checker_wrapper.sh
# Every 5 minutes
*/5 * * * * /abs/path/to/scripts/email/checker_wrapper.sh
```
Re-run `bash setup.sh` to reinstall crontab with updated paths if you move the folder.
---
## Working with OpenClaw
If you're running OpenClaw as your AI assistant, this app integrates naturally
via Telegram or WhatsApp:
**Update config:**
> _"Add @newcompany.com to my trusted senders"_
> _"Change my report email to [email protected]"_
> _"Switch the LLM model to gpt-4o"_
OpenClaw edits `config/settings.json` directly — changes take effect on the next run.
**Send a reply:**
> _"Send the draft reply to Angelo"_
> _"Reply to the GitHub notification with: noted, reviewing tomorrow"_
```bash
python3 scripts/email/send_reply.py \
--to [email protected] \
--subject "Re: Something" \
--content "Your reply here"
# From a file
python3 scripts/email/send_reply.py \
--to [email protected] \
--subject "Re: Something" \
--file /path/to/draft.txt
```
**Trigger a manual check:**
> _"Run the email checker now"_
```bash
python3 scripts/email/checker.py
```
**Check logs:**
```bash
tail -f logs/email_check.log
```
---
## Directory Structure
```
email-checker-for-mac/
├── README.md
├── setup.sh # Interactive setup wizard
├── _meta.json # ClawHub metadata
├── config/
│ ├── settings.example.json # Template (committed to git)
│ ├── settings.json # Your config (gitignored)
│ └── email_check_crontab.txt # Cron reference (written by setup.sh)
├── scripts/
│ └── email/
│ ├── checker.py # Main email checker
│ ├── checker_wrapper.sh # Cron entry point — owns logging
│ ├── get_unread_emails.scpt # AppleScript: fetch unread emails
│ └── send_reply.py # Manual / OpenClaw reply sender
├── logs/ # Runtime logs (gitignored)
└── temp/ # Runtime cache (gitignored)
```
---
## Troubleshooting
**"Config not found. Run setup.sh first."**
Run `bash setup.sh` — it walks you through everything.
**"Not authorized to send Apple events to Mail"**
System Settings → Privacy & Security → Automation → Allow Terminal to control Mail.
If using cron, also add Terminal to Full Disk Access.
**LLM times out or connection failed**
The model may still be processing a previous request server-side. Wait 1–2 minutes.
Increase `"timeout"` in `settings.json` for slow models.
**Wrong account / no emails detected**
Re-run `bash setup.sh` — it lists all Mail.app accounts for you to pick from.
**Logs not writing**
```bash
chmod +x scripts/email/checker_wrapper.sh
```
---
---
## Changelog
### v1.1.1 — 2026-03-24
**Setup wizard hotfix**
- LLM test now prints "please wait up to 15 seconds" — so users don't press Enter while waiting
- Flush buffered stdin before the "Continue anyway?" prompt — prevents a stray Enter from auto-accepting
- Changed "Continue anyway?" default from N (abort) to Y (continue) — LLM being offline shouldn't block the whole setup
### v1.1.0 — 2026-03-24
**Setup wizard improvements**
- Removed `set -e` — wizard no longer quits silently on any non-zero exit
- Required fields (name, email, model ID) now loop until valid — blank input re-prompts instead of saving empty values
- Email address format validated before saving
- LLM test captures both stdout and stderr — shows specific error (`CONNECTION_ERROR`, `HTTP_ERROR 401/404/400`) instead of a blank failure message
- If LLM test fails, user is asked whether to continue or abort
- Settings write is verified — exits with a clear error if `settings.json` fails to write
- All Y/N prompts loop until valid input, with sensible defaults
- Final success screen prints a summary: config path, schedule, report email, LLM model
**Priority & drafts**
- AI draft replies are now generated only for **HIGH** priority emails
- MEDIUM and LOW emails show a preview only — faster and uses less LLM
**Subject deduplication**
- Thread subject normalisation now strips nested prefixes (`Re: Re: Re:`, `Fwd: Re:`)
- Strips bracket tags (`[EXTERNAL]`, `[BULK]`) before comparing subjects
- Strips trailing punctuation and symbols
### v1.0.0 — 2026-03-02
Initial release.
- Automated inbox scanning via AppleScript and Mail.app
- Priority scoring (HIGH / MEDIUM / LOW) with trusted sender boost
- AI draft replies via LM Studio, Ollama, or OpenAI
- Hourly email report sent to your personal address
- Interactive setup wizard with crontab installer
- Published to ClawHub as `email-checker-by-entzai`
---
_Built by EntzAI · Powered by OpenClaw_
FILE:_meta.json
{
"slug": "email-checker-by-entzai",
"name": "Email Checker by EntzAI",
"version": "1.1.1",
"description": "Automated email assistant for Apple Mail. Scores priority, drafts AI replies, and sends you a report on a schedule. Manage your inbox from Telegram or WhatsApp.",
"os": ["darwin"],
"requires": {
"bins": ["python3", "osascript"],
"apps": ["Mail.app"]
},
"emoji": "📬",
"author": "EntzAI"
}
FILE:config/settings.example.json
{
"user": {
"name": "Your Full Name",
"bot_name": "EntzClawBot",
"report_email": "[email protected]",
"trusted_senders": [
"Angelo",
"@yourcompany.com",
"[email protected]"
]
},
"mail": {
"account_id": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
"inbox_name": "INBOX"
},
"schedule": {
"interval_minutes": 60
},
"llm": {
"provider": "lm_studio",
"base_url": "http://localhost:1234/v1",
"api_key": "local",
"model": "your-model-id",
"max_tokens": 800,
"timeout": 45
}
}
FILE:scripts/email/checker.py
#!/usr/bin/env python3
"""
Email Checker - Checks unread emails in Inbox and sends report.
Uses AppleScript for reliable detection of unread status.
CHANGELOG:
v3 (portability update):
- All hardcoded values moved to config/settings.json
- call_lm_studio() renamed to call_llm() (provider-agnostic)
- LLM can be disabled via settings.json ("provider": "none")
- get_unread_emails.scpt now receives account_id as CLI arg
- Run setup.sh to create settings.json
v2 (LM Studio update):
- Replaced Ollama API with LM Studio / vLLM OpenAI-compatible API
- call_ollama() renamed to call_lm_studio() and rewritten for
OpenAI /v1/chat/completions format
- Increased thread history from 5 → 10 messages
- Increased per-message content capture from 800 → 2000 chars
v1 (original rewrite):
- Added get_thread_history() : fetches previous messages in the same
thread from Mail.app via AppleScript
- Replaced generate_contextual_draft() : now builds a structured prompt
and sends it to the LLM
"""
import subprocess
import urllib.request # stdlib — no pip needed to call the REST API
import urllib.error
from datetime import datetime
from pathlib import Path
import json
import re
SCRIPT_DIR = Path(__file__).parent.resolve()
WORKSPACE_DIR = SCRIPT_DIR.parent.parent
LOG_DIR = WORKSPACE_DIR / "logs"
TEMP_DIR = WORKSPACE_DIR / "temp"
LOG_DIR.mkdir(parents=True, exist_ok=True)
TEMP_DIR.mkdir(parents=True, exist_ok=True)
LOG_FILE = LOG_DIR / "email_check.log"
EMAILS_FILE = TEMP_DIR / "recent_emails.json"
# ─────────────────────────────────────────────────────────────────────────────
# Config loading — all values come from config/settings.json.
# Run setup.sh to create it.
# ─────────────────────────────────────────────────────────────────────────────
CONFIG_FILE = WORKSPACE_DIR / "config" / "settings.json"
def load_config():
if not CONFIG_FILE.exists():
raise FileNotFoundError(
f"Config not found at {CONFIG_FILE}. Run setup.sh first."
)
return json.loads(CONFIG_FILE.read_text())
CONFIG = load_config()
LLM_BASE_URL = CONFIG["llm"]["base_url"]
LLM_API_KEY = CONFIG["llm"]["api_key"]
LLM_MODEL = CONFIG["llm"]["model"]
LLM_MAX_TOKENS = CONFIG["llm"]["max_tokens"]
LLM_TIMEOUT = CONFIG["llm"]["timeout"]
LLM_ENABLED = CONFIG["llm"].get("provider", "") != "none"
MAIL_ACCOUNT_ID = CONFIG["mail"]["account_id"]
REPORT_RECIPIENT = CONFIG["user"]["report_email"]
TRUSTED_SENDERS = CONFIG["user"]["trusted_senders"]
USER_NAME = CONFIG["user"]["name"]
BOT_NAME = CONFIG["user"]["bot_name"]
def log(message):
"""Write to log file with timestamp."""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with open(LOG_FILE, "a") as f:
f.write(f"[{timestamp}] {message}\n")
# ─────────────────────────────────────────────────────────────────────────────
# get_unread_emails
# ─────────────────────────────────────────────────────────────────────────────
def get_unread_emails():
"""Get unread emails from Inbox using AppleScript (includes content)."""
script_path = SCRIPT_DIR / "get_unread_emails.scpt"
result = subprocess.run(
['osascript', str(script_path), MAIL_ACCOUNT_ID],
capture_output=True, text=True
)
output = result.stdout.strip()
if 'NO_UNREAD_EMAILS' in output or not output:
log("No unread messages found in inbox")
return []
# Parse AppleScript output: sender|subject|||content (blocks split by |||)
emails = []
for block in output.split('|||'):
if '|' in block:
parts = block.split('|', 2)
sender = parts[0].strip()
subject = parts[1].strip() if len(parts) > 1 else "No Subject"
content = parts[2].strip() if len(parts) > 2 else ""
emails.append({
'sender': sender,
'subject': subject,
'content': content[:500], # first 500 chars for the preview
'date': datetime.now().isoformat()
})
log(f"Found {len(emails)} unread email(s)")
return emails
# ─────────────────────────────────────────────────────────────────────────────
# analyze_email
# ─────────────────────────────────────────────────────────────────────────────
def analyze_email(sender, subject, content=""):
"""Analyze an email and determine if it's important."""
importance_score = 0
priority_keywords = {
"urgent": 3,
"asap": 3,
"emergency": 3,
"immediate": 3,
"critical": 3,
"review": 2,
"approve": 2,
"feedback": 2,
"action required": 2,
"respond": 1
}
subject_lower = (subject or "").lower()
content_lower = (content or "").lower()
sender_lower = (sender or "").lower()
for keyword, score in priority_keywords.items():
if keyword in subject_lower or keyword in content_lower:
importance_score += score
for known in TRUSTED_SENDERS:
if known in sender_lower:
importance_score += 2
if importance_score >= 5:
priority = "HIGH"
needs_response = True
elif importance_score >= 2:
priority = "MEDIUM"
needs_response = False
else:
priority = "LOW"
needs_response = False
triggered_keywords = [
k for k in priority_keywords
if k in subject_lower or k in content_lower
]
return {
"priority": priority,
"score": importance_score,
"needs_response": needs_response,
"important_keywords": triggered_keywords
}
# ─────────────────────────────────────────────────────────────────────────────
# get_thread_history
# ─────────────────────────────────────────────────────────────────────────────
def get_thread_history(subject, sender, max_messages=10):
"""
Fetch previous messages in the same email thread from Mail.app.
Args:
subject : Subject of the current email (normalised internally)
sender : Sender of the current email
max_messages: Max prior messages to return (default 10)
Returns:
List of dicts [{"sender": ..., "subject": ..., "content": ...}]
sorted oldest-first. Empty list on failure or no history.
"""
# ── Normalise subject ─────────────────────────────────────────────────────
# Strip leading bracket tags e.g. [EXTERNAL], [BULK], [SPAM]
# then strip reply/forward prefixes repeatedly until none remain,
# then strip trailing symbols/punctuation.
base_subject = subject.strip()
base_subject = re.sub(r'^\[[^\]]*\]\s*', '', base_subject) # [TAG] prefix
while True:
stripped = re.sub(r'^(re|fwd|fw|aw|sv|ant)\s*:\s*', '', base_subject, flags=re.IGNORECASE).strip()
if stripped == base_subject:
break
base_subject = stripped
base_subject = re.sub(r'[\s\W]+$', '', base_subject).strip() # trailing symbols
if not base_subject:
log("Thread history: subject normalised to empty, skipping")
return []
# ── Escape for safe injection into AppleScript string literal ─────────────
safe_subject = base_subject.replace('"', '\\"')
# ── AppleScript: search INBOX for matching thread messages ─────────────────
applescript = f'''
tell application "Mail"
set targetSubject to "{safe_subject}"
set inboxAccount to account id "{MAIL_ACCOUNT_ID}"
set inboxFolder to mailbox "INBOX" of inboxAccount
set allMessages to every message of inboxFolder
set matchedMessages to {{}}
repeat with msg in allMessages
set msgSubject to subject of msg
if msgSubject contains targetSubject then
set msgContent to text 1 thru (min of 2000 and (count characters of (content of msg))) of (content of msg)
set msgEntry to (sender of msg) & "|||" & msgSubject & "|||" & msgContent
set end of matchedMessages to msgEntry
end if
end repeat
set AppleScript's text item delimiters to "<<<MSG>>>"
set resultText to matchedMessages as text
set AppleScript's text item delimiters to ""
return resultText
end tell
'''
try:
result = subprocess.run(
['osascript', '-e', applescript],
capture_output=True, text=True,
timeout=30
)
raw = result.stdout.strip()
if not raw:
log(f"Thread history: no prior messages found for '{base_subject}'")
return []
thread = []
for block in raw.split('<<<MSG>>>'):
block = block.strip()
if not block:
continue
parts = block.split('|||', 2)
if len(parts) == 3:
thread.append({
"sender": parts[0].strip(),
"subject": parts[1].strip(),
"content": parts[2].strip()
})
thread = thread[-max_messages:]
log(f"Thread history: found {len(thread)} prior message(s) for '{base_subject}'")
return thread
except subprocess.TimeoutExpired:
log("Thread history: AppleScript timed out, continuing without thread context")
return []
except Exception as e:
log(f"Thread history: failed ({e}), continuing without thread context")
return []
# ─────────────────────────────────────────────────────────────────────────────
# call_llm (was call_lm_studio in v2, call_ollama in v1)
# ─────────────────────────────────────────────────────────────────────────────
def call_llm(prompt, model=None, timeout=None):
"""
Send a prompt to the configured LLM and return generated text.
Uses the OpenAI-compatible /v1/chat/completions endpoint.
Provider, URL, key, and model come from config/settings.json.
Args:
prompt : Full prompt string (sent as a single user message)
model : Model ID override (defaults to LLM_MODEL)
timeout : Seconds before giving up (defaults to LLM_TIMEOUT)
Returns:
str : The model's response text, or empty string on any failure.
"""
model = model or LLM_MODEL
timeout = timeout or LLM_TIMEOUT
url = f"{LLM_BASE_URL}/chat/completions"
payload = json.dumps({
"model": model,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": LLM_MAX_TOKENS,
"stream": False
}).encode("utf-8")
req = urllib.request.Request(
url,
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {LLM_API_KEY}",
"User-Agent": "OpenClawBot/1.0"
},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = json.loads(resp.read().decode("utf-8"))
content = data["choices"][0]["message"]["content"]
# Strip <think>...</think> reasoning block if present
if "</think>" in content:
content = content.split("</think>", 1)[1]
return content.strip()
except urllib.error.URLError as e:
log(f"LLM connection failed ({e}). Check {LLM_BASE_URL} is reachable.")
return ""
except (KeyError, IndexError) as e:
log(f"LLM response format unexpected: {e}")
return ""
except json.JSONDecodeError as e:
log(f"LLM returned invalid JSON: {e}")
return ""
except Exception as e:
log(f"LLM call failed unexpectedly: {e}")
return ""
# ─────────────────────────────────────────────────────────────────────────────
# generate_contextual_draft
# ─────────────────────────────────────────────────────────────────────────────
def generate_contextual_draft(sender, subject, content):
"""
Generate a contextual draft reply using the configured LLM.
Fetches thread history first so the model has full conversation context.
Returns immediately with a placeholder if LLM_ENABLED is False.
"""
if not LLM_ENABLED:
return "[LLM disabled — no draft generated. Set provider in settings.json to enable.]"
# ── Extract clean display name ────────────────────────────────────────────
if '<' in sender:
sender_name = sender.split('<')[0].strip()
else:
sender_name = sender.strip()
if not sender_name:
sender_name = "there"
# ── Fetch thread history ──────────────────────────────────────────────────
log(f"Fetching thread history for: {subject}")
thread_history = get_thread_history(subject, sender)
# ── Build thread context block for the prompt ─────────────────────────────
if thread_history:
history_lines = ["Previous messages in this thread (oldest first):"]
history_lines.append("─" * 40)
for i, msg in enumerate(thread_history, 1):
history_lines.append(f"[Message {i}]")
history_lines.append(f"From: {msg['sender']}")
history_lines.append(f"Subject: {msg['subject']}")
history_lines.append(f"Content:\n{msg['content']}")
history_lines.append("")
thread_context = "\n".join(history_lines)
else:
thread_context = "This appears to be the start of a new thread (no prior messages found)."
# ── Build the full prompt ─────────────────────────────────────────────────
prompt = f"""You are {BOT_NAME}, an AI email assistant for {USER_NAME}.
Your job is to draft a reply on {USER_NAME}'s behalf to the email below.
About {USER_NAME}:
- Values concise, friendly, and professional communication
- Signs off as "{USER_NAME}" in personal emails, or "{BOT_NAME} 🤖" when acting autonomously
{thread_context}
─────────────────────────────────────────
Current email to reply to:
From: {sender} ({sender_name})
Subject: {subject}
Content:
{content}
─────────────────────────────────────────
Instructions:
- Read the thread history carefully and reply to THIS specific email in context.
- If the thread has prior messages, acknowledge or build on what was already discussed.
- If there is no thread history, respond appropriately for a first contact.
- Keep the reply concise (2–5 sentences is usually enough unless more detail is warranted).
- Match the tone of the incoming email: casual if they are casual, formal if they are formal.
- DO NOT use filler phrases like "I hope this email finds you well" or "Thanks for reaching out".
- DO NOT start with "I" as the very first word of the reply.
- End with a natural sign-off followed by "{BOT_NAME} 🤖" on its own line.
- Output ONLY the email body text. No explanations, no metadata, no subject line.
Draft reply:"""
log(f"Calling LLM ({LLM_MODEL}) for draft reply to: {subject}")
draft = call_llm(prompt)
if not draft:
log(f"LLM returned empty for '{subject}', using fallback draft")
draft = (
f"Hi {sender_name},\n\n"
f"Received your message about '{subject}'. "
f"I'll follow up with a proper reply shortly.\n\n"
f"{BOT_NAME} 🤖\n\n"
f"[NOTE: LLM was unavailable — this is a placeholder draft]"
)
log(f"Draft generated for: {subject}")
return draft
# ─────────────────────────────────────────────────────────────────────────────
# mark_emails_as_read
# ─────────────────────────────────────────────────────────────────────────────
def mark_emails_as_read():
"""Mark all unread emails in inbox as read using AppleScript."""
script = f'''tell application "Mail"
set inboxAccount to account id "{MAIL_ACCOUNT_ID}"
set inboxFolder to mailbox "INBOX" of inboxAccount
set unreadMessages to every message of inboxFolder whose read status is false
if (count of unreadMessages) > 0 then
repeat with msg in unreadMessages
set read status of msg to true
end repeat
return count of unreadMessages & " email(s) marked as read"
else
return "No emails to mark as read"
end if
end tell'''
try:
result = subprocess.run(
['osascript', '-e', script],
capture_output=True, text=True,
timeout=30
)
log(f"Marked as read: {result.stdout.strip()}")
return True
except subprocess.TimeoutExpired:
log("mark_emails_as_read: osascript timed out after 30s")
return False
except Exception as e:
log(f"Failed to mark emails as read: {e}")
return False
# ─────────────────────────────────────────────────────────────────────────────
# format_report
# ─────────────────────────────────────────────────────────────────────────────
def format_report(emails_data):
"""Format the email report."""
report = []
report.append("=" * 60)
report.append(f"EMAIL CHECK REPORT - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
report.append("=" * 60)
report.append("")
if not emails_data:
report.append("No unread emails found in inbox.")
return "\n".join(report)
report.append(f"Total unread messages: {len(emails_data)}")
report.append("")
high_priority = []
medium_priority = []
low_priority = []
for email in emails_data:
analysis = analyze_email(
email.get("sender", "Unknown"),
email.get("subject", "No Subject"),
email.get("content", "")
)
email["analysis"] = analysis
if analysis["priority"] == "HIGH":
high_priority.append(email)
elif analysis["priority"] == "MEDIUM":
medium_priority.append(email)
else:
low_priority.append(email)
# ── HIGH priority ──────────────────────────────────────────────────────────
if high_priority:
report.append("⚠️ HIGH PRIORITY EMAILS (Action Required)")
report.append("-" * 40)
for email in high_priority:
sender = email.get("sender", "Unknown")
subject = email.get("subject", "No Subject")
content = email.get("content", "")
report.append(f"\nFrom: {sender}")
report.append(f"Subject: {subject}")
report.append(f"Impact Score: {email['analysis']['score']}/15")
if email['analysis']['important_keywords']:
report.append(f"Triggered keywords: {', '.join(email['analysis']['important_keywords'])}")
if content:
report.append("\nEmail Preview:")
report.append("-" * 20)
report.append(content[:300] + "..." if len(content) > 300 else content)
report.append("\nDraft Response:")
report.append("-" * 20)
draft = generate_contextual_draft(sender, subject, content)
report.append(draft)
report.append("")
# ── MEDIUM priority ────────────────────────────────────────────────────────
if medium_priority:
report.append("⚠️ MEDIUM PRIORITY EMAILS")
report.append("-" * 40)
for email in medium_priority:
sender = email.get("sender", "Unknown")
subject = email.get("subject", "No Subject")
content = email.get("content", "")
report.append(f"\nFrom: {sender}")
report.append(f"Subject: {subject}")
if content:
report.append("Preview:")
report.append(content[:150] + "..." if len(content) > 150 else content)
report.append("")
# ── LOW priority ───────────────────────────────────────────────────────────
if low_priority:
report.append("✅ LOW PRIORITY EMAILS")
report.append("-" * 40)
for email in low_priority:
sender = email.get("sender", "Unknown")
subject = email.get("subject", "No Subject")
content = email.get("content", "")
report.append(f"\nFrom: {sender}")
report.append(f"Subject: {subject}")
if content:
report.append("Preview:")
report.append(content[:150] + "..." if len(content) > 150 else content)
report.append("")
report.append("=" * 60)
return "\n".join(report)
# ─────────────────────────────────────────────────────────────────────────────
# send_email_report
# ─────────────────────────────────────────────────────────────────────────────
def send_email_report(report_content):
"""Send email report."""
subject = f"OpenClaw Email Check Report - {datetime.now().strftime('%Y-%m-%d %H:%M')}"
report_file = TEMP_DIR / "email_report.txt"
report_file.write_text(report_content, encoding="utf-8")
applescript = f'''tell application "Mail"
set reportContent to do shell script "cat {report_file}"
set newMessage to make new outgoing message with properties {{subject:"{subject}", content:reportContent}}
tell newMessage
make new to recipient at end of to recipients with properties {{address:"{REPORT_RECIPIENT}"}}
end tell
send newMessage
end tell'''
try:
result = subprocess.run(
['osascript', '-e', applescript],
capture_output=True, text=True,
timeout=30
)
if result.returncode != 0:
log(f"send_email_report: osascript error: {result.stderr.strip()}")
return False
log(f"Email report sent to {REPORT_RECIPIENT}")
return True
except subprocess.TimeoutExpired:
log("send_email_report: osascript timed out after 30s")
return False
except Exception as e:
log(f"Failed to send email: {e}")
return False
# ─────────────────────────────────────────────────────────────────────────────
# main
# ─────────────────────────────────────────────────────────────────────────────
def main():
"""Main execution function."""
log("Starting email check...")
emails = get_unread_emails()
if not emails:
report = format_report([])
print(report)
return report
report = format_report(emails)
with open(EMAILS_FILE, "w") as f:
json.dump(
[
{
"sender": e.get("sender"),
"subject": e.get("subject"),
"priority": e.get("analysis", {}).get("priority")
}
for e in emails
],
f, indent=2
)
log("Email check completed")
send_email_report(report)
mark_emails_as_read()
print(report)
return report
if __name__ == "__main__":
main()
FILE:scripts/email/checker_wrapper.sh
#!/bin/bash
# Email Checker - Wrapper script for cron execution
# Runs the main Python checker and logs output
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CRON_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
LOG_DIR="$CRON_DIR/logs"
mkdir -p "$LOG_DIR"
echo "=== Email Check Started: $(date) ===" >> "$LOG_DIR/email_check.log"
# Run the Python checker — wrapper owns all logging, no crontab redirect needed
python3 "$SCRIPT_DIR/checker.py" >> "$LOG_DIR/email_check.log" 2>&1
echo "" >> "$LOG_DIR/email_check.log"
FILE:scripts/email/send_reply.py
#!/usr/bin/env python3
"""
send_reply.py — Send an email reply via Mail.app
Usage:
python3 send_reply.py --to EMAIL --subject SUBJECT --content "Reply text"
python3 send_reply.py --to EMAIL --subject SUBJECT --file reply.txt
Examples:
python3 send_reply.py \
--to [email protected] \
--subject "Re: Quick question" \
--content "Hi, thanks for the note! EntzClawBot 🤖"
python3 send_reply.py \
--to [email protected] \
--subject "Re: Meeting" \
--file /path/to/draft.txt
"""
import subprocess
import argparse
import sys
from datetime import datetime
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent.resolve()
WORKSPACE_DIR = SCRIPT_DIR.parent.parent
LOG_FILE = WORKSPACE_DIR / "logs" / "email_check.log"
TEMP_FILE = WORKSPACE_DIR / "temp" / "send_reply_content.txt"
def log(message):
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
with open(LOG_FILE, "a") as f:
f.write(f"[{timestamp}] {message}\n")
print(f"[{timestamp}] {message}")
def send_reply(to_address, subject, content):
# Write content to temp file — avoids AppleScript string injection issues
# with newlines and quotes in the message body.
TEMP_FILE.parent.mkdir(parents=True, exist_ok=True)
TEMP_FILE.write_text(content, encoding="utf-8")
applescript = f'''tell application "Mail"
set replyContent to do shell script "cat {TEMP_FILE}"
set newMessage to make new outgoing message with properties {{subject:"{subject}", content:replyContent}}
tell newMessage
make new to recipient at end of to recipients with properties {{address:"{to_address}"}}
end tell
send newMessage
return "Sent"
end tell'''
try:
result = subprocess.run(
['osascript', '-e', applescript],
capture_output=True, text=True,
timeout=30
)
if result.returncode != 0:
log(f"Failed to send to {to_address}: {result.stderr.strip()}")
return False
log(f"Reply sent to {to_address} — Subject: {subject}")
return True
except subprocess.TimeoutExpired:
log("send_reply: osascript timed out after 30s")
return False
except Exception as e:
log(f"send_reply: unexpected error: {e}")
return False
def main():
parser = argparse.ArgumentParser(description="Send an email reply via Mail.app")
parser.add_argument("--to", required=True, help="Recipient email address")
parser.add_argument("--subject", required=True, help="Email subject line")
content_group = parser.add_mutually_exclusive_group(required=True)
content_group.add_argument("--content", help="Reply text (inline)")
content_group.add_argument("--file", help="Path to a text file containing the reply")
args = parser.parse_args()
if args.file:
path = Path(args.file)
if not path.exists():
print(f"Error: file not found: {args.file}")
sys.exit(1)
content = path.read_text(encoding="utf-8").strip()
else:
content = args.content.strip()
if not content:
print("Error: reply content is empty")
sys.exit(1)
log(f"Sending reply to {args.to}...")
success = send_reply(args.to, args.subject, content)
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()
FILE:scripts/email/template.py
#!/usr/bin/env python3
"""
[SCRIPT_NAME] - [PURPOSE]
Follows cron organization system:
- Location: scripts/[category]/
- Naming: [purpose]_[type]_[frequency].py
"""
import os
from datetime import datetime
from pathlib import Path
# Configuration - use relative paths for portability
SCRIPT_DIR = Path(__file__).parent.resolve()
WORKSPACE_DIR = SCRIPT_DIR.parent.parent
LOG_DIR = WORKSPACE_DIR / "logs"
TEMP_DIR = WORKSPACE_DIR / "temp"
# Ensure directories exist
LOG_DIR.mkdir(parents=True, exist_ok=True)
TEMP_DIR.mkdir(parents=True, exist_ok=True)
def log(message):
"""Write to log file with timestamp."""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# Use script category folder name for log filename
category = SCRIPT_DIR.name
log_file = LOG_DIR / f"{category}_check.log"
with open(log_file, "a") as f:
f.write(f"[{timestamp}] {message}\n")
def main():
"""Main execution function."""
log("Script started")
# Your code here
log("Script completed successfully")
if __name__ == "__main__":
main()
FILE:scripts/email/template.sh
#!/bin/bash
# [SCRIPT_NAME] - [PURPOSE]
# Source the common functions
SCRIPT_DIR="$(dirname "$0")"
WORKSPACE_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
LOG_DIR="$WORKSPACE_DIR/logs"
TEMP_DIR="$WORKSPACE_DIR/temp"
mkdir -p "$LOG_DIR" "$TEMP_DIR"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_DIR/$(basename "$SCRIPT_DIR")_check.log"
}
main() {
log "Script started"
# Your code here
log "Script completed successfully"
}
main "$@"
FILE:setup.sh
#!/bin/bash
# setup.sh — OpenClaw Email Checker setup wizard
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG_DIR="$SCRIPT_DIR/config"
CONFIG_FILE="$CONFIG_DIR/settings.json"
echo "================================================"
echo " OpenClaw Email Checker — Setup"
echo "================================================"
echo ""
# ── Prerequisites ─────────────────────────────────────────────────────────────
echo "Checking prerequisites..."
errors=0
if ! command -v python3 &>/dev/null; then
echo " ✗ python3 not found — install via Homebrew: brew install python"
errors=$((errors + 1))
else
echo " ✓ python3 $(python3 --version 2>&1 | awk '{print $2}')"
fi
if ! command -v osascript &>/dev/null; then
echo " ✗ osascript not found — macOS only"
errors=$((errors + 1))
else
echo " ✓ osascript"
fi
if [ $errors -gt 0 ]; then
echo ""
echo "Fix the above and re-run setup."
exit 1
fi
echo ""
# ── Existing settings? ────────────────────────────────────────────────────────
if [ -f "$CONFIG_FILE" ]; then
echo "Existing settings.json found."
while true; do
read -r -p "Reconfigure from scratch? [y/N]: " reconfigure
reconfigure="-N"
case "$reconfigure" in
[Yy]) echo ""; break ;;
[Nn]) echo "Skipped. Using existing settings."; exit 0 ;;
*) echo " Please enter y or n." ;;
esac
done
fi
# ── Discover Mail.app accounts ────────────────────────────────────────────────
echo "Discovering Mail.app accounts..."
accounts_raw=$(osascript << 'APPLESCRIPT' 2>/dev/null
tell application "Mail"
set output to {}
repeat with acct in accounts
set end of output to ((id of acct) & ":::" & (name of acct))
end repeat
set AppleScript's text item delimiters to linefeed
set resultText to output as text
set AppleScript's text item delimiters to ""
return resultText
end tell
APPLESCRIPT
) || true
declare -a accounts
if [ -n "$accounts_raw" ]; then
while IFS= read -r line; do
[ -n "$line" ] && accounts+=("$line")
done <<< "$accounts_raw"
fi
if [ #accounts[@] -eq 0 ]; then
echo " Could not auto-discover accounts. Check Mail.app is set up and"
echo " Terminal has Automation permission to control Mail."
echo " (System Settings → Privacy & Security → Automation → Terminal → Mail)"
echo ""
while true; do
read -r -p " Enter Mail.app account ID manually (or Ctrl+C to abort): " MAIL_ACCOUNT_ID
[ -n "$MAIL_ACCOUNT_ID" ] && break
echo " Account ID cannot be empty."
done
else
echo ""
echo "Available Mail.app accounts:"
for i in "!accounts[@]"; do
id_part="::*"
name_part="::"
echo " [$((i + 1))] $name_part ($id_part)"
done
echo ""
while true; do
read -r -p "Select account [1-#accounts[@]]: " acct_num
if [[ "$acct_num" =~ ^[0-9]+$ ]] && \
[ "$acct_num" -ge 1 ] && [ "$acct_num" -le "#accounts[@]" ]; then
chosen="accounts[$((acct_num - 1))]"
MAIL_ACCOUNT_ID="::*"
break
fi
echo " Invalid selection — enter a number between 1 and #accounts[@]."
done
fi
echo ""
# ── User info ─────────────────────────────────────────────────────────────────
while true; do
read -r -p "Your full name (for LLM context): " USER_NAME
[ -n "$USER_NAME" ] && break
echo " Name cannot be empty."
done
read -r -p "Bot name [EntzClawBot]: " BOT_NAME
BOT_NAME="-EntzClawBot"
while true; do
read -r -p "Report recipient email: " REPORT_EMAIL
if [[ "$REPORT_EMAIL" =~ ^[^@]+@[^@]+\.[^@]+$ ]]; then
break
fi
echo " Enter a valid email address (e.g. [email protected])."
done
echo ""
echo "Trusted senders get a priority boost. Matched as substring of From: field."
echo " Examples: 'Angelo' '@company.com' '[email protected]'"
echo " Leave blank to skip."
read -r -p "Trusted senders (comma-separated): " TRUSTED_SENDERS_RAW
echo ""
# ── LLM provider ──────────────────────────────────────────────────────────────
echo "LLM provider:"
echo " [1] LM Studio (local or remote)"
echo " [2] Ollama (local)"
echo " [3] OpenAI"
echo " [4] Skip (no AI drafts)"
echo ""
while true; do
read -r -p "Select provider [1-4]: " llm_choice
case "$llm_choice" in
1)
LLM_PROVIDER="lm_studio"
read -r -p " Base URL [http://localhost:1234/v1]: " LLM_BASE_URL
LLM_BASE_URL="-http://localhost:1234/v1"
read -r -p " API key [local]: " LLM_API_KEY
LLM_API_KEY="-local"
while true; do
read -r -p " Model ID: " LLM_MODEL
[ -n "$LLM_MODEL" ] && break
echo " Model ID is required."
done
break ;;
2)
LLM_PROVIDER="ollama"
LLM_BASE_URL="http://localhost:11434/v1"
LLM_API_KEY="ollama"
read -r -p " Model ID [llama3]: " LLM_MODEL
LLM_MODEL="-llama3"
break ;;
3)
LLM_PROVIDER="openai"
LLM_BASE_URL="https://api.openai.com/v1"
while true; do
read -r -p " OpenAI API key: " LLM_API_KEY
[ -n "$LLM_API_KEY" ] && break
echo " API key is required."
done
read -r -p " Model ID [gpt-4o-mini]: " LLM_MODEL
LLM_MODEL="-gpt-4o-mini"
break ;;
4)
LLM_PROVIDER="none"
LLM_BASE_URL=""
LLM_API_KEY=""
LLM_MODEL=""
break ;;
*) echo " Invalid — enter 1, 2, 3, or 4." ;;
esac
done
echo ""
# ── Test LLM connection ────────────────────────────────────────────────────────
if [ "$LLM_PROVIDER" != "none" ]; then
echo "Testing LLM connection — please wait up to 15 seconds..."
llm_test_output=$(BASE_URL="$LLM_BASE_URL" API_KEY="$LLM_API_KEY" MODEL="$LLM_MODEL" \
python3 - << 'PYEOF' 2>&1
import urllib.request, json, os, sys
url = os.environ['BASE_URL'].rstrip('/') + '/chat/completions'
model = os.environ['MODEL']
key = os.environ['API_KEY']
payload = json.dumps({
'model': model,
'messages': [{'role': 'user', 'content': 'ping'}],
'max_tokens': 5
}).encode()
req = urllib.request.Request(
url, data=payload,
headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + key},
method='POST'
)
try:
with urllib.request.urlopen(req, timeout=15) as r:
body = json.loads(r.read())
if 'choices' in body or 'content' in str(body):
print('OK')
else:
print('UNEXPECTED_RESPONSE: ' + str(body)[:200])
except urllib.error.HTTPError as e:
print('HTTP_ERROR: ' + str(e.code) + ' ' + e.reason)
except urllib.error.URLError as e:
print('CONNECTION_ERROR: ' + str(e.reason))
except Exception as e:
print('ERROR: ' + str(e))
PYEOF
) || true
llm_test_output="-NO_OUTPUT"
if [ "$llm_test_output" = "OK" ]; then
echo " ✓ LLM connection successful"
else
echo " ✗ LLM connection failed: $llm_test_output"
echo ""
echo " Common fixes:"
echo " CONNECTION_ERROR → check the Base URL is reachable from this machine"
echo " HTTP_ERROR 401 → wrong API key"
echo " HTTP_ERROR 404 → wrong Base URL or model ID"
echo " HTTP_ERROR 400 → model ID not found on this server"
echo ""
# Flush any keypresses the user typed while waiting for the LLM test
read -r -t 0.1 -s -d '' _flush 2>/dev/null || true
while true; do
read -r -p " Continue anyway and fix later? [Y/n]: " cont
cont="-Y"
case "$cont" in
[Yy]) break ;;
[Nn]) echo "Aborted. Fix the LLM settings and re-run setup."; exit 1 ;;
*) echo " Please enter y or n." ;;
esac
done
fi
echo ""
fi
# ── Check interval ─────────────────────────────────────────────────────────────
echo "How often should the checker run?"
echo " [1] Every 15 minutes"
echo " [2] Every 30 minutes"
echo " [3] Every hour (default)"
echo " [4] Custom interval"
echo ""
while true; do
read -r -p "Select interval [1-4]: " interval_choice
case "$interval_choice" in
1) INTERVAL_MINUTES=15; break ;;
2) INTERVAL_MINUTES=30; break ;;
3) INTERVAL_MINUTES=60; break ;;
4)
while true; do
read -r -p " Enter interval in minutes: " INTERVAL_MINUTES
if [[ "$INTERVAL_MINUTES" =~ ^[0-9]+$ ]] && [ "$INTERVAL_MINUTES" -ge 1 ]; then
break
fi
echo " Must be a positive whole number."
done
break ;;
*) echo " Invalid — enter 1, 2, 3, or 4." ;;
esac
done
echo ""
# ── Write settings.json ────────────────────────────────────────────────────────
mkdir -p "$CONFIG_DIR"
write_output=$(CONFIG_FILE="$CONFIG_FILE" \
USER_NAME="$USER_NAME" \
BOT_NAME="$BOT_NAME" \
REPORT_EMAIL="$REPORT_EMAIL" \
TRUSTED_SENDERS_RAW="$TRUSTED_SENDERS_RAW" \
MAIL_ACCOUNT_ID="$MAIL_ACCOUNT_ID" \
LLM_PROVIDER="$LLM_PROVIDER" \
LLM_BASE_URL="$LLM_BASE_URL" \
LLM_API_KEY="$LLM_API_KEY" \
LLM_MODEL="$LLM_MODEL" \
INTERVAL_MINUTES="$INTERVAL_MINUTES" \
python3 - << 'PYEOF' 2>&1
import json, os, sys
trusted_raw = os.environ.get('TRUSTED_SENDERS_RAW', '')
trusted = [s.strip() for s in trusted_raw.split(',') if s.strip()]
config = {
"user": {
"name": os.environ['USER_NAME'],
"bot_name": os.environ['BOT_NAME'],
"report_email": os.environ['REPORT_EMAIL'],
"trusted_senders": trusted
},
"mail": {
"account_id": os.environ['MAIL_ACCOUNT_ID'],
"inbox_name": "INBOX"
},
"schedule": {
"interval_minutes": int(os.environ['INTERVAL_MINUTES'])
},
"llm": {
"provider": os.environ['LLM_PROVIDER'],
"base_url": os.environ['LLM_BASE_URL'],
"api_key": os.environ['LLM_API_KEY'],
"model": os.environ['LLM_MODEL'],
"max_tokens": 800,
"timeout": 45
}
}
config_path = os.environ.get('CONFIG_FILE', '')
with open(config_path, 'w') as f:
json.dump(config, f, indent=2)
print('OK')
PYEOF
)
if [ "$write_output" = "OK" ] && [ -f "$CONFIG_FILE" ]; then
echo " ✓ settings.json written to $CONFIG_FILE"
else
echo " ✗ Failed to write settings.json: $write_output"
echo " Setup cannot continue."
exit 1
fi
# ── Install crontab ────────────────────────────────────────────────────────────
WRAPPER="$SCRIPT_DIR/scripts/email/checker_wrapper.sh"
if [ "$INTERVAL_MINUTES" -eq 60 ]; then
CRON_SCHEDULE="0 * * * *"
else
CRON_SCHEDULE="*/$INTERVAL_MINUTES * * * *"
fi
echo ""
echo "Cron schedule: every INTERVAL_MINUTES min + on startup"
echo ""
while true; do
read -r -p "Install/update crontab? [Y/n]: " install_cron
install_cron="-Y"
case "$install_cron" in
[Yy])
( crontab -l 2>/dev/null | grep -v checker_wrapper || true
echo "# Email checker — runs at startup and every INTERVAL_MINUTES min"
echo "@reboot $WRAPPER"
echo "$CRON_SCHEDULE $WRAPPER"
) | crontab -
if [ $? -eq 0 ]; then
echo " ✓ Crontab installed"
else
echo " ✗ Crontab install failed — add manually:"
echo " @reboot $WRAPPER"
echo " $CRON_SCHEDULE $WRAPPER"
fi
break ;;
[Nn]) echo " Skipped crontab."; break ;;
*) echo " Please enter y or n." ;;
esac
done
# ── Permissions reminder ───────────────────────────────────────────────────────
echo ""
echo "================================================"
echo " PERMISSIONS (if not already granted)"
echo "================================================"
echo ""
echo " System Settings → Privacy & Security → Automation"
echo " → Allow Terminal to control Mail"
echo ""
echo " If cron runs fail, also add:"
echo " → Full Disk Access → Terminal (or cron)"
echo ""
# ── Optional test run ──────────────────────────────────────────────────────────
while true; do
read -r -p "Run a test check right now? [Y/n]: " run_test
run_test="-Y"
case "$run_test" in
[Yy])
echo ""
echo "Running test..."
echo "--------------------------------------------"
python3 "$SCRIPT_DIR/scripts/email/checker.py"
test_exit=$?
echo "--------------------------------------------"
if [ $test_exit -eq 0 ]; then
echo " ✓ Test run successful"
else
echo " ✗ Test run exited with code $test_exit"
echo " Check the output above for errors."
fi
break ;;
[Nn]) break ;;
*) echo " Please enter y or n." ;;
esac
done
# ── Done ───────────────────────────────────────────────────────────────────────
echo ""
echo "================================================"
echo " Setup complete!"
echo "================================================"
echo ""
echo " Config: $CONFIG_FILE"
echo " Schedule: every INTERVAL_MINUTES min + on startup"
echo " Reports: $REPORT_EMAIL"
if [ -n "$LLM_MODEL" ]; then
echo " LLM: $LLM_MODEL ($LLM_PROVIDER)"
fi
echo ""
echo " To reconfigure: bash $SCRIPT_DIR/setup.sh"
echo " Logs: $SCRIPT_DIR/logs/email_check.log"
echo ""