diff --git a/gwtm-helm/templates/_helpers.tpl b/gwtm-helm/templates/_helpers.tpl index 80db243..6c7e1b5 100644 --- a/gwtm-helm/templates/_helpers.tpl +++ b/gwtm-helm/templates/_helpers.tpl @@ -127,6 +127,14 @@ secretKeyRef: name: {{ include "gwtm.secretName" . }} key: mail-password +- name: RESEND_API_KEY + valueFrom: + secretKeyRef: + name: {{ include "gwtm.secretName" . }} + key: resend-api-key + # Optional: existing secrets may not have this key yet. When absent the + # env var is simply unset and the app falls back to SMTP. + optional: true - name: RECAPTCHA_PUBLIC_KEY valueFrom: secretKeyRef: diff --git a/gwtm-helm/templates/fastapi/deployment.yaml b/gwtm-helm/templates/fastapi/deployment.yaml index 2201f8a..ead57da 100644 --- a/gwtm-helm/templates/fastapi/deployment.yaml +++ b/gwtm-helm/templates/fastapi/deployment.yaml @@ -75,6 +75,8 @@ spec: secretKeyRef: name: {{ include "gwtm.secretName" . }} key: MAIL_PORT + - name: RESEND_FROM + value: {{ .Values.fastapi.env.RESEND_FROM | default "Gravitational-Wave Treasure Map " | quote }} {{- include "gwtm.storageEnv" . | nindent 8 }} - name: DEBUG value: "{{ .Values.global.environment | eq "development" | ternary "True" "False" }}" diff --git a/gwtm-helm/templates/secrets.yaml b/gwtm-helm/templates/secrets.yaml index 75fa363..707dfcb 100644 --- a/gwtm-helm/templates/secrets.yaml +++ b/gwtm-helm/templates/secrets.yaml @@ -34,7 +34,13 @@ data: {{- else }} mail-password: {{ randAlphaNum 16 | b64enc | quote }} {{- end }} - + + {{- if .Values.secrets.resendApiKey }} + resend-api-key: {{ .Values.secrets.resendApiKey | b64enc | quote }} + {{- else }} + resend-api-key: {{ "" | b64enc | quote }} + {{- end }} + {{- if .Values.secrets.mailUsername }} MAIL_USERNAME: {{ .Values.secrets.mailUsername | b64enc | quote }} {{- else }} diff --git a/gwtm-helm/values-argocd.yaml b/gwtm-helm/values-argocd.yaml index a68ddf0..13d67f0 100644 --- a/gwtm-helm/values-argocd.yaml +++ b/gwtm-helm/values-argocd.yaml @@ -58,6 +58,7 @@ secrets: # Configure these for production: mailPassword: "" + resendApiKey: "" # Resend API key; when set, Resend is used instead of SMTP mailUsername: "" mailDefaultSender: "" mailServer: "smtp.gmail.com" diff --git a/gwtm-helm/values.yaml b/gwtm-helm/values.yaml index d366bee..d75ec7d 100644 --- a/gwtm-helm/values.yaml +++ b/gwtm-helm/values.yaml @@ -116,6 +116,7 @@ secrets: # Mail configuration mailPassword: "" + resendApiKey: "" mailUsername: "" mailDefaultSender: "" mailServer: "smtp.gmail.com" diff --git a/server/config.py b/server/config.py index 9ab4a8e..851d18d 100644 --- a/server/config.py +++ b/server/config.py @@ -35,6 +35,12 @@ class Settings(BaseSettings): MAIL_PORT: int = Field(465, env="MAIL_PORT") MAIL_USE_TLS: bool = Field(False, env="MAIL_USE_TLS") MAIL_USE_SSL: bool = Field(True, env="MAIL_USE_SSL") + # Resend transactional email (preferred over SMTP when an API key is set) + RESEND_API_KEY: str = Field("", env="RESEND_API_KEY") + RESEND_FROM: str = Field( + "Gravitational-Wave Treasure Map ", + env="RESEND_FROM", + ) # Admin settings ADMINS: str = Field("gwtreasuremap@gmail.com", env="ADMINS") diff --git a/server/requirements.txt b/server/requirements.txt index 953cf0f..a813c0a 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -34,6 +34,7 @@ keystoneauth1>=5.0.0 mocpy>=0.12.0 pygcn>=0.1.8 python-dotenv>=1.0.0 +resend>=2.0.0 aiofiles>=0.8.0 pytest>=7.3.1 httpx>=0.24.1 diff --git a/server/utils/email.py b/server/utils/email.py index c902c1a..27d8d8a 100644 --- a/server/utils/email.py +++ b/server/utils/email.py @@ -16,6 +16,25 @@ BASE_URL = settings.BASE_URL SMTP_TIMEOUT_SECONDS = 10 +RESEND_API_KEY = settings.RESEND_API_KEY +RESEND_FROM = settings.RESEND_FROM + + +def _send_resend(recipient: str, subject: str, html: str, text: str) -> None: + """Blocking Resend send. Caller is responsible for offloading to a worker thread.""" + import resend + + resend.api_key = RESEND_API_KEY + resend.Emails.send( + { + "from": RESEND_FROM, + "to": [recipient], + "subject": subject, + "html": html, + "text": text, + } + ) + def _send_smtp(recipient: str, message_str: str) -> None: """Blocking SMTP send. Caller is responsible for offloading to a worker thread.""" @@ -166,25 +185,33 @@ async def send_verification_email( logger.info("Sending verification email to %s", email) - message = MIMEMultipart("alternative") - message["Subject"] = subject - message["From"] = SENDER_EMAIL - message["To"] = email - message.attach(MIMEText(text_content, "plain")) - message.attach(MIMEText(html_content, "html")) - - if not SMTP_SERVER: - if settings.DEVELOPMENT_MODE: - # Dev fallback only: log the full URL so developers can verify manually. - logger.warning( - "SMTP not configured — verification URL for %s: %s", email, verification_url - ) - else: - logger.warning("SMTP not configured — skipping verification email to %s", email) + # Preferred transport: Resend. The SDK is blocking, so run it in a worker + # thread to avoid stalling the event loop. Exceptions propagate to the caller. + if RESEND_API_KEY: + await asyncio.to_thread( + _send_resend, email, subject, html_content, text_content + ) return True - # smtplib is blocking; run the send in a worker thread so we don't stall - # the event loop while we wait on the network. - await asyncio.to_thread(_send_smtp, email, message.as_string()) + if SMTP_SERVER: + message = MIMEMultipart("alternative") + message["Subject"] = subject + message["From"] = SENDER_EMAIL + message["To"] = email + message.attach(MIMEText(text_content, "plain")) + message.attach(MIMEText(html_content, "html")) + + # smtplib is blocking; run the send in a worker thread so we don't stall + # the event loop while we wait on the network. + await asyncio.to_thread(_send_smtp, email, message.as_string()) + return True + # No transport configured. + if settings.DEVELOPMENT_MODE: + # Dev fallback only: log the full URL so developers can verify manually. + logger.warning( + "Email not configured — verification URL for %s: %s", email, verification_url + ) + else: + logger.warning("Email not configured — skipping verification email to %s", email) return True