feat: wire Gmail SMTP notifications with app password
Replaced the Gmail API (OAuth2) notification delivery with plain SMTP using a Gmail app password. Much simpler setup — no Google Cloud project, no OAuth2 flow, no extra dependencies. - Rewrote _send_gmail() to use smtplib with smtp.gmail.com:587 TLS - Added stonks-gmail-secrets to Helm chart (GMAIL_SENDER, GMAIL_RECIPIENT, GMAIL_APP_PASSWORD) - Added gmail secret to trading-engine deployment - Updated runmefirst.sh to read gmail.app from kube dir - Sender/recipient: celes@celestium.life
This commit is contained in:
@@ -49,3 +49,16 @@ stringData:
|
|||||||
{{- range $key, $val := .Values.secrets.dashboard }}
|
{{- range $key, $val := .Values.secrets.dashboard }}
|
||||||
{{ $key }}: {{ $val | quote }}
|
{{ $key }}: {{ $val | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: stonks-gmail-secrets
|
||||||
|
namespace: {{ .Release.Namespace }}
|
||||||
|
labels:
|
||||||
|
{{- include "stonks.labels" . | nindent 4 }}
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
{{- range $key, $val := .Values.secrets.gmail }}
|
||||||
|
{{ $key }}: {{ $val | quote }}
|
||||||
|
{{- end }}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ services:
|
|||||||
command: "uvicorn services.trading.app:app --host 0.0.0.0 --port 8000"
|
command: "uvicorn services.trading.app:app --host 0.0.0.0 --port 8000"
|
||||||
tier: trading
|
tier: trading
|
||||||
port: 8000
|
port: 8000
|
||||||
secrets: [stonks-core-secrets, stonks-broker-secrets]
|
secrets: [stonks-core-secrets, stonks-broker-secrets, stonks-gmail-secrets]
|
||||||
resources:
|
resources:
|
||||||
requests: { cpu: 100m, memory: 256Mi }
|
requests: { cpu: 100m, memory: 256Mi }
|
||||||
limits: { cpu: 500m, memory: 512Mi }
|
limits: { cpu: 500m, memory: 512Mi }
|
||||||
@@ -226,6 +226,10 @@ secrets:
|
|||||||
BROKER_BASE_URL: "https://paper-api.alpaca.markets"
|
BROKER_BASE_URL: "https://paper-api.alpaca.markets"
|
||||||
market:
|
market:
|
||||||
MARKET_DATA_API_KEY: ""
|
MARKET_DATA_API_KEY: ""
|
||||||
|
gmail:
|
||||||
|
GMAIL_SENDER: "celes@celestium.life"
|
||||||
|
GMAIL_RECIPIENT: "celes@celestium.life"
|
||||||
|
GMAIL_APP_PASSWORD: ""
|
||||||
dashboard:
|
dashboard:
|
||||||
SUPERSET_SECRET_KEY: ""
|
SUPERSET_SECRET_KEY: ""
|
||||||
SUPERSET_ADMIN_PASSWORD: ""
|
SUPERSET_ADMIN_PASSWORD: ""
|
||||||
|
|||||||
+47
-15
@@ -5,31 +5,62 @@ NAMESPACE="stonks-oracle"
|
|||||||
REPO_DIR="$HOME/sources/celesrenata/stonks-oracle"
|
REPO_DIR="$HOME/sources/celesrenata/stonks-oracle"
|
||||||
CHART_DIR="$REPO_DIR/infra/helm/stonks-oracle"
|
CHART_DIR="$REPO_DIR/infra/helm/stonks-oracle"
|
||||||
MIGRATIONS_DIR="$REPO_DIR/infra/migrations"
|
MIGRATIONS_DIR="$REPO_DIR/infra/migrations"
|
||||||
|
KUBE_DIR="$HOME/sources/kube/stonks-oracle"
|
||||||
|
|
||||||
# --- Secrets ---
|
# --- Secrets ---
|
||||||
GHCR_TOKEN=$(cat /run/secrets/github_token)
|
# All secrets are read from ~/sources/kube/stonks-oracle/ on gremlin-1.
|
||||||
MINIO_ACCESS_KEY="AKIA6V7J3N9B5P0D2YQH"
|
# This directory is NOT a git repo — secrets stay local to the deploy host.
|
||||||
MINIO_SECRET_KEY='8fG3!v2rJ7$wN@9mLpQ6zXbC4tKdPqW1'
|
#
|
||||||
|
# Required files:
|
||||||
|
# ~/sources/kube/stonks-oracle/polygon.io.key
|
||||||
|
# ~/sources/kube/stonks-oracle/alpaca.key
|
||||||
|
# ~/sources/kube/stonks-oracle/alpaca.secret
|
||||||
|
# ~/sources/kube/stonks-oracle/alpaca.url
|
||||||
|
# /run/secrets/github_token
|
||||||
|
|
||||||
|
_read_secret() {
|
||||||
|
local file="$1"
|
||||||
|
local default="${2:-}"
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
cat "$file" | tr -d '[:space:]'
|
||||||
|
elif [ -n "$default" ]; then
|
||||||
|
echo "$default"
|
||||||
|
else
|
||||||
|
echo "ERROR: Secret file not found: $file" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
GHCR_TOKEN=$(_read_secret /run/secrets/github_token)
|
||||||
PG_PASSWORD='St0nks0racl3!'
|
PG_PASSWORD='St0nks0racl3!'
|
||||||
REDIS_PASSWORD='PSCh4ng3me!'
|
REDIS_PASSWORD='PSCh4ng3me!'
|
||||||
POLYGON_API_KEY=$(cat "$REPO_DIR/polygon.io.key" | tr -d '[:space:]')
|
MINIO_ACCESS_KEY="AKIA6V7J3N9B5P0D2YQH"
|
||||||
ALPACA_API_KEY=$(cat "$REPO_DIR/alpaca.key" | tr -d '[:space:]')
|
MINIO_SECRET_KEY='8fG3!v2rJ7$wN@9mLpQ6zXbC4tKdPqW1'
|
||||||
ALPACA_API_SECRET=$(cat "$REPO_DIR/alpaca.secret" | tr -d '[:space:]')
|
POLYGON_API_KEY=$(_read_secret "$KUBE_DIR/polygon.io.key")
|
||||||
ALPACA_BASE_URL=$(cat "$REPO_DIR/alpaca.url" | tr -d '[:space:]')
|
ALPACA_API_KEY=$(_read_secret "$KUBE_DIR/alpaca.key")
|
||||||
|
ALPACA_API_SECRET=$(_read_secret "$KUBE_DIR/alpaca.secret")
|
||||||
|
ALPACA_BASE_URL=$(_read_secret "$KUBE_DIR/alpaca.url" "https://paper-api.alpaca.markets")
|
||||||
|
GMAIL_APP_PASSWORD=$(_read_secret "$KUBE_DIR/gmail.app" "")
|
||||||
|
|
||||||
echo "=== Stonks Oracle Deployment ==="
|
echo "=== Stonks Oracle Deployment ==="
|
||||||
|
echo "Namespace: $NAMESPACE"
|
||||||
|
echo "Chart: $CHART_DIR"
|
||||||
|
echo "Secrets: $KUBE_DIR"
|
||||||
|
|
||||||
|
# --- 0. Pull latest code ---
|
||||||
|
echo "[0/5] Pulling latest code..."
|
||||||
|
git -C "$REPO_DIR" pull --ff-only || echo "WARNING: git pull failed — using existing code"
|
||||||
|
|
||||||
# --- 1. Ensure namespace exists with correct labels ---
|
# --- 1. Ensure namespace exists with correct labels ---
|
||||||
echo "[1/4] Ensuring namespace $NAMESPACE exists..."
|
echo "[1/5] Ensuring namespace $NAMESPACE exists..."
|
||||||
if ! kubectl get namespace "$NAMESPACE" >/dev/null 2>&1; then
|
if ! kubectl get namespace "$NAMESPACE" >/dev/null 2>&1; then
|
||||||
kubectl create namespace "$NAMESPACE"
|
kubectl create namespace "$NAMESPACE"
|
||||||
fi
|
fi
|
||||||
# Label it so Helm doesn't complain about ownership
|
|
||||||
kubectl label namespace "$NAMESPACE" app.kubernetes.io/managed-by=Helm --overwrite
|
kubectl label namespace "$NAMESPACE" app.kubernetes.io/managed-by=Helm --overwrite
|
||||||
kubectl annotate namespace "$NAMESPACE" meta.helm.sh/release-name=stonks-oracle meta.helm.sh/release-namespace=stonks-oracle --overwrite
|
kubectl annotate namespace "$NAMESPACE" meta.helm.sh/release-name=stonks-oracle meta.helm.sh/release-namespace=stonks-oracle --overwrite
|
||||||
|
|
||||||
# --- 2. Create PostgreSQL user and database ---
|
# --- 2. Create PostgreSQL user and database ---
|
||||||
echo "[2/4] Setting up PostgreSQL database and user..."
|
echo "[2/5] Setting up PostgreSQL database and user..."
|
||||||
kubectl exec -i -n postgresql-service postgresql-1 -c postgres -- psql -U postgres <<EOF
|
kubectl exec -i -n postgresql-service postgresql-1 -c postgres -- psql -U postgres <<EOF
|
||||||
DO \$\$
|
DO \$\$
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -48,7 +79,7 @@ GRANT ALL PRIVILEGES ON DATABASE stonks TO stonks;
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
# --- 3. Run migrations ---
|
# --- 3. Run migrations ---
|
||||||
echo "[3/4] Running database migrations..."
|
echo "[3/5] Running database migrations..."
|
||||||
for f in $(ls "$MIGRATIONS_DIR"/*.sql | sort); do
|
for f in $(ls "$MIGRATIONS_DIR"/*.sql | sort); do
|
||||||
echo " -> $(basename "$f")"
|
echo " -> $(basename "$f")"
|
||||||
kubectl exec -i -n postgresql-service postgresql-1 -c postgres -- psql -U postgres -d stonks < "$f" 2>&1 | grep -v "already exists" || true
|
kubectl exec -i -n postgresql-service postgresql-1 -c postgres -- psql -U postgres -d stonks < "$f" 2>&1 | grep -v "already exists" || true
|
||||||
@@ -63,7 +94,7 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO stonks;
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
# --- 4. Helm deploy ---
|
# --- 4. Helm deploy ---
|
||||||
echo "[4/4] Deploying via Helm..."
|
echo "[4/5] Deploying via Helm..."
|
||||||
helm upgrade --install stonks-oracle "$CHART_DIR" \
|
helm upgrade --install stonks-oracle "$CHART_DIR" \
|
||||||
--namespace "$NAMESPACE" \
|
--namespace "$NAMESPACE" \
|
||||||
--set "ghcrAuth.password=$GHCR_TOKEN" \
|
--set "ghcrAuth.password=$GHCR_TOKEN" \
|
||||||
@@ -74,10 +105,11 @@ helm upgrade --install stonks-oracle "$CHART_DIR" \
|
|||||||
--set "secrets.market.MARKET_DATA_API_KEY=$POLYGON_API_KEY" \
|
--set "secrets.market.MARKET_DATA_API_KEY=$POLYGON_API_KEY" \
|
||||||
--set "secrets.broker.BROKER_API_KEY=$ALPACA_API_KEY" \
|
--set "secrets.broker.BROKER_API_KEY=$ALPACA_API_KEY" \
|
||||||
--set "secrets.broker.BROKER_API_SECRET=$ALPACA_API_SECRET" \
|
--set "secrets.broker.BROKER_API_SECRET=$ALPACA_API_SECRET" \
|
||||||
--set "secrets.broker.BROKER_BASE_URL=$ALPACA_BASE_URL"
|
--set "secrets.broker.BROKER_BASE_URL=$ALPACA_BASE_URL" \
|
||||||
|
--set "secrets.gmail.GMAIL_APP_PASSWORD=$GMAIL_APP_PASSWORD"
|
||||||
|
|
||||||
# --- Rolling restart to pick up secrets ---
|
# --- 5. Rolling restart to pick up new images ---
|
||||||
echo "Rolling restart..."
|
echo "[5/5] Rolling restart..."
|
||||||
for dep in $(kubectl get deployments -n "$NAMESPACE" -o name); do
|
for dep in $(kubectl get deployments -n "$NAMESPACE" -o name); do
|
||||||
kubectl rollout restart -n "$NAMESPACE" "$dep"
|
kubectl rollout restart -n "$NAMESPACE" "$dep"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
"""Notification dispatch for the autonomous trading engine.
|
"""Notification dispatch for the autonomous trading engine.
|
||||||
|
|
||||||
Handles actual delivery of notifications via AWS SNS (SMS) and Gmail API
|
Handles actual delivery of notifications via AWS SNS (SMS) and Gmail SMTP
|
||||||
(email), with rate limiting via Redis, retry with exponential backoff,
|
(email), with rate limiting via Redis, retry with exponential backoff,
|
||||||
and persistence to the notifications table.
|
and persistence to the notifications table.
|
||||||
|
|
||||||
Task 31: Wire notification dispatch.
|
Task 31: Wire notification dispatch.
|
||||||
|
|
||||||
boto3 and google-api-python-client are optional dependencies — the module
|
Gmail uses SMTP with an app password (smtp.gmail.com:587 STARTTLS).
|
||||||
logs a warning and degrades gracefully if they are not installed.
|
boto3 is an optional dependency for SNS — the module degrades gracefully
|
||||||
|
if not installed.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import smtplib
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
from services.shared.config import TradingConfig
|
from services.shared.config import TradingConfig
|
||||||
from services.shared.redis_keys import trading_notification_rate_key
|
from services.shared.redis_keys import trading_notification_rate_key
|
||||||
@@ -30,19 +33,6 @@ except ImportError:
|
|||||||
_HAS_BOTO3 = False
|
_HAS_BOTO3 = False
|
||||||
logger.info("boto3 not installed — SNS notifications disabled")
|
logger.info("boto3 not installed — SNS notifications disabled")
|
||||||
|
|
||||||
# Conditionally import Google API client
|
|
||||||
try:
|
|
||||||
import base64
|
|
||||||
from email.mime.text import MIMEText
|
|
||||||
|
|
||||||
from google.oauth2.credentials import Credentials
|
|
||||||
from googleapiclient.discovery import build as google_build
|
|
||||||
|
|
||||||
_HAS_GOOGLE = True
|
|
||||||
except ImportError:
|
|
||||||
_HAS_GOOGLE = False
|
|
||||||
logger.info("google-api-python-client not installed — Gmail notifications disabled")
|
|
||||||
|
|
||||||
|
|
||||||
class NotificationDispatcher:
|
class NotificationDispatcher:
|
||||||
"""Routes notification delivery to enabled channels.
|
"""Routes notification delivery to enabled channels.
|
||||||
@@ -178,46 +168,36 @@ class NotificationDispatcher:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def _send_gmail(self, event_type: str, message: str) -> None:
|
async def _send_gmail(self, event_type: str, message: str) -> None:
|
||||||
"""Send email via Gmail API."""
|
"""Send email via Gmail SMTP with app password (TLS on port 587)."""
|
||||||
if not _HAS_GOOGLE:
|
|
||||||
raise RuntimeError(
|
|
||||||
"google-api-python-client not installed — cannot send Gmail notification"
|
|
||||||
)
|
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
await loop.run_in_executor(None, self._send_gmail_sync, event_type, message)
|
await loop.run_in_executor(None, self._send_gmail_sync, event_type, message)
|
||||||
|
|
||||||
def _send_gmail_sync(self, event_type: str, message: str) -> None:
|
def _send_gmail_sync(self, event_type: str, message: str) -> None:
|
||||||
"""Synchronous Gmail send (runs in executor)."""
|
"""Synchronous Gmail SMTP send (runs in executor)."""
|
||||||
import os
|
import os
|
||||||
|
|
||||||
refresh_token = os.getenv("GMAIL_REFRESH_TOKEN", "")
|
sender = self.config.gmail_sender or os.getenv("GMAIL_SENDER", "")
|
||||||
client_id = os.getenv("GMAIL_CLIENT_ID", "")
|
recipient = self.config.gmail_recipient or os.getenv("GMAIL_RECIPIENT", "")
|
||||||
client_secret = os.getenv("GMAIL_CLIENT_SECRET", "")
|
app_password = os.getenv("GMAIL_APP_PASSWORD", "")
|
||||||
|
|
||||||
if not all([refresh_token, client_id, client_secret]):
|
if not all([sender, recipient, app_password]):
|
||||||
raise RuntimeError("Gmail OAuth2 credentials not configured")
|
raise RuntimeError(
|
||||||
|
"Gmail SMTP not configured — need GMAIL_SENDER, GMAIL_RECIPIENT, "
|
||||||
creds = Credentials(
|
"and GMAIL_APP_PASSWORD env vars"
|
||||||
token=None,
|
)
|
||||||
refresh_token=refresh_token,
|
|
||||||
client_id=client_id,
|
|
||||||
client_secret=client_secret,
|
|
||||||
token_uri="https://oauth2.googleapis.com/token",
|
|
||||||
)
|
|
||||||
|
|
||||||
service = google_build("gmail", "v1", credentials=creds)
|
|
||||||
|
|
||||||
subject = f"[Stonks Alert] {event_type.replace('_', ' ').title()}"
|
subject = f"[Stonks Alert] {event_type.replace('_', ' ').title()}"
|
||||||
mime_msg = MIMEText(message)
|
mime_msg = MIMEText(message)
|
||||||
mime_msg["to"] = self.config.gmail_recipient
|
mime_msg["To"] = recipient
|
||||||
mime_msg["from"] = self.config.gmail_sender or "me"
|
mime_msg["From"] = sender
|
||||||
mime_msg["subject"] = subject
|
mime_msg["Subject"] = subject
|
||||||
|
|
||||||
raw = base64.urlsafe_b64encode(mime_msg.as_bytes()).decode()
|
with smtplib.SMTP("smtp.gmail.com", 587) as server:
|
||||||
service.users().messages().send(
|
server.starttls()
|
||||||
userId="me", body={"raw": raw}
|
server.login(sender, app_password)
|
||||||
).execute()
|
server.send_message(mime_msg)
|
||||||
|
|
||||||
|
logger.info("Email sent: %s → %s (%s)", sender, recipient, event_type)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Rate limiting via Redis
|
# Rate limiting via Redis
|
||||||
|
|||||||
Reference in New Issue
Block a user