Compare commits

..

3 Commits

Author SHA1 Message Date
KenwoodFox 53c6fad262 Add entrypoint to run migrations at start 2025-12-03 18:54:30 -05:00
KenwoodFox c97b06e07b Fix last emails 2025-12-03 18:54:02 -05:00
KenwoodFox 2836d5954e Email config, email template and more! 2025-12-03 18:46:48 -05:00
16 changed files with 890 additions and 36 deletions

1
.gitignore vendored
View File

@ -36,6 +36,7 @@ db.sqlite3
db.sqlite3-journal
/media
/staticfiles
/local
# IDE
.vscode/

View File

@ -14,4 +14,9 @@ COPY . /app
# Collect static files for WhiteNoise
RUN python manage.py collectstatic --noinput
# Make entrypoint script executable
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["gunicorn", "seduttomachineworks_project.wsgi:application", "--bind", "0.0.0.0:8000"]

View File

@ -22,7 +22,7 @@ services:
web_upload:
image: git.kitsunehosting.net/kenwood/smw-upload:latest
volumes:
- /var/seduttomachineworks/data:/app/store
- /var/seduttomachineworks/data:/app/data
ports:
- "8000:8000"
environment:

11
docker-entrypoint.sh Normal file
View File

@ -0,0 +1,11 @@
#!/bin/bash
set -e
# Run migrations
echo "Running database migrations..."
python manage.py migrate --noinput
# Start the application
echo "Starting application..."
exec "$@"

0
quotes/__init__.py Normal file
View File

43
quotes/admin.py Normal file
View File

@ -0,0 +1,43 @@
from django.contrib import admin
from .models import Submission, SubmissionFile
class SubmissionFileInline(admin.TabularInline):
"""Inline admin for submission files."""
model = SubmissionFile
extra = 0
readonly_fields = ["uploaded_at", "file_size"]
@admin.register(Submission)
class SubmissionAdmin(admin.ModelAdmin):
"""Admin interface for submissions."""
list_display = [
"email",
"submitted_at",
"expiration_time",
"is_expired",
"file_count",
]
list_filter = ["submitted_at", "expiration_time"]
search_fields = ["email", "description"]
readonly_fields = ["submitted_at"]
inlines = [SubmissionFileInline]
def file_count(self, obj):
"""Display the number of files in this submission."""
return obj.files.count()
file_count.short_description = "Files"
@admin.register(SubmissionFile)
class SubmissionFileAdmin(admin.ModelAdmin):
"""Admin interface for submission files."""
list_display = ["original_filename", "submission", "file_size", "uploaded_at"]
list_filter = ["uploaded_at"]
search_fields = ["original_filename", "submission__email"]
readonly_fields = ["uploaded_at", "file_size"]

6
quotes/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class QuotesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "quotes"

176
quotes/email_utils.py Normal file
View File

@ -0,0 +1,176 @@
"""
Email utilities for sending submission notifications.
"""
from django.core.mail import send_mail, EmailMultiAlternatives
from django.template.loader import render_to_string
from django.conf import settings
def get_file_urls_with_base_url(files):
"""
Get absolute URLs for files.
Constructs full URLs using ALLOWED_HOSTS from settings.
"""
# Get base URL from ALLOWED_HOSTS
allowed_hosts = getattr(settings, "ALLOWED_HOSTS", [])
if allowed_hosts and allowed_hosts[0] != "*":
# Use first allowed host
domain = allowed_hosts[0]
# Check if it's localhost or IP for HTTP
if (
domain.startswith("127.0.0.1")
or domain.startswith("localhost")
or domain.startswith("10.")
):
# In DEBUG mode, add port 8000 (default Django dev server)
if settings.DEBUG:
base_url = f"http://{domain}:8000"
else:
base_url = f"http://{domain}"
else:
base_url = f"https://{domain}"
else:
# Fallback: use localhost with port in DEBUG mode
if settings.DEBUG:
base_url = "http://127.0.0.1:8000"
else:
base_url = ""
file_urls = []
for file in files:
# In DEBUG mode, use MEDIA_URL (WhiteNoise serves it)
# In production, use /uploads/ (nginx serves it)
if settings.DEBUG:
# Use MEDIA_URL which is "media/"
media_url = settings.MEDIA_URL.rstrip("/")
file_path = f"/{media_url}/{file.path}"
else:
# Use /uploads/ for nginx
file_path = f"/uploads/{file.path}"
if base_url:
# Make it an absolute URL
file_url = base_url + file_path
else:
# Fallback: relative URL
file_url = file_path
file_urls.append(
{
"file": file,
"url": file_url,
}
)
return file_urls
def send_submission_emails(submission):
"""
Send emails for a quote submission.
Sends:
1. Notification to owner (OWNER_EMAIL)
2. Confirmation to submitter (submission.email)
Returns tuple (owner_sent, submitter_sent)
"""
files = submission.files.all()
file_urls = get_file_urls_with_base_url(files)
owner_email = getattr(settings, "OWNER_EMAIL", "")
owner_sent = False
submitter_sent = False
# Email 1: Notification to owner
if owner_email:
try:
subject = f"New Quote Request - Submission #{submission.id}"
html_message = render_to_string(
"emails/submission_notification_owner.html",
{
"submission": submission,
"files": files,
"file_urls": file_urls,
},
)
# Plain text fallback
text_message = f"""
New Quote Request Received!
Submitted by: {submission.email}
Submitted at: {submission.submitted_at.strftime('%B %d, %Y %I:%M %p')}
Submission ID: #{submission.id}
Description:
{submission.description or '(No description provided)'}
Files uploaded: {files.count()}
"""
for file_url_data in file_urls:
file = file_url_data["file"]
url = file_url_data["url"]
text_message += f"- {file.original_filename} ({file.file_size} bytes)\n"
text_message += f" Download: {url}\n"
msg = EmailMultiAlternatives(
subject=subject,
body=text_message,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[owner_email],
)
msg.attach_alternative(html_message, "text/html")
msg.send()
owner_sent = True
except Exception as e:
print(f"Error sending owner email: {e}")
# Email 2: Confirmation to submitter
try:
subject = "Quote Request Received - Sedutto Machineworks"
html_message = render_to_string(
"emails/submission_confirmation.html",
{
"submission": submission,
"files": files,
"file_urls": file_urls,
},
)
# Plain text fallback
text_message = f"""
Thank you for your quote request!
We have received your submission and will review it shortly.
Submission Details:
Submission ID: #{submission.id}
Submitted at: {submission.submitted_at.strftime('%B %d, %Y %I:%M %p')}
Your Notes:
{submission.description or '(No notes provided)'}
Files uploaded: {files.count()}
"""
for file_url_data in file_urls:
file = file_url_data["file"]
url = file_url_data["url"]
text_message += f"- {file.original_filename} ({file.file_size} bytes)\n"
text_message += f" Download: {url}\n"
text_message += f"\nWe will contact you at {submission.email} regarding your quote request.\n"
text_message += f"\nThis submission will be kept on file until {submission.expiration_time.strftime('%B %d, %Y')}."
msg = EmailMultiAlternatives(
subject=subject,
body=text_message,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[submission.email],
)
msg.attach_alternative(html_message, "text/html")
msg.send()
submitter_sent = True
except Exception as e:
print(f"Error sending submitter email: {e}")
return (owner_sent, submitter_sent)

View File

@ -0,0 +1,112 @@
# Generated by Django 4.2.27 on 2025-12-03 23:22
from django.db import migrations, models
import django.db.models.deletion
import quotes.models
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [
migrations.CreateModel(
name="Submission",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"email",
models.EmailField(
help_text="Email of the person who submitted the quote request",
max_length=254,
),
),
(
"description",
models.TextField(
help_text="Notes, comments, or description provided by the submitter"
),
),
(
"submitted_at",
models.DateTimeField(
auto_now_add=True, help_text="When the submission was created"
),
),
(
"expiration_time",
models.DateTimeField(
default=quotes.models.default_expiration_time,
help_text="When this submission expires (default 20 days from submission)",
),
),
],
options={
"verbose_name": "Submission",
"verbose_name_plural": "Submissions",
"ordering": ["-submitted_at"],
},
),
migrations.CreateModel(
name="SubmissionFile",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"original_filename",
models.CharField(
help_text="Original filename as uploaded by the user",
max_length=255,
),
),
(
"path",
models.CharField(
help_text="Path relative to /data/uploads (e.g., 'filename.pdf')",
max_length=500,
),
),
(
"file_size",
models.PositiveIntegerField(help_text="Size of the file in bytes"),
),
(
"uploaded_at",
models.DateTimeField(
auto_now_add=True, help_text="When the file was uploaded"
),
),
(
"submission",
models.ForeignKey(
help_text="The submission this file belongs to",
on_delete=django.db.models.deletion.CASCADE,
related_name="files",
to="quotes.submission",
),
),
],
options={
"verbose_name": "Submission File",
"verbose_name_plural": "Submission Files",
"ordering": ["uploaded_at"],
},
),
]

View File

121
quotes/models.py Normal file
View File

@ -0,0 +1,121 @@
from django.db import models
from django.utils import timezone
from datetime import timedelta
from pathlib import Path
def default_expiration_time():
"""Default expiration time: 20 days from now."""
return timezone.now() + timedelta(days=20)
class Submission(models.Model):
"""Model for quote request submissions."""
email = models.EmailField(
help_text="Email of the person who submitted the quote request"
)
description = models.TextField(
help_text="Notes, comments, or description provided by the submitter"
)
submitted_at = models.DateTimeField(
auto_now_add=True, help_text="When the submission was created"
)
expiration_time = models.DateTimeField(
default=default_expiration_time,
help_text="When this submission expires (default 20 days from submission)",
)
class Meta:
ordering = ["-submitted_at"]
verbose_name = "Submission"
verbose_name_plural = "Submissions"
def __str__(self):
return f"Submission from {self.email} at {self.submitted_at.strftime('%Y-%m-%d %H:%M')}"
def is_expired(self):
"""Check if the submission has expired."""
return timezone.now() > self.expiration_time
def get_file_paths(self):
"""
Get list of all file paths relative to /data/uploads.
Returns list of relative paths as strings.
"""
return [file.path for file in self.files.all()]
def get_file_urls(self):
"""
Get list of URLs for all files in this submission.
Returns list of URLs that can be used to access the files.
"""
from django.conf import settings
from django.urls import reverse
urls = []
for file in self.files.all():
# Path relative to MEDIA_ROOT
relative_path = file.path
# Construct URL
url = f"{settings.MEDIA_URL}{relative_path}"
urls.append(url)
return urls
def get_absolute_file_paths(self):
"""
Get list of absolute file system paths for all files.
Returns list of absolute Path objects.
"""
from django.conf import settings
paths = []
for file in self.files.all():
# Path relative to MEDIA_ROOT/uploads
relative_path = file.path
# Construct absolute path
absolute_path = Path(settings.MEDIA_ROOT) / relative_path
paths.append(absolute_path)
return paths
class SubmissionFile(models.Model):
"""Model for files associated with a submission."""
submission = models.ForeignKey(
Submission,
on_delete=models.CASCADE,
related_name="files",
help_text="The submission this file belongs to",
)
original_filename = models.CharField(
max_length=255, help_text="Original filename as uploaded by the user"
)
path = models.CharField(
max_length=500,
help_text="Path relative to /data/uploads (e.g., 'filename.pdf')",
)
file_size = models.PositiveIntegerField(help_text="Size of the file in bytes")
uploaded_at = models.DateTimeField(
auto_now_add=True, help_text="When the file was uploaded"
)
class Meta:
ordering = ["uploaded_at"]
verbose_name = "Submission File"
verbose_name_plural = "Submission Files"
def __str__(self):
return f"{self.original_filename} ({self.submission.email})"
def get_absolute_path(self):
"""Get the absolute file system path."""
from django.conf import settings
return Path(settings.MEDIA_ROOT) / self.path
def get_url(self):
"""Get the URL to access this file."""
from django.conf import settings
return f"{settings.MEDIA_URL}{self.path}"

View File

@ -0,0 +1,52 @@
"""
Email configuration parser and utilities.
Parses EMAIL_CONFIG environment variable in format:
smtp://<user>@kitsunehosting.net:<password>@smtp.forwardemail.net:465
"""
import os
import re
from urllib.parse import urlparse, unquote
def parse_email_config(email_config_str):
"""
Parse EMAIL_CONFIG string into email settings.
Format: smtp://<user>@kitsunehosting.net:<password>@smtp.forwardemail.net:465
Returns dict with: user, password, host, port, from_email
"""
if not email_config_str:
return None
# Parse the URL
# Format: smtp://user@domain:password@host:port
# We need to handle the @ symbols carefully
match = re.match(r"smtp://([^:]+):([^@]+)@([^:]+):(\d+)", email_config_str)
if not match:
raise ValueError(f"Invalid EMAIL_CONFIG format: {email_config_str}")
user_with_domain = match.group(1)
password = unquote(match.group(2))
host = match.group(3)
port = int(match.group(4))
# Extract email address (user@domain)
from_email = user_with_domain
return {
"user": user_with_domain,
"password": password,
"host": host,
"port": port,
"from_email": from_email,
}
def get_email_config():
"""Get email configuration from environment."""
email_config_str = os.environ.get("EMAIL_CONFIG")
if not email_config_str:
return None
return parse_email_config(email_config_str)

View File

@ -3,6 +3,8 @@ import time
from pathlib import Path
from django.http import StreamingHttpResponse, JsonResponse
from django.conf import settings
from django.core.validators import validate_email
from django.core.exceptions import ValidationError
from django.views.decorators.clickjacking import xframe_options_exempt
from django.views.decorators.csrf import csrf_exempt
@ -33,40 +35,83 @@ def submit_quote(request):
def process_quote():
try:
if not settings.DEBUG:
raise Exception("Not implemented")
# Step 1: Validate email
yield send_progress_update(1, "Validating email address...", 5)
# Step 1: Upload files
yield send_progress_update(1, "Uploading files...", 25)
# Files are already uploaded at this point, just acknowledge
uploaded_files = []
for file in files:
uploaded_files.append({"name": file.name, "size": file.size})
if not email:
raise ValueError("Email is required")
# Step 2: Save files
yield send_progress_update(2, "Saving files...", 50)
# Save files to disk/storage
saved_paths = []
for file in files:
# Create store directory if it doesn't exist
store_dir = Path(settings.MEDIA_ROOT) / "uploads"
store_dir.mkdir(parents=True, exist_ok=True)
try:
validate_email(email)
except ValidationError:
raise ValueError(f"Invalid email address: {email}")
# Save file
file_path = store_dir / file.name
with open(file_path, "wb") as f:
yield send_progress_update(1, "Email validated successfully", 10)
# Step 2: Create submission & upload files
from quotes.models import Submission, SubmissionFile
# Create a Submission entry
submission = Submission.objects.create(
email=email,
description=notes,
)
yield send_progress_update(2, "Created submission entry...", 15)
# Save each file and register in the model
total_files = len(files)
file_objs = []
for idx, file in enumerate(files, start=1):
# Use the original filename and save to MEDIA_ROOT/submission_<id>_filename
submission_dir = (
Path(settings.MEDIA_ROOT) / f"submission_{submission.id}"
)
submission_dir.mkdir(parents=True, exist_ok=True)
target_path = submission_dir / file.name
with open(target_path, "wb") as f:
for chunk in file.chunks():
f.write(chunk)
saved_paths.append(str(file_path))
# Step 3: Send emails
yield send_progress_update(3, "Sending emails 1/2...", 70)
time.sleep(0.5)
# TODO: Actually send emails
yield send_progress_update(4, "Sending emails 2/2...", 75)
time.sleep(0.5)
# TODO: Actually send emails
emails_sent = True # Placeholder
# Register file in SubmissionFile model
rel_path = (Path(f"submission_{submission.id}") / file.name).as_posix()
submission_file = SubmissionFile.objects.create(
submission=submission,
original_filename=file.name,
path=rel_path,
file_size=file.size,
)
file_objs.append(submission_file)
yield send_progress_update(
2,
f"Uploaded file {idx} of {total_files}: {file.name}",
15 + int((idx / total_files) * 30),
)
yield send_progress_update(3, "Files saved...", 50)
# Step 4: Send emails
from quotes.email_utils import send_submission_emails
yield send_progress_update(4, "Sending notification email...", 70)
owner_sent, submitter_sent = send_submission_emails(submission)
time.sleep(0.3)
yield send_progress_update(4, "Sending confirmation email...", 75)
time.sleep(0.3)
# Verify emails were sent
if not owner_sent and getattr(settings, "OWNER_EMAIL", ""):
yield send_progress_update(
"warning",
"Warning: Owner notification email may not have been sent",
75,
)
if not submitter_sent:
yield send_progress_update(
"warning", "Warning: Confirmation email may not have been sent", 75
)
# Complete
yield send_progress_update(

View File

@ -28,6 +28,7 @@ INSTALLED_APPS = [
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"quotes",
]
MIDDLEWARE = [
@ -63,12 +64,25 @@ WSGI_APPLICATION = "seduttomachineworks_project.wsgi.application"
# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
# Use local/data directory in DEBUG mode, otherwise use /app/data
if DEBUG:
DATA_DIR = BASE_DIR / "local" / "data"
DATA_DIR.mkdir(parents=True, exist_ok=True)
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": DATA_DIR / "db.sqlite3",
}
}
else:
DATA_DIR = Path("/app/data")
DATA_DIR.mkdir(parents=True, exist_ok=True)
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": DATA_DIR / "db.sqlite3",
}
}
}
# Internationalization
@ -76,7 +90,7 @@ DATABASES = {
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
TIME_ZONE = "America/New_York"
USE_I18N = True
@ -94,7 +108,17 @@ STATICFILES_DIRS = [BASE_DIR / "static"]
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
MEDIA_URL = "media/"
MEDIA_ROOT = BASE_DIR / "media"
# Use local/data/uploads in DEBUG mode, otherwise use /app/data/uploads
if DEBUG:
DATA_DIR = BASE_DIR / "local" / "data"
DATA_DIR.mkdir(parents=True, exist_ok=True)
MEDIA_ROOT = DATA_DIR / "uploads"
MEDIA_ROOT.mkdir(parents=True, exist_ok=True)
else:
DATA_DIR = Path("/app/data")
DATA_DIR.mkdir(parents=True, exist_ok=True)
MEDIA_ROOT = DATA_DIR / "uploads"
MEDIA_ROOT.mkdir(parents=True, exist_ok=True)
# Default primary key field type
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
@ -103,3 +127,29 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Allow iframe embedding (for the quoting upload box)
# Note: We'll handle this per-view using @xframe_options_exempt decorator
# Email configuration
EMAIL_CONFIG = None
try:
from .email_config import get_email_config
email_config = get_email_config()
if email_config:
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = email_config["host"]
EMAIL_PORT = email_config["port"]
EMAIL_USE_SSL = True # Port 465 uses SSL
EMAIL_HOST_USER = email_config["user"]
EMAIL_HOST_PASSWORD = email_config["password"]
DEFAULT_FROM_EMAIL = email_config["from_email"]
SERVER_EMAIL = email_config["from_email"]
EMAIL_CONFIG = email_config
else:
# Fallback to console backend for development
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
except Exception as e:
# If email config fails, use console backend
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
# Owner email for notifications
OWNER_EMAIL = os.environ.get("OWNER_EMAIL", "")

View File

@ -0,0 +1,117 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #0066cc;
color: white;
padding: 20px;
text-align: center;
border-radius: 5px 5px 0 0;
}
.content {
background-color: #f9f9f9;
padding: 20px;
border: 1px solid #ddd;
border-top: none;
}
.info-box {
background-color: white;
padding: 15px;
margin: 15px 0;
border-left: 4px solid #0066cc;
border-radius: 4px;
}
.file-list {
list-style: none;
padding: 0;
margin: 10px 0;
}
.file-list li {
padding: 8px;
background-color: #f0f0f0;
margin: 5px 0;
border-radius: 3px;
}
.file-list li a {
color: #0066cc;
text-decoration: none;
font-weight: bold;
}
.file-list li a:hover {
text-decoration: underline;
}
.footer {
text-align: center;
color: #666;
font-size: 12px;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #ddd;
}
</style>
</head>
<body>
<div class="header">
<h1>Quote Request Received</h1>
</div>
<div class="content">
<p>Thank you for your quote request! We have received your submission and will review it shortly.</p>
<div class="info-box">
<strong>Submission Details:</strong><br>
<strong>Submission ID:</strong> #{{ submission.id }}<br>
<strong>Submitted at:</strong> {{ submission.submitted_at|date:"F d, Y g:i A" }}
</div>
{% if submission.description %}
<div class="info-box">
<strong>Your Notes:</strong><br>
{{ submission.description|linebreaks }}
</div>
{% endif %}
{% if files %}
<div class="info-box">
<strong>Files You Uploaded ({{ files|length }}):</strong>
<ul class="file-list">
{% for file_url_data in file_urls %}
<li>
<a href="{{ file_url_data.url }}" target="_blank">{{ file_url_data.file.original_filename }}</a>
({{ file_url_data.file.file_size|filesizeformat }})
</li>
{% endfor %}
</ul>
</div>
{% endif %}
<p>We will contact you at <strong>{{ submission.email }}</strong> regarding your quote request.</p>
<p><em>This submission will be kept on file until {{ submission.expiration_time|date:"F d, Y" }}.</em></p>
</div>
<div class="footer">
<p>Sedutto Machineworks</p>
<p>Thank you for your interest in our services!</p>
</div>
</body>
</html>

View File

@ -0,0 +1,115 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #0066cc;
color: white;
padding: 20px;
text-align: center;
border-radius: 5px 5px 0 0;
}
.content {
background-color: #f9f9f9;
padding: 20px;
border: 1px solid #ddd;
border-top: none;
}
.info-box {
background-color: white;
padding: 15px;
margin: 15px 0;
border-left: 4px solid #0066cc;
border-radius: 4px;
}
.file-list {
list-style: none;
padding: 0;
margin: 10px 0;
}
.file-list li {
padding: 8px;
background-color: #f0f0f0;
margin: 5px 0;
border-radius: 3px;
}
.file-list li a {
color: #0066cc;
text-decoration: none;
font-weight: bold;
}
.file-list li a:hover {
text-decoration: underline;
}
.footer {
text-align: center;
color: #666;
font-size: 12px;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #ddd;
}
</style>
</head>
<body>
<div class="header">
<h1>New Quote Request Submission</h1>
</div>
<div class="content">
<p>HUZZAHH! A new quote request has been submitted!</p>
<div class="info-box">
<strong>Submitted by:</strong> {{ submission.email }}<br>
<strong>Submitted at:</strong> {{ submission.submitted_at|date:"F d, Y g:i A" }}<br>
<strong>Submission ID:</strong> #{{ submission.id }}
</div>
{% if submission.description %}
<div class="info-box">
<strong>Description/Notes:</strong><br>
{{ submission.description|linebreaks }}
</div>
{% endif %}
{% if files %}
<div class="info-box">
<strong>Uploaded Files ({{ files|length }}):</strong>
<ul class="file-list">
{% for file_url_data in file_urls %}
<li>
<a href="{{ file_url_data.url }}" target="_blank">{{ file_url_data.file.original_filename }}</a>
({{ file_url_data.file.file_size|filesizeformat }})
</li>
{% endfor %}
</ul>
</div>
{% endif %}
<p><strong>Note:</strong> This submission will expire on {{ submission.expiration_time|date:"F d, Y g:i A" }}.
(But who knows when joe will actually delete it)</p>
</div>
<div class="footer">
<p>Sedutto Machineworks - Joe is tottally awesome</p>
</div>
</body>
</html>