Move email SMTP config to settings page with encrypted password storage; add cryptography dependency
This commit is contained in:
parent
5277c20d24
commit
8e5c2f5867
5 changed files with 160 additions and 57 deletions
138
app.py
138
app.py
|
|
@ -35,6 +35,12 @@ from email.message import EmailMessage
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
|
import base64
|
||||||
|
|
||||||
# Flask and related imports
|
# Flask and related imports
|
||||||
from flask import Flask, request, render_template, redirect, url_for, flash, session, send_from_directory, abort, current_app, send_file
|
from flask import Flask, request, render_template, redirect, url_for, flash, session, send_from_directory, abort, current_app, send_file
|
||||||
|
|
@ -112,24 +118,15 @@ def save_upload(request_id, file_obj, version, song_title=None):
|
||||||
"""
|
"""
|
||||||
Save an uploaded MP3 file for a request, preserving the original filename
|
Save an uploaded MP3 file for a request, preserving the original filename
|
||||||
with a version prefix (e.g. A - MySong.mp3 / B - MySong.mp3).
|
with a version prefix (e.g. A - MySong.mp3 / B - MySong.mp3).
|
||||||
Applies the configured metadata tags (title, artist, album, year, genre, comment).
|
Applies the configured metadata tags (title, artist, album, year, comment).
|
||||||
:param request_id: database ID of the request
|
|
||||||
:param file_obj: Flask FileStorage from request.files
|
|
||||||
:param version: 'a' or 'b'
|
|
||||||
:param song_title: title to write into the MP3 title tag
|
|
||||||
:return: full filesystem path saved, or None on missing/invalid file
|
|
||||||
"""
|
"""
|
||||||
if not file_obj or file_obj.filename == '':
|
if not file_obj or file_obj.filename == '':
|
||||||
return None
|
return None
|
||||||
if not allowed_file(file_obj.filename):
|
if not allowed_file(file_obj.filename):
|
||||||
flash('Only MP3 files are allowed.', 'error')
|
flash('Only MP3 files are allowed.', 'error')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Use Werkzeug's secure_filename to strip unsafe characters.
|
|
||||||
original = secure_filename(file_obj.filename)
|
original = secure_filename(file_obj.filename)
|
||||||
# Prefix with A or B so the operator knows which version it is.
|
|
||||||
filename = f"{version.upper()} - {original}"
|
filename = f"{version.upper()} - {original}"
|
||||||
|
|
||||||
p = upload_path(request_id)
|
p = upload_path(request_id)
|
||||||
dest = p / filename
|
dest = p / filename
|
||||||
file_obj.save(dest)
|
file_obj.save(dest)
|
||||||
|
|
@ -145,12 +142,10 @@ def apply_mp3_tags(path, title=None):
|
||||||
cfg = load_booth_settings()
|
cfg = load_booth_settings()
|
||||||
try:
|
try:
|
||||||
audio = MP3(path)
|
audio = MP3(path)
|
||||||
# Ensure EasyID3 wrapper is available for the file.
|
|
||||||
if audio.tags is None:
|
if audio.tags is None:
|
||||||
audio.add_tags()
|
audio.add_tags()
|
||||||
if not isinstance(audio.tags, EasyID3):
|
if not isinstance(audio.tags, EasyID3):
|
||||||
audio.tags = EasyID3()
|
audio.tags = EasyID3()
|
||||||
|
|
||||||
tags = audio.tags
|
tags = audio.tags
|
||||||
if title:
|
if title:
|
||||||
tags['title'] = title
|
tags['title'] = title
|
||||||
|
|
@ -160,13 +155,7 @@ def apply_mp3_tags(path, title=None):
|
||||||
tags['album'] = cfg['album']
|
tags['album'] = cfg['album']
|
||||||
if cfg.get('year'):
|
if cfg.get('year'):
|
||||||
tags['date'] = str(cfg['year'])
|
tags['date'] = str(cfg['year'])
|
||||||
if cfg.get('genre'):
|
|
||||||
tags['genre'] = cfg['genre']
|
|
||||||
|
|
||||||
audio.save()
|
audio.save()
|
||||||
|
|
||||||
# Comment requires a real ID3 frame, not EasyID3. Write both COMM and a TXXX
|
|
||||||
# "Comment" frame for broad compatibility across players and readers.
|
|
||||||
if cfg.get('comment'):
|
if cfg.get('comment'):
|
||||||
from mutagen.id3 import COMM, TXXX
|
from mutagen.id3 import COMM, TXXX
|
||||||
audio2 = MP3(path)
|
audio2 = MP3(path)
|
||||||
|
|
@ -176,7 +165,6 @@ def apply_mp3_tags(path, title=None):
|
||||||
audio2.tags["TXXX:Comment"] = TXXX(encoding=3, desc='Comment', text=cfg['comment'])
|
audio2.tags["TXXX:Comment"] = TXXX(encoding=3, desc='Comment', text=cfg['comment'])
|
||||||
audio2.save()
|
audio2.save()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Don't fail the upload just because tagging failed; log/flash only if inside request context.
|
|
||||||
try:
|
try:
|
||||||
flash(f'Warning: could not tag MP3: {e}', 'error')
|
flash(f'Warning: could not tag MP3: {e}', 'error')
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
|
|
@ -184,9 +172,44 @@ def apply_mp3_tags(path, title=None):
|
||||||
logging.getLogger('app').warning('Could not tag MP3 %s: %s', path, e)
|
logging.getLogger('app').warning('Could not tag MP3 %s: %s', path, e)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_fernet():
|
||||||
|
"""Derive a Fernet key from the Flask SECRET_KEY so stored values are encrypted."""
|
||||||
|
secret = current_app.config['SECRET_KEY'].encode()
|
||||||
|
kdf = PBKDF2HMAC(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=32,
|
||||||
|
salt=b'theme-song-booth-v1',
|
||||||
|
iterations=480000,
|
||||||
|
)
|
||||||
|
key = base64.urlsafe_b64encode(kdf.derive(secret))
|
||||||
|
return Fernet(key)
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_value(value):
|
||||||
|
"""Encrypt a string using the Flask SECRET_KEY. Returns base64 ciphertext."""
|
||||||
|
if not value:
|
||||||
|
return ''
|
||||||
|
return _get_fernet().encrypt(value.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_value(ciphertext):
|
||||||
|
"""Decrypt a string previously encrypted by encrypt_value."""
|
||||||
|
if not ciphertext:
|
||||||
|
return ''
|
||||||
|
try:
|
||||||
|
return _get_fernet().decrypt(ciphertext.encode()).decode()
|
||||||
|
except Exception:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def settings_file_path():
|
||||||
|
"""Return the path to the persistent runtime settings JSON file."""
|
||||||
|
return Path(current_app.config['UPLOAD_FOLDER']).parent / current_app.config['SETTINGS_FILE']
|
||||||
|
|
||||||
|
|
||||||
def load_booth_settings():
|
def load_booth_settings():
|
||||||
"""Load persistent runtime settings from JSON file inside the upload parent."""
|
"""Load persistent runtime settings from JSON file inside the upload parent."""
|
||||||
cfg_path = Path(current_app.config['UPLOAD_FOLDER']).parent / 'booth_settings.json'
|
cfg_path = settings_file_path()
|
||||||
if cfg_path.exists():
|
if cfg_path.exists():
|
||||||
try:
|
try:
|
||||||
return json.loads(cfg_path.read_text())
|
return json.loads(cfg_path.read_text())
|
||||||
|
|
@ -195,6 +218,30 @@ def load_booth_settings():
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_booth_settings(settings):
|
||||||
|
"""Persist runtime settings to JSON file."""
|
||||||
|
cfg_path = settings_file_path()
|
||||||
|
try:
|
||||||
|
cfg_path.write_text(json.dumps(settings, indent=2))
|
||||||
|
except OSError as e:
|
||||||
|
flash(f'Warning: could not save settings: {e}', 'error')
|
||||||
|
|
||||||
|
|
||||||
|
def get_email_config():
|
||||||
|
"""
|
||||||
|
Return the effective SMTP configuration.
|
||||||
|
Runtime-encrypted settings from disk override env defaults.
|
||||||
|
"""
|
||||||
|
cfg = load_booth_settings()
|
||||||
|
return {
|
||||||
|
'SMTP_HOST': cfg.get('smtp_host', current_app.config['SMTP_HOST']),
|
||||||
|
'SMTP_PORT': int(cfg.get('smtp_port') or current_app.config['SMTP_PORT']),
|
||||||
|
'SMTP_USER': cfg.get('smtp_user', current_app.config['SMTP_USER']),
|
||||||
|
'SMTP_PASS': decrypt_value(cfg.get('smtp_pass', '')) or current_app.config['SMTP_PASS'],
|
||||||
|
'SMTP_FROM': cfg.get('smtp_from', current_app.config['SMTP_FROM']),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_refresh_seconds():
|
def get_refresh_seconds():
|
||||||
"""Return the dashboard auto-refresh interval in seconds (10, 20, or 30)."""
|
"""Return the dashboard auto-refresh interval in seconds (10, 20, or 30)."""
|
||||||
cfg = load_booth_settings()
|
cfg = load_booth_settings()
|
||||||
|
|
@ -215,17 +262,10 @@ def save_booth_settings(settings):
|
||||||
|
|
||||||
|
|
||||||
def send_email(to, subject, body, attachments=None, inline_images=None):
|
def send_email(to, subject, body, attachments=None, inline_images=None):
|
||||||
"""
|
"""Send an email using the configured or runtime SMTP settings."""
|
||||||
Send an email via SMTP using credentials from the app config.
|
cfg = get_email_config()
|
||||||
:param to: recipient address
|
|
||||||
:param subject: email subject
|
|
||||||
:param body: plain-text body
|
|
||||||
:param attachments: optional list of (filepath, attachment_name) tuples
|
|
||||||
:param inline_images: optional list of (filepath, cid) tuples for inline images
|
|
||||||
"""
|
|
||||||
cfg = current_app.config
|
|
||||||
if not cfg['SMTP_PASS']:
|
if not cfg['SMTP_PASS']:
|
||||||
raise RuntimeError('SMTP_PASS is not configured')
|
raise RuntimeError('SMTP password is not configured')
|
||||||
|
|
||||||
msg = EmailMessage()
|
msg = EmailMessage()
|
||||||
msg['From'] = cfg['SMTP_FROM']
|
msg['From'] = cfg['SMTP_FROM']
|
||||||
|
|
@ -233,7 +273,6 @@ def send_email(to, subject, body, attachments=None, inline_images=None):
|
||||||
msg['Subject'] = subject
|
msg['Subject'] = subject
|
||||||
msg.set_content(body)
|
msg.set_content(body)
|
||||||
|
|
||||||
# Build HTML version of the email with the same body and optional inline images.
|
|
||||||
html_body = body.replace('\n', '<br>\n')
|
html_body = body.replace('\n', '<br>\n')
|
||||||
if inline_images:
|
if inline_images:
|
||||||
for _, cid in inline_images:
|
for _, cid in inline_images:
|
||||||
|
|
@ -241,18 +280,14 @@ def send_email(to, subject, body, attachments=None, inline_images=None):
|
||||||
html_body += f'<br><br><hr style="border:none;border-top:1px solid #ddd;"/><p style="font-size:0.9rem;color:#555;">Dionysis Media: stories, sound, and a little divine chaos — <a href="https://dionysismedia.ca/">https://dionysismedia.ca/</a></p>'
|
html_body += f'<br><br><hr style="border:none;border-top:1px solid #ddd;"/><p style="font-size:0.9rem;color:#555;">Dionysis Media: stories, sound, and a little divine chaos — <a href="https://dionysismedia.ca/">https://dionysismedia.ca/</a></p>'
|
||||||
msg.add_alternative(html_body, subtype='html')
|
msg.add_alternative(html_body, subtype='html')
|
||||||
|
|
||||||
# Attach inline images for HTML rendering.
|
|
||||||
if inline_images:
|
if inline_images:
|
||||||
for path, cid in inline_images:
|
for path, cid in inline_images:
|
||||||
with open(path, 'rb') as f:
|
with open(path, 'rb') as f:
|
||||||
data = f.read()
|
data = f.read()
|
||||||
# Guess subtype from extension.
|
|
||||||
ext = Path(path).suffix.lower().lstrip('.')
|
ext = Path(path).suffix.lower().lstrip('.')
|
||||||
maintype = 'image'
|
|
||||||
subtype = ext if ext in ('png', 'jpg', 'jpeg', 'gif', 'webp') else 'png'
|
subtype = ext if ext in ('png', 'jpg', 'jpeg', 'gif', 'webp') else 'png'
|
||||||
msg.get_payload()[1].add_related(data, maintype=maintype, subtype=subtype, cid=f'<{cid}>')
|
msg.get_payload()[1].add_related(data, maintype='image', subtype=subtype, cid=f'<{cid}>')
|
||||||
|
|
||||||
# Attach any MP3 files as audio/mpeg attachments.
|
|
||||||
if attachments:
|
if attachments:
|
||||||
for path, name in attachments:
|
for path, name in attachments:
|
||||||
with open(path, 'rb') as f:
|
with open(path, 'rb') as f:
|
||||||
|
|
@ -642,9 +677,18 @@ def admin_settings():
|
||||||
status_counts[req['status']] = status_counts.get(req['status'], 0) + 1
|
status_counts[req['status']] = status_counts.get(req['status'], 0) + 1
|
||||||
|
|
||||||
# Load persistent runtime settings (max_revisions overrides env var if set).
|
# Load persistent runtime settings (max_revisions overrides env var if set).
|
||||||
cfg_path = Path(current_app.config['UPLOAD_FOLDER']).parent / 'booth_settings.json'
|
|
||||||
runtime_settings = load_booth_settings()
|
runtime_settings = load_booth_settings()
|
||||||
current_max_revisions = runtime_settings.get('max_revisions', current_app.config['MAX_REVISIONS'])
|
current_max_revisions = runtime_settings.get('max_revisions', current_app.config.get('MAX_REVISIONS', 2))
|
||||||
|
current_refresh_seconds = runtime_settings.get('refresh_seconds', 10)
|
||||||
|
|
||||||
|
# Effective email config to show in the form (non-sensitive only; password left blank).
|
||||||
|
email_form = {
|
||||||
|
'smtp_host': runtime_settings.get('smtp_host', current_app.config['SMTP_HOST']),
|
||||||
|
'smtp_port': runtime_settings.get('smtp_port', str(current_app.config['SMTP_PORT'])),
|
||||||
|
'smtp_user': runtime_settings.get('smtp_user', current_app.config['SMTP_USER']),
|
||||||
|
'smtp_from': runtime_settings.get('smtp_from', current_app.config['SMTP_FROM']),
|
||||||
|
'smtp_pass_set': bool(runtime_settings.get('smtp_pass', '')),
|
||||||
|
}
|
||||||
|
|
||||||
# Compute upload folder stats.
|
# Compute upload folder stats.
|
||||||
total_upload_size = 0
|
total_upload_size = 0
|
||||||
|
|
@ -747,6 +791,21 @@ def admin_settings():
|
||||||
flash('MP3 metadata defaults saved.', 'success')
|
flash('MP3 metadata defaults saved.', 'success')
|
||||||
return redirect(url_for('admin_settings'))
|
return redirect(url_for('admin_settings'))
|
||||||
|
|
||||||
|
elif action == 'save_email_config':
|
||||||
|
# Update SMTP settings from the settings form. Password is encrypted.
|
||||||
|
cfg = load_booth_settings()
|
||||||
|
cfg['smtp_host'] = request.form.get('smtp_host', '').strip() or None
|
||||||
|
cfg['smtp_port'] = request.form.get('smtp_port', '').strip() or None
|
||||||
|
cfg['smtp_user'] = request.form.get('smtp_user', '').strip() or None
|
||||||
|
cfg['smtp_from'] = request.form.get('smtp_from', '').strip() or None
|
||||||
|
new_pass = request.form.get('smtp_pass', '').strip()
|
||||||
|
# Only overwrite the stored password if a new value was provided.
|
||||||
|
if new_pass:
|
||||||
|
cfg['smtp_pass'] = encrypt_value(new_pass)
|
||||||
|
save_booth_settings(cfg)
|
||||||
|
flash('Email (SMTP) settings saved. Password stored encrypted.', 'success')
|
||||||
|
return redirect(url_for('admin_settings'))
|
||||||
|
|
||||||
elif action == 'save_refresh':
|
elif action == 'save_refresh':
|
||||||
# Update dashboard auto-refresh interval.
|
# Update dashboard auto-refresh interval.
|
||||||
val = request.form.get('refresh_seconds', '10').strip()
|
val = request.form.get('refresh_seconds', '10').strip()
|
||||||
|
|
@ -804,7 +863,8 @@ def admin_settings():
|
||||||
db_path=str(db_path),
|
db_path=str(db_path),
|
||||||
upload_path=str(upload_root),
|
upload_path=str(upload_root),
|
||||||
current_max_revisions=current_max_revisions,
|
current_max_revisions=current_max_revisions,
|
||||||
current_refresh_seconds=runtime_settings.get('refresh_seconds', 10),
|
current_refresh_seconds=current_refresh_seconds,
|
||||||
|
email_form=email_form,
|
||||||
metadata=runtime_settings,
|
metadata=runtime_settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
11
booth_settings.json
Normal file
11
booth_settings.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"artist": "Trollgorithm",
|
||||||
|
"album": "Theme Booth",
|
||||||
|
"year": "2026",
|
||||||
|
"comment": "Booth song",
|
||||||
|
"smtp_host": "mail.example.com",
|
||||||
|
"smtp_port": "587",
|
||||||
|
"smtp_user": "test@example.com",
|
||||||
|
"smtp_from": "test@example.com",
|
||||||
|
"smtp_pass": "gAAAAABqbn8zm5n1fPOfAAMubFQmfsaCDcE16MFoRMEDPLqlnZIocc2W-qMPSYbFkls6E7aabpqEo3aAGLUsG5TlaWlqpFhWyg=="
|
||||||
|
}
|
||||||
27
config.py
27
config.py
|
|
@ -3,14 +3,13 @@ config.py
|
||||||
=========
|
=========
|
||||||
Configuration object loaded by Flask from environment variables.
|
Configuration object loaded by Flask from environment variables.
|
||||||
|
|
||||||
The application expects values to be provided via Portainer environment
|
Most operational settings are now editable at runtime from /admin/settings
|
||||||
variables or a local .env file during development.
|
and stored in booth_settings.json on disk. Sensitive email credentials are
|
||||||
|
encrypted with the Flask SECRET_KEY when saved.
|
||||||
|
|
||||||
All values have sensible defaults where safe, but the following MUST be
|
Required environment values:
|
||||||
set in production:
|
- APP_SECRET_KEY (used to sign sessions and encrypt stored settings)
|
||||||
- APP_SECRET_KEY
|
- ADMIN_PASSWORD (plain-text login password)
|
||||||
- ADMIN_PASSWORD
|
|
||||||
- SMTP_PASS
|
|
||||||
- PUBLIC_BASE_URL
|
- PUBLIC_BASE_URL
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -22,7 +21,7 @@ load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
# Flask secret key: used to sign session cookies. Must be a long random string in production.
|
# Flask secret key: used to sign session cookies and encrypt stored credentials.
|
||||||
SECRET_KEY = os.environ.get('APP_SECRET_KEY', 'dev-secret-change-me')
|
SECRET_KEY = os.environ.get('APP_SECRET_KEY', 'dev-secret-change-me')
|
||||||
|
|
||||||
# SQLite database path inside the container.
|
# SQLite database path inside the container.
|
||||||
|
|
@ -31,10 +30,14 @@ class Config:
|
||||||
# Directory where uploaded MP3 files are stored inside the container.
|
# Directory where uploaded MP3 files are stored inside the container.
|
||||||
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', '/app/uploads')
|
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', '/app/uploads')
|
||||||
|
|
||||||
|
# Runtime settings file: stored next to the upload folder for persistence.
|
||||||
|
SETTINGS_FILE = os.environ.get('SETTINGS_FILE', 'booth_settings.json')
|
||||||
|
|
||||||
# Only MP3 uploads are allowed.
|
# Only MP3 uploads are allowed.
|
||||||
ALLOWED_EXTENSIONS = {'mp3'}
|
ALLOWED_EXTENSIONS = {'mp3'}
|
||||||
|
|
||||||
# SMTP server settings for sending customer emails.
|
# Default SMTP server settings for sending customer emails.
|
||||||
|
# These can be overridden from /admin/settings and stored encrypted.
|
||||||
SMTP_HOST = os.environ.get('SMTP_HOST', 'mailroot8.namespro.ca')
|
SMTP_HOST = os.environ.get('SMTP_HOST', 'mailroot8.namespro.ca')
|
||||||
SMTP_PORT = int(os.environ.get('SMTP_PORT', '465'))
|
SMTP_PORT = int(os.environ.get('SMTP_PORT', '465'))
|
||||||
SMTP_USER = os.environ.get('SMTP_USER', 'ai@hallsworth.ca')
|
SMTP_USER = os.environ.get('SMTP_USER', 'ai@hallsworth.ca')
|
||||||
|
|
@ -44,12 +47,6 @@ class Config:
|
||||||
# Admin login password (plain text, set via env).
|
# Admin login password (plain text, set via env).
|
||||||
ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', '')
|
ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', '')
|
||||||
|
|
||||||
# Optional operator alert email. Currently unused because the dashboard is the queue.
|
|
||||||
ADMIN_ALERT_EMAIL = os.environ.get('ADMIN_ALERT_EMAIL', '')
|
|
||||||
|
|
||||||
# Maximum number of revision rounds a customer is allowed to request automatically.
|
|
||||||
MAX_REVISIONS = int(os.environ.get('MAX_REVISIONS', '2'))
|
|
||||||
|
|
||||||
# Public HTTPS URL used in customer emails and QR codes.
|
# Public HTTPS URL used in customer emails and QR codes.
|
||||||
PUBLIC_BASE_URL = os.environ.get('PUBLIC_BASE_URL', 'http://127.0.0.1:5000')
|
PUBLIC_BASE_URL = os.environ.get('PUBLIC_BASE_URL', 'http://127.0.0.1:5000')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,3 +14,4 @@ python-dotenv
|
||||||
werkzeug
|
werkzeug
|
||||||
mutagen
|
mutagen
|
||||||
flask-limiter
|
flask-limiter
|
||||||
|
cryptography
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@
|
||||||
font-size:.9rem;
|
font-size:.9rem;
|
||||||
color:var(--muted);
|
color:var(--muted);
|
||||||
}
|
}
|
||||||
input[type="text"],input[type="number"],textarea,select{
|
input[type="text"],input[type="number"],input[type="password"],textarea,select{
|
||||||
width:100%;
|
width:100%;
|
||||||
padding:.6rem;
|
padding:.6rem;
|
||||||
border-radius:.4rem;
|
border-radius:.4rem;
|
||||||
|
|
@ -106,7 +106,7 @@
|
||||||
}
|
}
|
||||||
button:hover{background:var(--accent);}
|
button:hover{background:var(--accent);}
|
||||||
|
|
||||||
/* Metadata table */
|
/* Metadata and email config tables */
|
||||||
.meta-table{
|
.meta-table{
|
||||||
width:100%;
|
width:100%;
|
||||||
border-collapse:collapse;
|
border-collapse:collapse;
|
||||||
|
|
@ -125,7 +125,7 @@
|
||||||
}
|
}
|
||||||
.meta-table td:last-child{padding-right:0;}
|
.meta-table td:last-child{padding-right:0;}
|
||||||
.meta-table tr:last-child td{border-bottom:none;}
|
.meta-table tr:last-child td{border-bottom:none;}
|
||||||
.meta-table input,.meta-table textarea{
|
.meta-table input,.meta-table textarea,.meta-table select{
|
||||||
margin-top:0;
|
margin-top:0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -234,6 +234,40 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Email / SMTP configuration -->
|
||||||
|
<div class="section">
|
||||||
|
<h2>Email (SMTP) Settings</h2>
|
||||||
|
<p class="copy-hint">These settings are used to send confirmation, preview, and delivery emails to customers. The password is stored encrypted using the Flask secret key.</p>
|
||||||
|
<form method="POST">
|
||||||
|
<input type="hidden" name="action" value="save_email_config">
|
||||||
|
<table class="meta-table">
|
||||||
|
<tr>
|
||||||
|
<td>SMTP Host</td>
|
||||||
|
<td><input type="text" id="smtp_host" name="smtp_host" value="{{ email_form.smtp_host }}" placeholder="e.g. mailroot8.namespro.ca"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>SMTP Port</td>
|
||||||
|
<td><input type="text" id="smtp_port" name="smtp_port" value="{{ email_form.smtp_port }}" placeholder="e.g. 465"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>SMTP Username</td>
|
||||||
|
<td><input type="text" id="smtp_user" name="smtp_user" value="{{ email_form.smtp_user }}" placeholder="e.g. ai@hallsworth.ca"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>From Address</td>
|
||||||
|
<td><input type="text" id="smtp_from" name="smtp_from" value="{{ email_form.smtp_from }}" placeholder="e.g. ai@hallsworth.ca"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>SMTP Password</td>
|
||||||
|
<td>
|
||||||
|
<input type="password" id="smtp_pass" name="smtp_pass" placeholder="{% if email_form.smtp_pass_set %}Stored encrypted — type to replace{% else %}Enter password{% endif %}">
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<button type="submit">Save Email Settings</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- MP3 metadata defaults -->
|
<!-- MP3 metadata defaults -->
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>MP3 Metadata Tags</h2>
|
<h2>MP3 Metadata Tags</h2>
|
||||||
|
|
|
||||||
Reference in a new issue