migrate from gotify to ntfy with image attachment support
- Replace GotifyNotifier with NtfyNotifier for push notifications - Add support for sending email image attachments to ntfy - Add NTFY_URL, NTFY_TOKEN, NTFY_TOPIC environment variables - Add get_mailpit_image() and get_mailpit_image_thumb() helpers - Sanitize message content for HTTP headers (remove newlines) - Remove all gotify references 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,9 @@ MP_SMTP_TLS_KEY=sans:mailpit.local
|
|||||||
MP_WEBHOOK_URL=http://sneaky_mon_webhook:8088/hook
|
MP_WEBHOOK_URL=http://sneaky_mon_webhook:8088/hook
|
||||||
|
|
||||||
# SNEAKYMON VARS
|
# SNEAKYMON VARS
|
||||||
GOTIFY_URL=https://yourgotify.server.com
|
MAILPIT_API=http://mailpit:8025 # <-- this points to above, it can even be the IP if you want.
|
||||||
GOTIFY_TOKEN=YourTokenForGotify
|
|
||||||
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
|
||||||
68
CLAUDE.md
Normal file
68
CLAUDE.md
Normal file
@@ -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
|
||||||
@@ -5,5 +5,5 @@ load_dotenv()
|
|||||||
|
|
||||||
from main import handle_hook
|
from main import handle_hook
|
||||||
|
|
||||||
msg_id = "jtdx9og7NAQ6LuVvEmRL6t"
|
msg_id = "42EJJktKsUUaH7zjQ6YoKY"
|
||||||
handle_hook(msg_id)
|
handle_hook(msg_id)
|
||||||
|
|||||||
80
app/main.py
80
app/main.py
@@ -24,8 +24,11 @@ load_dotenv()
|
|||||||
|
|
||||||
MAILPIT_API = os.getenv("MAILPIT_API", "http://localhost:8025")
|
MAILPIT_API = os.getenv("MAILPIT_API", "http://localhost:8025")
|
||||||
MAILPIT_TOKEN = os.getenv("MAILPIT_TOKEN", "")
|
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()
|
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -36,7 +39,7 @@ log = logging.getLogger("mailpit-hook")
|
|||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
from utils.gotify_api import GotifyNotifier
|
from utils.ntfy_api import NtfyNotifier
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Helpers
|
# Helpers
|
||||||
@@ -53,16 +56,46 @@ def get_mailpit_message(message_id: str) -> Dict[str, Any]:
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return resp.json()
|
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:
|
resp = requests.get(url, headers=headers, timeout=10)
|
||||||
"""Send a message to Gotify."""
|
resp.raise_for_status()
|
||||||
notify = GotifyNotifier(GOTIFY_URL, GOTIFY_TOKEN)
|
return resp.content
|
||||||
|
|
||||||
result = notify.gotify(title=title,markdown=message,priority=5)
|
def get_mailpit_image(message_id: str, part_id: str) -> bytes:
|
||||||
if not result:
|
"""Retrieve image thumbnail from Mailpit REST API as binary data."""
|
||||||
log.warning("Gotify push failed")
|
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:
|
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
|
return jsonify({"error":"Error sending webhook"}), 500
|
||||||
|
|
||||||
|
|
||||||
def handle_hook(msg_id:str):
|
def handle_hook(msg_id: str):
|
||||||
try:
|
try:
|
||||||
msg = get_mailpit_message(msg_id)
|
msg = get_mailpit_message(msg_id)
|
||||||
subject = msg.get("Subject", "(no subject)")
|
subject = msg.get("Subject", "(no subject)")
|
||||||
text = msg.get("Text", "") or msg.get("HTML", "")
|
text = msg.get("Text", "") or msg.get("HTML", "")
|
||||||
|
attachments = msg.get("Attachments") or []
|
||||||
|
|
||||||
|
# Build preview text
|
||||||
preview = (text or "")
|
preview = (text or "")
|
||||||
if len(preview) > 200:
|
if len(preview) > 200:
|
||||||
preview = 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
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -119,6 +171,4 @@ def handle_hook(msg_id:str):
|
|||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
# msg_id = "ZTUUK57e7kUoaviua6TCgP@mailpit"
|
|
||||||
# msg = get_mailpit_message(msg_id)
|
|
||||||
app.run(host="0.0.0.0", port=8088)
|
app.run(host="0.0.0.0", port=8088)
|
||||||
|
|||||||
81
app/utils/ntfy_api.py
Normal file
81
app/utils/ntfy_api.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user