Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,22 @@ DB_PASSWORD=password
DB_HOST=hostname
DB_PORT=port

# Email server configuration with AWS SES, can be left as placeholders if not sending emails
# Email configuration
DEFAULT_FROM_EMAIL=email
# AWS SES for production, can be left as placeholders in development
AWS_SES_ACCESS_KEY_ID=access_key_id
AWS_SES_SECRET_ACCESS_KEY=secret_access_key
AWS_SES_REGION_NAME=region_name
AWS_SES_REGION_ENDPOINT=region_endpoint
DEFAULT_FROM_EMAIL=email
# SMTP for development, can also be left as placeholders if SEND_EMAILS is False
SMTP_HOST=host
SMTP_PORT=port
SMTP_HOST_USER=user
SMTP_HOST_PASSWORD=password
# Secret key for authorization into the Next.js email template renderer
# Must be the same as the one in the frontend .env file
# Can be left as a placeholder for development
EMAIL_BRIDGE_SECRET=secret

# The URL of the main site, used for generating links (no trailing slash)
BASE_URL=https://example.com
Expand Down
17 changes: 7 additions & 10 deletions backend/api/account/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

import bcrypt
from device_detector import DeviceDetector
from django.core.mail import send_mail
from django.db import transaction
from django.db.models import Q
from rest_framework.response import Response
Expand All @@ -30,15 +29,16 @@
ACCOUNT_COOKIE_NAME,
AUTHED_PWD_RESET_EXP_SECONDS,
LONG_SESS_EXP_SECONDS,
SEND_EMAILS,
SESS_EXP_SECONDS,
ThrottleScopes,
)
from api.utils import (
EmailTemplateKey,
MessageOutputSerializer,
check_rate_limit,
delete_session_cookie,
prune_account_sessions,
send_templated_email,
)

logger = logging.getLogger("api")
Expand Down Expand Up @@ -244,14 +244,11 @@ def start_authed_password_reset(request):
)
logger.debug("Authed password reset code for %s: %s", user.email, reset_code)

if SEND_EMAILS:
send_mail(
subject="Plancake - Password Reset Code",
message=f"Your password reset code is: {reset_code}.\n\nNot you? You should change your password immediately to protect your account.",
from_email=None, # Use the default from settings
recipient_list=[user.email],
fail_silently=False,
)
send_templated_email(
to_email=user.email,
template_key=EmailTemplateKey.PASSWORD_RESET_CODE,
context={"code": reset_code},
)

return Response(
{"message": ["An email has been sent to your address with the reset code."]},
Expand Down
57 changes: 22 additions & 35 deletions backend/api/auth/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from datetime import datetime, timedelta

import bcrypt
from django.core.mail import send_mail
from django.db import transaction
from rest_framework.response import Response

Expand Down Expand Up @@ -31,19 +30,19 @@
)
from api.settings import (
ACCOUNT_COOKIE_NAME,
BASE_URL,
EMAIL_CODE_EXP_SECONDS,
PWD_RESET_EXP_SECONDS,
SEND_EMAILS,
ThrottleScopes,
)
from api.utils import (
EmailTemplateKey,
MessageOutputSerializer,
check_rate_limit,
delete_session_cookie,
get_client_ip_address,
get_client_user_agent,
get_session,
send_templated_email,
set_session_cookie,
)

Expand Down Expand Up @@ -83,14 +82,11 @@ def register(request):
# Check if the email already exists
if UserAccount.objects.filter(email=email).exists():
logger.info("Email %s is already in use!", email)
if SEND_EMAILS:
send_mail(
subject="Plancake - Email in Use",
message=f"Looks like your email was already used for a Plancake account.\n\nNot you? Nothing to worry about, just ignore this email.",
from_email=None, # Use the default from settings
recipient_list=[email],
fail_silently=False,
)
send_templated_email(
to_email=email,
template_key=EmailTemplateKey.EMAIL_IN_USE,
context={},
)
else:
# Create an unverified user account
ver_code = str(uuid.uuid4())
Expand All @@ -103,14 +99,11 @@ def register(request):
)
logger.debug("Verification code for %s: %s", email, ver_code)

if SEND_EMAILS:
send_mail(
subject="Plancake - Email Verification",
message=f"Welcome to Plancake!\n\nClick this link to verify your email:\n{BASE_URL}/verify-email?code={ver_code}\n\nNot you? Nothing to worry about, just ignore this email.",
from_email=None, # Use the default from settings
recipient_list=[email],
fail_silently=False,
)
send_templated_email(
to_email=email,
template_key=EmailTemplateKey.EMAIL_VERIFICATION,
context={"code": ver_code},
)

return Response(
{"message": ["An email has been sent to your address for verification."]},
Expand Down Expand Up @@ -147,14 +140,11 @@ def resend_register_email(request):
unverified_user.verification_code,
)

if SEND_EMAILS:
send_mail(
subject="Plancake - Email Verification",
message=f"Welcome to Plancake!\n\nClick this link to verify your email:\n{BASE_URL}/verify-email?code={unverified_user.verification_code}\n\nNot you? Nothing to worry about, just ignore this email.",
from_email=None, # Use the default from settings
recipient_list=[email],
fail_silently=False,
)
send_templated_email(
to_email=email,
template_key=EmailTemplateKey.EMAIL_VERIFICATION,
context={"code": unverified_user.verification_code},
)

except UnverifiedUserAccount.DoesNotExist:
logger.info("Unverified user with email %s does not exist!", email)
Expand Down Expand Up @@ -333,14 +323,11 @@ def start_password_reset(request):
)
logger.debug("Password reset token for %s: %s", email, reset_token)

if SEND_EMAILS:
send_mail(
subject="Plancake - Reset Password",
message=f"Click this link to reset your password:\n{BASE_URL}/reset-password?token={reset_token}\n\nNot you? Nothing to worry about, just ignore this email.",
from_email=None, # Use the default from settings
recipient_list=[email],
fail_silently=False,
)
send_templated_email(
to_email=email,
template_key=EmailTemplateKey.PASSWORD_RESET,
context={"token": reset_token},
)

except UserAccount.DoesNotExist:
logger.info("Password reset failed for %s: User does not exist.", email)
Expand Down
24 changes: 18 additions & 6 deletions backend/api/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,13 +148,25 @@ class ThrottleScopes:
{"error": {"general": ["An unknown error has occurred."]}}, status=500
)

# AWS SES Credentials
EMAIL_BACKEND = "django_ses.SESBackend"
AWS_SES_ACCESS_KEY_ID = env("AWS_SES_ACCESS_KEY_ID")
AWS_SES_SECRET_ACCESS_KEY = env("AWS_SES_SECRET_ACCESS_KEY")
AWS_SES_REGION_NAME = env("AWS_SES_REGION_NAME")
AWS_SES_REGION_ENDPOINT = env("AWS_SES_REGION_ENDPOINT")
# Email settings
if DEBUG:
# For use with something like Mailpit
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = env("SMTP_HOST")
EMAIL_PORT = env.int("SMTP_PORT")
EMAIL_USE_TLS = False
EMAIL_HOST_USER = env("SMTP_HOST_USER")
EMAIL_HOST_PASSWORD = env("SMTP_HOST_PASSWORD")
else:
# For production, uses AWS SES
EMAIL_BACKEND = "django_ses.SESBackend"
AWS_SES_ACCESS_KEY_ID = env("AWS_SES_ACCESS_KEY_ID")
AWS_SES_SECRET_ACCESS_KEY = env("AWS_SES_SECRET_ACCESS_KEY")
AWS_SES_REGION_NAME = env("AWS_SES_REGION_NAME")
AWS_SES_REGION_ENDPOINT = env("AWS_SES_REGION_ENDPOINT")
DEFAULT_FROM_EMAIL = env("DEFAULT_FROM_EMAIL")
EMAIL_BRIDGE_SECRET = env("EMAIL_BRIDGE_SECRET")
Comment thread
jzgom067 marked this conversation as resolved.
EMAIL_TIMEOUT = 10 # seconds
ADMIN_EMAILS = env.list("ADMIN_EMAILS", default=[])
SEND_EMAILS = env.bool("SEND_EMAILS", default=False)
CRITICAL_EMAIL_INTERVAL_SECONDS = 1800 # 30 minutes
Expand Down
74 changes: 74 additions & 0 deletions backend/api/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from enum import Enum
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

import requests
from django.core.mail import EmailMultiAlternatives
from django.db.models import Q
from ipware import get_client_ip
from redis import Redis
Expand All @@ -17,10 +19,13 @@
from api.redis_pools import sync_pool
from api.settings import (
ACCOUNT_COOKIE_NAME,
BASE_URL,
COOKIE_DOMAIN,
DEBUG,
EMAIL_BRIDGE_SECRET,
GUEST_COOKIE_NAME,
LONG_SESS_EXP_SECONDS,
SEND_EMAILS,
SESS_EXP_SECONDS,
TEST_ENVIRONMENT,
ThrottleScope,
Expand Down Expand Up @@ -472,3 +477,72 @@ def notify_live_update(event: LiveUpdateEvent):
f"event_{event.event_code}",
event.dumps(),
)


class EmailTemplateKey(str, Enum):
EMAIL_IN_USE = "email_in_use"
EMAIL_VERIFICATION = "email_verification"
PASSWORD_RESET = "password_reset"
PASSWORD_RESET_CODE = "password_reset_code"


def send_templated_email(to_email, template_key: EmailTemplateKey, context) -> bool:
"""
Fetches rendered HTML from Next.js and sends it as an email.

Returns True if the email was sent successfully, False otherwise.

This function will check the SEND_EMAILS environment variable, and if set to False
then the email will not be sent and True will be returned every time.
"""
if not SEND_EMAILS:
return True

url = BASE_URL
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {EMAIL_BRIDGE_SECRET}",
}
payload = {
"template_key": template_key,
"context": context,
}

html_content = None
text_fallback = None
msg_subject = None
success = False

try:
response = requests.post(
url + "/api/render-email/", json=payload, headers=headers, timeout=5
)

if response.status_code == 200:
data = response.json()
html_content = data.get("html")
text_fallback = data.get("text")
msg_subject = data.get("subject")
success = True
logger.info(f"Successfully rendered email for template '{template_key}'")
else:
logger.error(
f"Failed to render email for template '{template_key}'. "
f"Status code: {response.status_code}, Response: {response.text}"
)
except requests.RequestException as e:
logger.error(f"Error rendering email for template '{template_key}': {e}")

try:
if html_content:
EmailMultiAlternatives(
subject=msg_subject,
body=text_fallback,
alternatives=[(html_content, "text/html")],
to=[to_email],
).send()
except Exception as e:
logger.error(f"Error sending email: {e}")
success = False

return success
Comment thread
jzgom067 marked this conversation as resolved.
4 changes: 4 additions & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ billiard==4.2.1
boto3==1.39.14
botocore==1.39.14
celery==5.5.3
certifi==2026.6.17
charset-normalizer==3.4.9
click==8.2.1
click-didyoumean==0.3.1
click-plugins==1.1.1.2
Expand All @@ -19,6 +21,7 @@ django-ipware==7.0.1
django-ses==4.4.0
djangorestframework==3.16.0
h11==0.16.0
idna==3.18
isort==8.0.1
jmespath==1.0.1
kombu==5.5.4
Expand All @@ -31,6 +34,7 @@ python-ipware==3.0.0
PyYAML==6.0.3
redis==6.2.0
regex==2026.4.4
requests==2.34.2
s3transfer==0.13.1
six==1.17.0
sqlparse==0.5.3
Expand Down
12 changes: 10 additions & 2 deletions frontend/.env.example
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
# Enable debug mode, important to have this false in production for security
NEXT_PUBLIC_DEBUG=true

# The base URL for the frontend, used for generating links in emails (no trailing slash)
BASE_URL=http://localhost:3000

# The API URL for both the client and the Next.js server to communicate with the backend
# Note the lack of a trailing slash
# (no trailing slash)
NEXT_PUBLIC_API_URL=https://example.com

# The URL for the feedback form
NEXT_PUBLIC_FEEDBACK_FORM_URL=https://forms.example.com/feedback
NEXT_PUBLIC_FEEDBACK_FORM_URL=https://forms.example.com/feedback

# Secret key for authorization into the email template renderer
# Must be the same as the one in the backend .env file
# Can be left as a placeholder for development
EMAIL_BRIDGE_SECRET=secret
Loading
Loading