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 }}
{{ $key }}: {{ $val | quote }}
{{- 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"
tier: trading
port: 8000
secrets: [stonks-core-secrets, stonks-broker-secrets]
secrets: [stonks-core-secrets, stonks-broker-secrets, stonks-gmail-secrets]
resources:
requests: { cpu: 100m, memory: 256Mi }
limits: { cpu: 500m, memory: 512Mi }
@@ -226,6 +226,10 @@ secrets:
BROKER_BASE_URL: "https://paper-api.alpaca.markets"
market:
MARKET_DATA_API_KEY: ""
gmail:
GMAIL_SENDER: "celes@celestium.life"
GMAIL_RECIPIENT: "celes@celestium.life"
GMAIL_APP_PASSWORD: ""
dashboard:
SUPERSET_SECRET_KEY: ""
SUPERSET_ADMIN_PASSWORD: ""
+47 -15
View File
@@ -5,31 +5,62 @@ NAMESPACE="stonks-oracle"
REPO_DIR="$HOME/sources/celesrenata/stonks-oracle"
CHART_DIR="$REPO_DIR/infra/helm/stonks-oracle"
MIGRATIONS_DIR="$REPO_DIR/infra/migrations"
KUBE_DIR="$HOME/sources/kube/stonks-oracle"
# --- Secrets ---
GHCR_TOKEN=$(cat /run/secrets/github_token)
MINIO_ACCESS_KEY="AKIA6V7J3N9B5P0D2YQH"
MINIO_SECRET_KEY='8fG3!v2rJ7$wN@9mLpQ6zXbC4tKdPqW1'
# All secrets are read from ~/sources/kube/stonks-oracle/ on gremlin-1.
# This directory is NOT a git repo — secrets stay local to the deploy host.
#
# 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!'
REDIS_PASSWORD='PSCh4ng3me!'
POLYGON_API_KEY=$(cat "$REPO_DIR/polygon.io.key" | tr -d '[:space:]')
ALPACA_API_KEY=$(cat "$REPO_DIR/alpaca.key" | tr -d '[:space:]')
ALPACA_API_SECRET=$(cat "$REPO_DIR/alpaca.secret" | tr -d '[:space:]')
ALPACA_BASE_URL=$(cat "$REPO_DIR/alpaca.url" | tr -d '[:space:]')
MINIO_ACCESS_KEY="AKIA6V7J3N9B5P0D2YQH"
MINIO_SECRET_KEY='8fG3!v2rJ7$wN@9mLpQ6zXbC4tKdPqW1'
POLYGON_API_KEY=$(_read_secret "$KUBE_DIR/polygon.io.key")
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 "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 ---
echo "[1/4] Ensuring namespace $NAMESPACE exists..."
echo "[1/5] Ensuring namespace $NAMESPACE exists..."
if ! kubectl get namespace "$NAMESPACE" >/dev/null 2>&1; then
kubectl create namespace "$NAMESPACE"
fi
# Label it so Helm doesn't complain about ownership
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
# --- 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
DO \$\$
BEGIN
@@ -48,7 +79,7 @@ GRANT ALL PRIVILEGES ON DATABASE stonks TO stonks;
EOF
# --- 3. Run migrations ---
echo "[3/4] Running database migrations..."
echo "[3/5] Running database migrations..."
for f in $(ls "$MIGRATIONS_DIR"/*.sql | sort); do
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
@@ -63,7 +94,7 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO stonks;
EOF
# --- 4. Helm deploy ---
echo "[4/4] Deploying via Helm..."
echo "[4/5] Deploying via Helm..."
helm upgrade --install stonks-oracle "$CHART_DIR" \
--namespace "$NAMESPACE" \
--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.broker.BROKER_API_KEY=$ALPACA_API_KEY" \
--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 ---
echo "Rolling restart..."
# --- 5. Rolling restart to pick up new images ---
echo "[5/5] Rolling restart..."
for dep in $(kubectl get deployments -n "$NAMESPACE" -o name); do
kubectl rollout restart -n "$NAMESPACE" "$dep"
done
+24 -44
View File
@@ -1,20 +1,23 @@
"""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,
and persistence to the notifications table.
Task 31: Wire notification dispatch.
boto3 and google-api-python-client are optional dependencies — the module
logs a warning and degrades gracefully if they are not installed.
Gmail uses SMTP with an app password (smtp.gmail.com:587 STARTTLS).
boto3 is an optional dependency for SNS — the module degrades gracefully
if not installed.
"""
from __future__ import annotations
import asyncio
import logging
import smtplib
from datetime import datetime, timedelta, timezone
from email.mime.text import MIMEText
from services.shared.config import TradingConfig
from services.shared.redis_keys import trading_notification_rate_key
@@ -30,19 +33,6 @@ except ImportError:
_HAS_BOTO3 = False
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:
"""Routes notification delivery to enabled channels.
@@ -178,46 +168,36 @@ class NotificationDispatcher:
)
async def _send_gmail(self, event_type: str, message: str) -> None:
"""Send email via Gmail API."""
if not _HAS_GOOGLE:
raise RuntimeError(
"google-api-python-client not installed — cannot send Gmail notification"
)
"""Send email via Gmail SMTP with app password (TLS on port 587)."""
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._send_gmail_sync, event_type, message)
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
refresh_token = os.getenv("GMAIL_REFRESH_TOKEN", "")
client_id = os.getenv("GMAIL_CLIENT_ID", "")
client_secret = os.getenv("GMAIL_CLIENT_SECRET", "")
sender = self.config.gmail_sender or os.getenv("GMAIL_SENDER", "")
recipient = self.config.gmail_recipient or os.getenv("GMAIL_RECIPIENT", "")
app_password = os.getenv("GMAIL_APP_PASSWORD", "")
if not all([refresh_token, client_id, client_secret]):
raise RuntimeError("Gmail OAuth2 credentials not configured")
creds = Credentials(
token=None,
refresh_token=refresh_token,
client_id=client_id,
client_secret=client_secret,
token_uri="https://oauth2.googleapis.com/token",
if not all([sender, recipient, app_password]):
raise RuntimeError(
"Gmail SMTP not configured — need GMAIL_SENDER, GMAIL_RECIPIENT, "
"and GMAIL_APP_PASSWORD env vars"
)
service = google_build("gmail", "v1", credentials=creds)
subject = f"[Stonks Alert] {event_type.replace('_', ' ').title()}"
mime_msg = MIMEText(message)
mime_msg["to"] = self.config.gmail_recipient
mime_msg["from"] = self.config.gmail_sender or "me"
mime_msg["subject"] = subject
mime_msg["To"] = recipient
mime_msg["From"] = sender
mime_msg["Subject"] = subject
raw = base64.urlsafe_b64encode(mime_msg.as_bytes()).decode()
service.users().messages().send(
userId="me", body={"raw": raw}
).execute()
with smtplib.SMTP("smtp.gmail.com", 587) as server:
server.starttls()
server.login(sender, app_password)
server.send_message(mime_msg)
logger.info("Email sent: %s%s (%s)", sender, recipient, event_type)
# ------------------------------------------------------------------
# Rate limiting via Redis