walletpress/backend/core/email_notify.py
cryptorugmunch e13bd4d774
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / security (push) Waiting to run
CI / pre-commit (push) Waiting to run
CI / license (push) Waiting to run
CI / ai-review (push) Waiting to run
docs: apply fleet-template (16-artifact scaffold)
Adds missing standard artifacts:
- README.md (if missing)
- AGENTS.md (AI agent contract)
- PLAN.md (current sprint)
- STATUS.md (where we are)
- DEVELOPMENT.md (dev workflow)
- DEPLOYMENT.md (deploy procedure)
- TESTING.md (test strategy)
- DECISIONS.md (ADR index + templates)
- .github/CODEOWNERS
- .github/workflows/ci.yml

Preserves all existing artifacts.

Refs: RugMunchMedia/fleet-template
2026-07-02 02:07:06 +07:00

189 lines
4.8 KiB
Python

"""Email notification system for wallet events.
Sends transactional emails via SMTP for:
- Wallet generated (confirmation with address)
- Payment received (amount + tx hash)
- Low balance alert (wallet below threshold)
- Rotation due (wallet scheduled for rotation)
- Security alert (suspicious activity detected)
Trust: Email credentials are stored in env vars, never logged.
All email sending is async and non-blocking.
"""
from __future__ import annotations
import asyncio
import logging
import os
import smtplib
import ssl
from dataclasses import dataclass
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Optional
logger = logging.getLogger("wp.email")
SMTP_HOST = os.getenv("WP_SMTP_HOST", "")
SMTP_PORT = int(os.getenv("WP_SMTP_PORT", "587"))
SMTP_USER = os.getenv("WP_SMTP_USER", "")
SMTP_PASS = os.getenv("WP_SMTP_PASS", "")
FROM_EMAIL = os.getenv("WP_FROM_EMAIL", "walletpress@localhost")
TO_EMAIL = os.getenv("WP_NOTIFY_EMAIL", "")
@dataclass
class EmailMessage:
subject: str
body_text: str
body_html: str = ""
TEMPLATES = {
"wallet.generated": {
"subject": "WalletPress — Wallet Generated: {chain}",
"body": """Wallet Generated
Chain: {chain}
Address: {address}
Label: {label}
Time: {time}
This wallet is stored in your encrypted vault.
Use GET /vault/{{id}} with your API key to retrieve the private key.
---
WalletPress — Self-Hosted Multi-Chain Wallet Generator
""",
},
"payment.received": {
"subject": "WalletPress — Payment Received: ${amount}",
"body": """Payment Received
Amount: ${amount}
Token: {token}
Chain: {chain}
From: {from_address}
TX: {tx_hash}
Time: {time}
---
WalletPress
""",
},
"balance.alert": {
"subject": "WalletPress — Low Balance Alert: {wallet_id}",
"body": """Low Balance Alert
Wallet: {wallet_id}
Chain: {chain}
Address: {address}
Current Balance: ${balance_usd}
Threshold: ${threshold}
Time: {time}
Action recommended: Fund this wallet or rotate to a new one.
---
WalletPress
""",
},
"rotation.due": {
"subject": "WalletPress — Rotation Due: {wallet_id}",
"body": """Wallet Rotation Due
Wallet: {wallet_id}
Chain: {chain}
Address: {address}
Rotation Due: {due_date}
Rotate this wallet to maintain security best practices.
---
WalletPress
""",
},
"security.alert": {
"subject": "WalletPress — Security Alert: {event}",
"body": """Security Alert
Event: {event}
Wallet: {wallet_id}
Time: {time}
Details: {details}
Review your vault access immediately if this was unexpected.
---
WalletPress
""",
},
}
class EmailNotifier:
"""Async email notification sender via SMTP."""
def __init__(self):
self._enabled = bool(SMTP_HOST and SMTP_USER and SMTP_PASS and TO_EMAIL)
async def send_event(self, event_type: str, data: dict) -> bool:
"""Send a notification email for a wallet event.
Args:
event_type: One of wallet.generated, payment.received, etc.
data: Template variables (chain, address, amount, etc.)
Returns:
True if email was sent successfully.
"""
if not self._enabled:
logger.debug(f"Email not configured, skipping {event_type}")
return False
template = TEMPLATES.get(event_type)
if not template:
logger.warning(f"No email template for {event_type}")
return False
subject = template["subject"].format(**data)
body = template["body"].format(**data)
return await self._send(subject, body)
async def _send(self, subject: str, body: str) -> bool:
"""Send email via SMTP in executor to avoid blocking."""
loop = asyncio.get_event_loop()
try:
await loop.run_in_executor(None, self._send_sync, subject, body)
logger.info(f"Email sent: {subject[:50]}...")
return True
except Exception as e:
logger.error(f"Email send failed: {e}")
return False
def _send_sync(self, subject: str, body: str):
"""Synchronous SMTP send (runs in thread pool)."""
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = FROM_EMAIL
msg["To"] = TO_EMAIL
msg.attach(MIMEText(body, "plain", "utf-8"))
msg.attach(MIMEText(f"<pre style='font:14px/1.5 monospace'>{body}</pre>", "html", "utf-8"))
ctx = ssl.create_default_context()
with smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=15) as server:
server.starttls(context=ctx)
server.login(SMTP_USER, SMTP_PASS)
server.sendmail(FROM_EMAIL, [TO_EMAIL], msg.as_string())
_notifier: Optional[EmailNotifier] = None
def get_notifier() -> EmailNotifier:
global _notifier
if _notifier is None:
_notifier = EmailNotifier()
return _notifier