diff --git a/server/.env.example b/server/.env.example index fecedcf..6bbb7a3 100644 --- a/server/.env.example +++ b/server/.env.example @@ -19,8 +19,32 @@ AWS_SECRET_ACCESS_KEY= AWS_STORAGE_BUCKET_NAME=bucket_name AWS_REGION_NAME=ap-southeast-2 +# ====================== +# Google Calendar +# ====================== GOOGLE_CREDENTIALS_FILE=api/booking/google_calendar/google_calendar_service.json GOOGLE_CALENDAR_ID= +# ====================== +# Frontend URL +# ====================== +FRONTEND_URL="http://localhost:3000" + +# ====================== +# Email configuration +# ====================== +EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend +EMAIL_HOST=smtp.gmail.com +EMAIL_PORT=587 + +# Must be replaced with your real Gmail address +EMAIL_HOST_USER=your_email@example.com + +# IMPORTANT: +# Gmail requires an App Password (not your normal password) +# Learn more: https://support.google.com/accounts/answer/185833 +EMAIL_HOST_PASSWORD=your_app_password_here + +EMAIL_USE_TLS=True +EMAIL_USE_SSL=False -FRONTEND_URL="http://localhost:3000" \ No newline at end of file diff --git a/server/api/email_utils.py b/server/api/email_utils.py new file mode 100644 index 0000000..1f1f74c --- /dev/null +++ b/server/api/email_utils.py @@ -0,0 +1,160 @@ +from typing import Iterable + +from django.conf import settings +from django.core.mail import send_mail +from django.template.loader import render_to_string +from django.utils.html import strip_tags +from django.templatetags.static import static + + +# Template paths under api/templates/emails +BOOKING_CONFIRMED_TEMPLATE = "emails/booking_confirmed.html" +BOOKING_CANCELLED_TEMPLATE = "emails/booking_cancelled.html" + +""" +Email utilities for booking notifications. + +Usage: +- Call `send_booking_confirmed_email` or `send_booking_cancelled_email` +- Pass booking-specific data via the `context` dictionary +- Shared layout and branding (e.g. Bloom logo) are injected automatically + +Expected context structure: + +Booking confirmed email (`send_booking_confirmed_email`): +context = { + "room_name": str, # required + "start_datetime": datetime, # required + "end_datetime": datetime, # required + "visitor_name": str, # required + "location_name": str, # required + "manage_url": str | None, # optional +} + +Booking cancelled email (`send_booking_cancelled_email`): +context = { + "room_name": str, # required + "start_datetime": datetime, # required + "end_datetime": datetime, # required + "book_room_url": str | None, # optional +} + +Shared variables (injected automatically): +- bloom_logo_url: str + +Templates: +- emails/base_booking_email.html (shared layout) +- emails/booking_confirmed.html +- emails/booking_cancelled.html +""" + + +def get_bloom_logo_url() -> str: + """ + URL for the Bloom logo used in emails. + + Uses BLOOM_LOGO_URL from settings if present, otherwise falls + back to the static path (useful for local/dev). + """ + logo_url = getattr(settings, "BLOOM_LOGO_URL", None) + if logo_url: + return logo_url + + # Fallback – relative static path; fine for local / console email previews + return static("images/bloom_logo.png") + + +def send_simple_email( + subject: str, + message: str | None, + recipients: Iterable[str], + *, + html_template: str | None = None, + context: dict | None = None, + from_email: str | None = None, + fail_silently: bool = False, +) -> int: + """ + Wrapper for Django's send_mail using EMAIL_HOST_USER. + + - If `html_template` is provided, it renders that template with `context` + and sends it as HTML email. + - If `message` is None, it will be generated by stripping HTML tags from + the rendered HTML so a plain-text body is still sent. + - Existing plain-text usage still works: pass `subject`, `message`, + and `recipients` without `html_template`. + """ + if from_email is None: + from_email = settings.EMAIL_HOST_USER + + html_message = None + + if html_template is not None: + context = context or {} + html_message = render_to_string(html_template, context) + # Derive plain text from HTML if not explicitly provided + if message is None: + message = strip_tags(html_message) + + # Django requires a non-empty string for `message` + if message is None: + message = "" + + return send_mail( + subject=subject, + message=message, + from_email=from_email, + recipient_list=list(recipients), + fail_silently=fail_silently, + html_message=html_message, + ) + + +def send_booking_confirmed_email( + recipients: Iterable[str], + *, + context: dict, + subject: str = "Booking confirmed!", + fail_silently: bool = False, +) -> int: + """ + Convenience wrapper for the booking confirmed HTML template. + Expects `context` to match the variables used in + emails/booking_confirmed.html. + """ + ctx = dict(context or {}) + ctx.setdefault("bloom_logo_url", get_bloom_logo_url()) + + return send_simple_email( + subject=subject, + message=None, + recipients=recipients, + html_template=BOOKING_CONFIRMED_TEMPLATE, + context=ctx, + fail_silently=fail_silently, + ) + + +def send_booking_cancelled_email( + recipients: Iterable[str], + *, + context: dict, + subject: str = "Booking cancelled!", + fail_silently: bool = False, +) -> int: + """ + Convenience wrapper for the booking cancelled HTML template. + Expects `context` to match the variables used in + emails/booking_cancelled.html. + """ + ctx = dict(context or {}) + ctx.setdefault("bloom_logo_url", get_bloom_logo_url()) + + return send_simple_email( + subject=subject, + message=None, + recipients=recipients, + html_template=BOOKING_CANCELLED_TEMPLATE, + context=ctx, + fail_silently=fail_silently, + ) diff --git a/server/api/settings.py b/server/api/settings.py index a135fe0..d7b0609 100644 --- a/server/api/settings.py +++ b/server/api/settings.py @@ -80,7 +80,7 @@ TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [], + "DIRS": [BASE_DIR / "api" / "templates"], "APP_DIRS": True, "OPTIONS": { "context_processors": [ @@ -180,6 +180,32 @@ "DEFAULT_AUTHENTICATION_CLASSES": ["rest_framework_simplejwt.authentication.JWTAuthentication"], } +# ========================= +# Email configuration +# ========================= + +EMAIL_BACKEND = os.environ.get( + "EMAIL_BACKEND", + "django.core.mail.backends.smtp.EmailBackend", +) + +EMAIL_HOST = os.environ.get("EMAIL_HOST", "smtp.gmail.com") +EMAIL_PORT = int(os.environ.get("EMAIL_PORT", "587")) +EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "") +EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "") + +# TLS / SSL flags come from env as strings ("True"/"False") +EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", "True") == "True" +EMAIL_USE_SSL = os.environ.get("EMAIL_USE_SSL", "False") == "True" + +DEFAULT_FROM_EMAIL = os.environ.get("EMAIL_HOST_USER", "") +SERVER_EMAIL = os.environ.get("SERVER_EMAIL", DEFAULT_FROM_EMAIL) + + +# ========================= +# AWS S3 storage configuration +# ========================= + AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID") AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY") AWS_STORAGE_BUCKET_NAME = os.getenv("AWS_STORAGE_BUCKET_NAME") @@ -195,18 +221,7 @@ ]) # By default, models using ImageField/FileField will use S3 for storage, as configured in STORAGES. -# example model: -# ``` -# class Room(models.Model): -# name = models.CharField(max_length=255) -# image = models.ImageField(upload_to="rooms/") -# ``` # Only specify a custom storage backend if you need to use something other than S3. -# Example: to check the default storage backend being used: -# ``` -# from django.core.files.storage import default_storage -# print(default_storage.__class__) -# ``` if USE_S3: STORAGES = { "default": { @@ -239,4 +254,4 @@ } MEDIA_URL = "/media/" -AUTH_USER_MODEL = 'api_user.CustomUser' +AUTH_USER_MODEL = "api_user.CustomUser" diff --git a/server/api/static/images/bloom_logo.png b/server/api/static/images/bloom_logo.png new file mode 100644 index 0000000..ef3fa17 Binary files /dev/null and b/server/api/static/images/bloom_logo.png differ diff --git a/server/api/templates/emails/base_booking_email.html b/server/api/templates/emails/base_booking_email.html new file mode 100644 index 0000000..517d241 --- /dev/null +++ b/server/api/templates/emails/base_booking_email.html @@ -0,0 +1,118 @@ + + + + + {% block title %}Bloom Notification{% endblock %} + + + + + +
+
+ + +
+
+
+ Bloom logo +
+
+
+ + +
+ {% block content %}{% endblock %} + + +
+ +
+
+ + diff --git a/server/api/templates/emails/booking_cancelled.html b/server/api/templates/emails/booking_cancelled.html new file mode 100644 index 0000000..26773f3 --- /dev/null +++ b/server/api/templates/emails/booking_cancelled.html @@ -0,0 +1,33 @@ +{% extends "emails/base_booking_email.html" %} + +{% block title %}Booking cancelled{% endblock %} + +{% block extra_styles %} + h1 { + margin: 0 0 12px; + font-size: 22px; + font-weight: 600; + color: #111827; + } + + .subtitle { + margin: 0 0 28px; + font-size: 14px; + color: #4b5563; + line-height: 1.6; + } +{% endblock %} + +{% block content %} +

Booking cancelled!

+ +

+ Your {{ room_name }} booking for + {{ start_datetime|date:"d M Y" }} from {{ start_datetime|time:"g:i A" }} + to {{ end_datetime|time:"g:i A" }} has been cancelled. +

+ + {% if book_room_url %} + Book room + {% endif %} +{% endblock %} diff --git a/server/api/templates/emails/booking_confirmed.html b/server/api/templates/emails/booking_confirmed.html new file mode 100644 index 0000000..aeacff1 --- /dev/null +++ b/server/api/templates/emails/booking_confirmed.html @@ -0,0 +1,144 @@ +{% extends "emails/base_booking_email.html" %} + +{% block title %}Booking confirmed{% endblock %} + +{% block extra_styles %} + .icon-circle { + width: 48px; + height: 48px; + border-radius: 999px; + border: 2px solid #4caf50; + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 16px; + } + + .icon-circle span { + font-size: 26px; + } + + h1 { + margin: 0 0 8px; + font-size: 22px; + font-weight: 600; + color: #111827; + } + + .subtitle { + margin: 0 0 24px; + font-size: 14px; + color: #4b5563; + line-height: 1.6; + } + + .divider { + margin: 0 auto 24px; + width: 100%; + border: none; + border-top: 1px solid #e5e7eb; + } + + .details-title { + font-size: 14px; + font-weight: 600; + margin-bottom: 16px; + color: #111827; + } + + .details-card { + margin: 0 auto 28px; + max-width: 520px; + background-color: #f9fafb; + border-radius: 12px; + border: 1px solid #e5e7eb; + padding: 20px 24px; + text-align: left; + } + + .detail-row { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 16px; + } + + .detail-row:last-child { + margin-bottom: 0; + } + + .detail-icon { + width: 20px; + text-align: center; + font-size: 16px; + } + + .detail-content-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #6b7280; + margin-bottom: 2px; + } + + .detail-content-value { + font-size: 14px; + color: #111827; + font-weight: 500; + } +{% endblock %} + +{% block content %} +
+ βœ” +
+ +

Booking confirmed!

+ +

+ Your {{ room_name }} booking for + {{ start_datetime|date:"d M Y" }} from {{ start_datetime|time:"g:i A" }} + to {{ end_datetime|time:"g:i A" }} has been confirmed. +

+ +
+ +
Details
+ +
+
+
πŸ‘€
+
+ +
+ {{ visitor_name }} +
+
+
+ +
+
πŸ“…
+
+ +
+ {{ start_datetime|date:"d/m/Y" }}, + {{ start_datetime|time:"g:i A" }} – {{ end_datetime|time:"g:i A" }} +
+
+
+ +
+
πŸ“
+
+ +
+ {{ location_name }} +
+
+
+
+ + {% if manage_url %} + Manage your booking + {% endif %} +{% endblock %}