38686-vm/config/settings.py
Konrad du Plessis 5d6446ae75 Fix empty DEFAULT_FROM_EMAIL causing 'Invalid address' on outbound mail
When DEFAULT_FROM_EMAIL env var isn't set, it defaulted to an empty
string, causing every outbound email (receipts, payslips) to fail
with: Invalid address "".

Phase 1 removed the hardcoded Gmail fallback for security. The
cleanest restore — without reintroducing a secret default — is to
fall back to EMAIL_HOST_USER, which is already the authenticated
Gmail address we send AS. That address is always valid when SMTP
auth works, and it's already set on the VM (otherwise sending
would fail with an auth error instead).

Now:
  DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "") or EMAIL_HOST_USER

Verified locally: when DEFAULT_FROM_EMAIL is unset and EMAIL_HOST_USER
is 'test@example.com', DEFAULT_FROM_EMAIL resolves to the same address.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 04:33:15 +02:00

303 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Django settings for config project.
Generated by 'django-admin startproject' using Django 5.2.7.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.2/ref/settings/
"""
from pathlib import Path
import os
from django.core.exceptions import ImproperlyConfigured
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR.parent / ".env")
# === DEBUG ===
# DEBUG defaults to FALSE — must be explicitly enabled via env var.
# Previously defaulted to "true" which exposed full tracebacks and
# settings to anyone who hit a 500 error in production.
DEBUG = os.getenv("DJANGO_DEBUG", "false").lower() == "true"
# === DEV MODE DETECTION ===
# Local dev uses SQLite (see run_dev.bat). When USE_SQLITE is set we're
# in dev and can relax a few "must be set in prod" checks.
_IS_DEV = os.getenv("USE_SQLITE", "").lower() == "true"
# === SECRET_KEY ===
# Must be provided via DJANGO_SECRET_KEY env var in any non-dev deploy.
# In dev mode (USE_SQLITE=true) we fall back to a known-insecure key so
# local development works out of the box. In prod the absence of the
# env var raises a startup error rather than silently using a weak key.
SECRET_KEY = os.getenv("DJANGO_SECRET_KEY", "")
if not SECRET_KEY:
if _IS_DEV or DEBUG:
# Dev-only key — NEVER set this value in a production env var.
SECRET_KEY = "dev-only-insecure-key-do-not-use-in-production"
else:
raise ImproperlyConfigured(
"DJANGO_SECRET_KEY environment variable is not set. "
"Set it in the deploy platform's environment variables. "
"Use `python -c \"import secrets; print(secrets.token_urlsafe(64))\"` "
"to generate a new one."
)
ALLOWED_HOSTS = [
"127.0.0.1",
"localhost",
"foxlog.flatlogic.app",
os.getenv("HOST_FQDN", ""),
]
# === CSRF TRUSTED ORIGINS ===
# Build the list, then normalise each entry to have an https:// prefix.
# Guard against the double-prefix bug: if the user sets HOST_FQDN to
# "https://example.com" (with a scheme), the raw f-string would produce
# "https://https://example.com" which Django rejects.
CSRF_TRUSTED_ORIGINS = [
origin for origin in [
"foxlog.flatlogic.app",
os.getenv("HOST_FQDN", ""),
os.getenv("CSRF_TRUSTED_ORIGIN", ""),
] if origin
]
def _normalize_origin(host):
"""Ensure `host` has an http:// or https:// scheme; default to https.
Accepts any of: 'example.com' / 'https://example.com' / 'http://localhost'
Returns a string with a scheme every time.
"""
host = host.strip()
if host.startswith(("http://", "https://")):
return host
return f"https://{host}"
CSRF_TRUSTED_ORIGINS = [_normalize_origin(h) for h in CSRF_TRUSTED_ORIGINS]
# Cookies must always be HTTPS-only; SameSite=Lax keeps CSRF working behind the proxy.
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = "None"
CSRF_COOKIE_SAMESITE = "None"
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'core',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
# Disable X-Frame-Options middleware to allow Flatlogic preview iframes.
# 'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
X_FRAME_OPTIONS = 'ALLOWALL'
ROOT_URLCONF = 'config.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
# Explicitly load core/templates first so we can override specific
# Django admin templates (e.g. admin/base_site.html) without having
# to reorder INSTALLED_APPS. Without this entry, the app-dirs loader
# finds django.contrib.admin's version before ours.
'DIRS': [BASE_DIR / 'core' / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
# IMPORTANT: do not remove injects PROJECT_DESCRIPTION/PROJECT_IMAGE_URL and cache-busting timestamp
'core.context_processors.project_context',
],
},
},
]
WSGI_APPLICATION = 'config.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'NAME': os.getenv('DB_NAME', ''),
'USER': os.getenv('DB_USER', ''),
'PASSWORD': os.getenv('DB_PASS', ''),
'HOST': os.getenv('DB_HOST', '127.0.0.1'),
'PORT': os.getenv('DB_PORT', '3306'),
'OPTIONS': {
'charset': 'utf8mb4',
},
},
}
# Password validation
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'Africa/Johannesburg'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/
STATIC_URL = 'static/'
# Collect static into a separate folder; avoid overlapping with STATICFILES_DIRS.
STATIC_ROOT = BASE_DIR / 'staticfiles'
STATICFILES_DIRS = [
BASE_DIR / 'static',
BASE_DIR / 'node_modules',
]
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
# === EMAIL CONFIGURATION ===
# Uses Gmail SMTP with an App Password to send payslip PDFs and receipts.
# The App Password is a 16-character code from Google Account settings —
# it lets the app send email through Gmail without your actual password.
# === EMAIL CONFIGURATION ===
# NO FALLBACKS for credentials — they MUST come from environment variables.
# Previous versions had the Gmail App Password committed in source as a
# fallback default, which is a critical security leak via git history.
# In local dev (USE_SQLITE=true) empty credentials are fine; email sends
# will just fail with an auth error — which is what you want locally.
EMAIL_BACKEND = os.getenv(
"EMAIL_BACKEND",
"django.core.mail.backends.smtp.EmailBackend"
)
EMAIL_HOST = os.getenv("EMAIL_HOST", "smtp.gmail.com")
EMAIL_PORT = int(os.getenv("EMAIL_PORT", "587"))
EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "") # set on deploy platform
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "") # set on deploy platform
EMAIL_USE_TLS = os.getenv("EMAIL_USE_TLS", "true").lower() == "true"
EMAIL_USE_SSL = os.getenv("EMAIL_USE_SSL", "false").lower() == "true"
# === FROM-ADDRESS ===
# Where outgoing emails appear to come from. If DEFAULT_FROM_EMAIL isn't
# explicitly set, fall back to the Gmail address we authenticate as —
# that's always a valid sender since it's the same account sending the email.
# Without this fallback, emails fail with "Invalid address ''" if the
# env var is missing, even though auth + SMTP are otherwise fine.
DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "") or EMAIL_HOST_USER
CONTACT_EMAIL_TO = [
item.strip()
for item in os.getenv("CONTACT_EMAIL_TO", DEFAULT_FROM_EMAIL).split(",")
if item.strip()
]
# Spark Receipt Email — payslip and receipt PDFs routed here for accounting import.
# This is a routing address, not a secret, so a default is acceptable — but override
# via env var for flexibility. Set to empty string if you want to disable sending.
SPARK_RECEIPT_EMAIL = os.getenv("SPARK_RECEIPT_EMAIL", "foxfitt-ed9wc+expense@to.sparkreceipt.com")
# Fail loudly at startup in production if credentials are missing — catches the
# "I forgot to set env vars on the new deploy platform" mistake before a user
# triggers a payroll payment and the email silently fails.
if not DEBUG and not _IS_DEV:
_missing_email_vars = [
name for name, val in [
("EMAIL_HOST_USER", EMAIL_HOST_USER),
("EMAIL_HOST_PASSWORD", EMAIL_HOST_PASSWORD),
("DEFAULT_FROM_EMAIL", DEFAULT_FROM_EMAIL),
] if not val
]
if _missing_email_vars:
# Don't crash — email sending isn't critical for the app to boot —
# but log a loud warning so it's visible in deploy logs.
import logging
logging.getLogger(__name__).warning(
"Email configuration incomplete in production. Missing env vars: %s. "
"Payslip and receipt emails will fail to send until these are set.",
", ".join(_missing_email_vars),
)
# When both TLS and SSL flags are enabled, prefer SSL explicitly
if EMAIL_USE_SSL:
EMAIL_USE_TLS = False
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
LOGIN_URL = 'login'
LOGIN_REDIRECT_URL = 'home'
LOGOUT_REDIRECT_URL = 'login'
# === MESSAGE TAGS ===
# Django's messages.error() tags messages as "error", but Bootstrap uses "danger"
# for red alerts. Without this mapping, error messages would render as "alert-error"
# which doesn't exist in Bootstrap — making them invisible to the user!
from django.contrib.messages import constants as message_constants
MESSAGE_TAGS = {
message_constants.ERROR: 'danger',
}
# === LOCAL DEVELOPMENT: SQLite override ===
# Set USE_SQLITE=true in environment to use SQLite instead of MariaDB.
# This lets you test locally without a MySQL/MariaDB server.
if os.getenv('USE_SQLITE', 'false').lower() == 'true':
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# Disable secure cookies for local http:// testing
SESSION_COOKIE_SECURE = False
CSRF_COOKIE_SECURE = False
SESSION_COOKIE_SAMESITE = 'Lax'
CSRF_COOKIE_SAMESITE = 'Lax'