From c4666c071b8b0a749b2cafb68703667e47ded25d Mon Sep 17 00:00:00 2001 From: Celes Renata Date: Thu, 16 Apr 2026 02:37:40 +0000 Subject: [PATCH] feat: wire Gmail SMTP notifications with app password MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../helm/stonks-oracle/templates/secrets.yaml | 13 ++++ infra/helm/stonks-oracle/values.yaml | 6 +- runmefirst.sh | 62 ++++++++++++---- services/trading/notification_dispatch.py | 70 +++++++------------ 4 files changed, 90 insertions(+), 61 deletions(-) diff --git a/infra/helm/stonks-oracle/templates/secrets.yaml b/infra/helm/stonks-oracle/templates/secrets.yaml index 646bb93..43a0817 100644 --- a/infra/helm/stonks-oracle/templates/secrets.yaml +++ b/infra/helm/stonks-oracle/templates/secrets.yaml @@ -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 }} diff --git a/infra/helm/stonks-oracle/values.yaml b/infra/helm/stonks-oracle/values.yaml index 580688a..620a39c 100644 --- a/infra/helm/stonks-oracle/values.yaml +++ b/infra/helm/stonks-oracle/values.yaml @@ -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: "" diff --git a/runmefirst.sh b/runmefirst.sh index 8efce55..2fc57fe 100755 --- a/runmefirst.sh +++ b/runmefirst.sh @@ -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 < $(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 diff --git a/services/trading/notification_dispatch.py b/services/trading/notification_dispatch.py index f9da98e..5c8ba6e 100644 --- a/services/trading/notification_dispatch.py +++ b/services/trading/notification_dispatch.py @@ -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", - ) - - service = google_build("gmail", "v1", credentials=creds) + if not all([sender, recipient, app_password]): + raise RuntimeError( + "Gmail SMTP not configured — need GMAIL_SENDER, GMAIL_RECIPIENT, " + "and GMAIL_APP_PASSWORD env vars" + ) 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