diff --git a/.env.example b/.env.example index 6d563c6..3ae75d5 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,9 @@ MP_SMTP_TLS_KEY=sans:mailpit.local MP_WEBHOOK_URL=http://sneaky_mon_webhook:8088/hook # SNEAKYMON VARS -GOTIFY_URL=https://yourgotify.server.com -GOTIFY_TOKEN=YourTokenForGotify -MAILPIT_API=http://mailpit:8025 # <-- this points to above, it can even be the IP if you want. \ No newline at end of file +MAILPIT_API=http://mailpit:8025 # <-- this points to above, it can even be the IP if you want. + +# Ntfy notification service +NTFY_URL=https://ntfy.sh +NTFY_TOKEN=YourNtfyAccessToken +NTFY_TOPIC=your-topic-name \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5e8fc3e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,68 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Sneaky Mon is an email-to-notification bridge that receives webhooks from Mailpit (local email service) and forwards emails as push notifications to Gotify or Ntfy. Designed for monitoring system-generated emails (e.g., IoT camera motion alerts) on secure internal networks. + +## Commands + +### Development +```bash +# Setup virtual environment +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt + +# Run Flask server (listens on 0.0.0.0:8088) +python app/main.py + +# Test with hardcoded message ID +python app/dev_main.py +``` + +### Docker +```bash +# Start services (Mailpit + webhook handler) +docker-compose up -d + +# View logs +docker-compose logs -f sneaky_mon_webhook + +# Build and push image +./build_push_image.sh +``` + +## Architecture + +``` +Email Sources → Mailpit (SMTP:1025, API:8025) → Sneaky Mon (POST /hook) → Ntfy/Gotify +``` + +**Data Flow:** +1. Email arrives at Mailpit SMTP +2. Mailpit triggers webhook to `/hook` endpoint +3. Flask app fetches full message from Mailpit API +4. Extracts subject/content, creates notification +5. Sends to Ntfy or Gotify + +## Key Files + +- `app/main.py` - Flask app with `/hook` webhook endpoint and core logic +- `app/utils/gotify_api.py` - GotifyNotifier class for Gotify integration +- `app/utils/ntfy_api.py` - NtfyNotifier class for Ntfy integration +- `docker-compose.yaml` - Orchestrates Mailpit and webhook services + +## Configuration + +All configuration via environment variables (see `.env.example`): +- `MAILPIT_API` / `MAILPIT_TOKEN` - Mailpit REST API access +- `NTFY_URL` / `NTFY_TOKEN` / `NTFY_TOPIC` - Ntfy notification service (primary) +- `GOTIFY_URL` / `GOTIFY_TOKEN` - Gotify notification service (legacy, optional) +- `LOG_LEVEL` - Logging verbosity + +## Current State Notes + +- Project uses Ntfy for push notifications with image attachment support +- Application intentionally lacks TLS/auth - designed for isolated internal VLANs diff --git a/app/dev_main.py b/app/dev_main.py index 48dca23..8641261 100644 --- a/app/dev_main.py +++ b/app/dev_main.py @@ -5,5 +5,5 @@ load_dotenv() from main import handle_hook -msg_id = "jtdx9og7NAQ6LuVvEmRL6t" +msg_id = "42EJJktKsUUaH7zjQ6YoKY" handle_hook(msg_id) diff --git a/app/main.py b/app/main.py index c6f71a6..8bfc8f7 100644 --- a/app/main.py +++ b/app/main.py @@ -24,8 +24,11 @@ load_dotenv() MAILPIT_API = os.getenv("MAILPIT_API", "http://localhost:8025") MAILPIT_TOKEN = os.getenv("MAILPIT_TOKEN", "") -GOTIFY_URL = os.getenv("GOTIFY_URL","") -GOTIFY_TOKEN = os.getenv("GOTIFY_TOKEN","") + +NTFY_URL = os.getenv("NTFY_URL", "") +NTFY_TOKEN = os.getenv("NTFY_TOKEN", "") +NTFY_TOPIC = os.getenv("NTFY_TOPIC", "") + LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() logging.basicConfig( @@ -36,7 +39,7 @@ log = logging.getLogger("mailpit-hook") app = Flask(__name__) -from utils.gotify_api import GotifyNotifier +from utils.ntfy_api import NtfyNotifier # ------------------------------------------------------------------ # # Helpers @@ -53,16 +56,46 @@ def get_mailpit_message(message_id: str) -> Dict[str, Any]: resp.raise_for_status() return resp.json() +def get_mailpit_image_thumb(message_id: str, part_id: str) -> bytes: + """Retrieve image thumbnail from Mailpit REST API as binary data.""" + url = f"{MAILPIT_API}/api/v1/message/{message_id}/part/{part_id}/thumb" + headers = {} + if MAILPIT_TOKEN: + headers["Authorization"] = f"Bearer {MAILPIT_TOKEN}" -def send_gotify(title: str, message: str, priority: int = 5) -> None: - """Send a message to Gotify.""" - notify = GotifyNotifier(GOTIFY_URL, GOTIFY_TOKEN) - - result = notify.gotify(title=title,markdown=message,priority=5) - if not result: - log.warning("Gotify push failed") + resp = requests.get(url, headers=headers, timeout=10) + resp.raise_for_status() + return resp.content + +def get_mailpit_image(message_id: str, part_id: str) -> bytes: + """Retrieve image thumbnail from Mailpit REST API as binary data.""" + url = f"{MAILPIT_API}/api/v1/message/{message_id}/part/{part_id}" + headers = {} + if MAILPIT_TOKEN: + headers["Authorization"] = f"Bearer {MAILPIT_TOKEN}" + + resp = requests.get(url, headers=headers, timeout=10) + resp.raise_for_status() + return resp.content + +def send_notification(title: str, message: str, priority: int = 5, + image_data: bytes = None, filename: str = "image.jpg") -> bool: + """Send a notification to Ntfy, optionally with an image attachment.""" + notify = NtfyNotifier(NTFY_URL, NTFY_TOKEN, NTFY_TOPIC) + + if image_data: + result = notify.send_with_image( + title=title, message=message, image_data=image_data, + filename=filename, priority=priority + ) else: - log.info("Gotify push OK") + result = notify.send(title=title, message=message, priority=priority) + + if not result: + log.warning("Ntfy push failed") + else: + log.info("Ntfy push OK") + return result # ------------------------------------------------------------------ # @@ -97,16 +130,35 @@ def hook(): return jsonify({"error":"Error sending webhook"}), 500 -def handle_hook(msg_id:str): +def handle_hook(msg_id: str): try: msg = get_mailpit_message(msg_id) subject = msg.get("Subject", "(no subject)") text = msg.get("Text", "") or msg.get("HTML", "") + attachments = msg.get("Attachments") or [] + + # Build preview text preview = (text or "") if len(preview) > 200: preview = preview[:200] + "..." - send_gotify(subject, preview) + # Check for image attachments and fetch thumbnail + image_data = None + filename = "image.jpg" + if attachments: + first_attachment = attachments[0] + part_id = first_attachment.get("PartID") + content_type = first_attachment.get("ContentType", "") + if part_id and content_type.startswith("image/"): + try: + # image_data = get_mailpit_image_thumb(msg_id, part_id) + image_data = get_mailpit_image(msg_id, part_id) + # Extract filename from attachment if available + filename = first_attachment.get("FileName", "image.jpg") + except Exception as e: + log.warning("Failed to fetch image thumbnail: %s", e) + + send_notification(subject, preview, image_data=image_data, filename=filename) return True except Exception as e: @@ -119,6 +171,4 @@ def handle_hook(msg_id:str): # ------------------------------------------------------------------ # if __name__ == "__main__": - # msg_id = "ZTUUK57e7kUoaviua6TCgP@mailpit" - # msg = get_mailpit_message(msg_id) app.run(host="0.0.0.0", port=8088) diff --git a/app/utils/ntfy_api.py b/app/utils/ntfy_api.py new file mode 100644 index 0000000..1b7c2a7 --- /dev/null +++ b/app/utils/ntfy_api.py @@ -0,0 +1,81 @@ +import requests + + +class NtfyNotifier: + + def __init__(self, server_url: str, api_token: str, topic: str): + self.base_url = server_url.rstrip('/') + self.token = api_token + self.topic = topic + + def send(self, title: str = "Test", message: str = "testing msg", priority: int = 5) -> bool: + """ + Send a text message to Ntfy. + + Args: + title (str): Title of the notification. + message (str): Body of the notification. + priority (int, optional): Message priority (1-5). Defaults to 5. + + Returns: + bool: True if the message was sent successfully, False otherwise. + """ + if not self.base_url or not self.token or not self.topic: + print("[!] Missing NTFY_URL, NTFY_TOKEN, or NTFY_TOPIC") + return False + + url = f"{self.base_url}/{self.topic}" + + headers = { + "Authorization": f"Bearer {self.token}", + "Title": title, + "Priority": str(priority), + } + + try: + response = requests.post(url, headers=headers, data=message, timeout=10) + response.raise_for_status() + return True + except requests.RequestException as e: + print(f"[!] Failed to send Ntfy message: {e}") + return False + + def send_with_image(self, title: str, message: str, image_data: bytes, + filename: str = "image.jpg", priority: int = 5) -> bool: + """ + Send a notification with an attached image to Ntfy. + + Args: + title (str): Title of the notification. + message (str): Body of the notification. + image_data (bytes): Binary image data to attach. + filename (str): Filename for the attachment. Defaults to "image.jpg". + priority (int, optional): Message priority (1-5). Defaults to 5. + + Returns: + bool: True if the message was sent successfully, False otherwise. + """ + if not self.base_url or not self.token or not self.topic: + print("[!] Missing NTFY_URL, NTFY_TOKEN, or NTFY_TOPIC") + return False + + url = f"{self.base_url}/{self.topic}" + + # Sanitize message for HTTP header (no newlines/carriage returns allowed) + safe_message = message.replace('\r', ' ').replace('\n', ' ').strip() + + headers = { + "Authorization": f"Bearer {self.token}", + "Title": title, + "Priority": str(priority), + "Filename": filename, + "Message": safe_message, + } + + try: + response = requests.put(url, headers=headers, data=image_data, timeout=10) + response.raise_for_status() + return True + except requests.RequestException as e: + print(f"[!] Failed to send Ntfy message with image: {e}") + return False