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:
Celes Renata
2026-04-16 02:37:40 +00:00
parent 9aae57f3e1
commit c4666c071b
4 changed files with 90 additions and 61 deletions
@@ -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 }}
+5 -1
View File
@@ -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
View File
@@ -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
+25 -45
View File
@@ -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