Add email sending (service + 3 flows) and a general asset label generator
Email: a stdlib SMTP mailer (settings-first config, graceful no-op when unconfigured), a test-email endpoint wired to the Email settings page, forced first-login password change (users.mustchangepassword, migration 7d23, /change-password flow), new-user welcome mail, and on-demand report/alert delivery (POST /api/reports/email + Email Report buttons) with an external-cron-with-a-scoped-PAT path documented for automation. All tests patch smtplib - no network. Labels: a shared /print/asset-label/<type>/<id> view any asset detail page opens - card or plain style, QR or barcode, configurable encoding. Per-type qr_target_* templates plus label_default_style/codetype/encodes settings on the Printing page. Measuring-tool labels default to encoding their inspection-operation code (derived from the location name, e.g. 0615), so every tool in an area shares the area code - verified by decoding the rendered QR. Machine labels default to the machine number; blank-serial handled gracefully. 808 tests pass; both features verified live. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@ from flask_jwt_extended import (
|
||||
get_jwt_identity,
|
||||
current_user
|
||||
)
|
||||
from werkzeug.security import check_password_hash
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
from shopdb.extensions import db, cache
|
||||
from shopdb.core.models import User
|
||||
@@ -151,7 +151,8 @@ def login():
|
||||
'email': user.email,
|
||||
'firstname': user.firstname,
|
||||
'lastname': user.lastname,
|
||||
'roles': [r.rolename for r in user.roles]
|
||||
'roles': [r.rolename for r in user.roles],
|
||||
'mustchangepassword': bool(user.mustchangepassword)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -196,10 +197,59 @@ def get_current_user():
|
||||
'firstname': current_user.firstname,
|
||||
'lastname': current_user.lastname,
|
||||
'roles': [r.rolename for r in current_user.roles],
|
||||
'permissions': current_user.getpermissions()
|
||||
'permissions': current_user.getpermissions(),
|
||||
'mustchangepassword': bool(current_user.mustchangepassword)
|
||||
})
|
||||
|
||||
|
||||
@auth_bp.route('/change-password', methods=['POST'])
|
||||
@jwt_required()
|
||||
def change_password():
|
||||
"""Change the authenticated user's own password.
|
||||
|
||||
Request:
|
||||
{ "current_password": "string", "new_password": "string" }
|
||||
|
||||
current_password is required for a normal self-service change. When the
|
||||
account is flagged mustchangepassword (an admin set a temporary password),
|
||||
the forced-change case accepts new_password alone. On success the flag is
|
||||
cleared and any lockout/failed-login state is reset.
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
new_password = data.get('new_password')
|
||||
current_password = data.get('current_password')
|
||||
|
||||
if not new_password:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR, 'new_password is required')
|
||||
if len(new_password) < 8:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'New password must be at least 8 characters')
|
||||
|
||||
user = current_user
|
||||
|
||||
# A normal change must prove knowledge of the current password. The forced
|
||||
# first-login case (admin-set temp password) may skip it.
|
||||
if not user.mustchangepassword:
|
||||
if not current_password:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR, 'current_password is required')
|
||||
if not check_password_hash(user.passwordhash, current_password):
|
||||
return error_response(
|
||||
ErrorCodes.UNAUTHORIZED,
|
||||
'Current password is incorrect', http_code=401)
|
||||
|
||||
user.passwordhash = generate_password_hash(new_password)
|
||||
user.mustchangepassword = False
|
||||
user.failedlogins = 0
|
||||
user.lockeduntil = None
|
||||
db.session.commit()
|
||||
|
||||
return success_response(
|
||||
{'mustchangepassword': False}, message='Password changed')
|
||||
|
||||
|
||||
@auth_bp.route('/logout', methods=['POST'])
|
||||
@jwt_required()
|
||||
def logout():
|
||||
|
||||
@@ -12,6 +12,7 @@ from shopdb.core.models import (
|
||||
Application
|
||||
)
|
||||
from shopdb.utils.responses import success_response, error_response, ErrorCodes
|
||||
from shopdb.utils.authz import require_permission
|
||||
|
||||
reports_bp = Blueprint('reports', __name__)
|
||||
|
||||
@@ -513,6 +514,55 @@ def pc_relationships():
|
||||
})
|
||||
|
||||
|
||||
@reports_bp.route('/email', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('reports.export')
|
||||
def email_report():
|
||||
"""Email a report's data as an HTML table (on-demand delivery).
|
||||
|
||||
Request:
|
||||
{
|
||||
"subject": "Warranty Report",
|
||||
"columns": [{"key": "vendor", "label": "Vendor"}, ...],
|
||||
"rows": [{"vendor": "...", ...}, ...],
|
||||
"intro": "optional lead paragraph",
|
||||
"to": "addr@example.com" // optional; defaults to alert_recipients
|
||||
}
|
||||
|
||||
Recipients default to the site's Alert Recipients when `to` is omitted, so
|
||||
this doubles as the alert delivery path. There is no scheduler in this app:
|
||||
sending is on-demand. To automate it, point an external cron job at this
|
||||
endpoint using an API token (PAT) scoped to reports.export.
|
||||
"""
|
||||
from shopdb.utils.mailer import get_smtp_config, render_table_email, try_send
|
||||
|
||||
data = request.get_json() or {}
|
||||
subject = data.get('subject') or 'ShopDB report'
|
||||
columns = data.get('columns') or []
|
||||
rows = data.get('rows') or []
|
||||
|
||||
config = get_smtp_config()
|
||||
recipient = data.get('to') or config['alert_recipients']
|
||||
|
||||
if not config['enabled'] or not config['host']:
|
||||
return success_response(
|
||||
{'sent': False, 'reason': 'notconfigured'},
|
||||
message='Email is not configured (SMTP disabled or host unset).')
|
||||
if not recipient:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'No recipient. Provide "to" or set Alert Recipients.')
|
||||
|
||||
html, text = render_table_email(
|
||||
subject, columns, rows, intro=data.get('intro'))
|
||||
ok, error = try_send(recipient, subject, html, text=text)
|
||||
if ok:
|
||||
return success_response({'sent': True}, message='Report emailed.')
|
||||
return success_response(
|
||||
{'sent': False, 'error': error},
|
||||
message='Report email failed: ' + (error or 'unknown error'))
|
||||
|
||||
|
||||
@reports_bp.route('', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def list_reports():
|
||||
|
||||
@@ -264,6 +264,44 @@ def update_setting(key: str):
|
||||
return success_response(_serialize_setting(setting), message='Setting updated')
|
||||
|
||||
|
||||
@settings_bp.route('/test-email', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('settings.edit')
|
||||
def test_email():
|
||||
"""Send a test email to verify SMTP configuration.
|
||||
|
||||
Request: { "to": "addr@example.com" } (falls back to alert_recipients)
|
||||
|
||||
Returns a 200 with a `sent` flag either way. When SMTP is not configured
|
||||
the response explains that gracefully; when a real send fails the SMTP error
|
||||
is surfaced with any credential scrubbed out.
|
||||
"""
|
||||
from shopdb.utils.mailer import get_smtp_config, render_email, try_send
|
||||
|
||||
data = request.get_json() or {}
|
||||
config = get_smtp_config()
|
||||
recipient = data.get('to') or config['alert_recipients']
|
||||
|
||||
if not config['enabled'] or not config['host']:
|
||||
return success_response(
|
||||
{'sent': False, 'reason': 'notconfigured'},
|
||||
message='Email is not configured (SMTP disabled or host unset).')
|
||||
if not recipient:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'No recipient. Provide "to" or set Alert Recipients.')
|
||||
|
||||
html, text = render_email(
|
||||
'ShopDB test email',
|
||||
'<p>This is a test message confirming your SMTP settings work.</p>')
|
||||
ok, error = try_send(recipient, 'ShopDB test email', html, text=text)
|
||||
if ok:
|
||||
return success_response({'sent': True}, message='Test email sent.')
|
||||
return success_response(
|
||||
{'sent': False, 'error': error},
|
||||
message='Test email failed: ' + (error or 'unknown error'))
|
||||
|
||||
|
||||
@settings_bp.route('', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('settings.edit')
|
||||
@@ -530,6 +568,73 @@ def build_default_settings():
|
||||
'category': 'printing',
|
||||
'description': "USB mini-label code style: 'barcode' (CODE128 of the serial number) or 'qr' (QR code linking to the QR target)."
|
||||
},
|
||||
{
|
||||
'key': 'qr_target_machine',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'printing',
|
||||
'description': 'Custom URL template for machine labels. Blank = link to the machine page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}.'
|
||||
},
|
||||
{
|
||||
'key': 'qr_target_computer',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'printing',
|
||||
'description': 'Custom URL template for computer labels. Blank = link to the computer page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}.'
|
||||
},
|
||||
{
|
||||
'key': 'qr_target_network_device',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'printing',
|
||||
'description': 'Custom URL template for network-device labels. Blank = link to the device page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}.'
|
||||
},
|
||||
{
|
||||
'key': 'qr_target_measuring_tool',
|
||||
'value': '',
|
||||
'valuetype': 'string',
|
||||
'category': 'printing',
|
||||
'description': 'Custom URL template for measuring-tool labels. Blank = link to the tool page. Placeholders: {assetid}, {assetnumber}, {serialnumber}, {name}, {pluginid}, {locationcode}, {locationname}.'
|
||||
},
|
||||
{
|
||||
'key': 'label_default_style',
|
||||
'value': 'card',
|
||||
'valuetype': 'string',
|
||||
'category': 'printing',
|
||||
'description': "Default asset-label layout: 'card' (badge with image and identity) or 'plain' (just the code and a caption)."
|
||||
},
|
||||
{
|
||||
'key': 'label_default_codetype',
|
||||
'value': 'qr',
|
||||
'valuetype': 'string',
|
||||
'category': 'printing',
|
||||
'description': "Default asset-label code type: 'qr' (QR code) or 'barcode' (CODE128)."
|
||||
},
|
||||
]
|
||||
|
||||
# Per-asset-type default for what a label's code encodes. Machines default
|
||||
# to their machine number (assetnumber), measuring tools to their inspection
|
||||
# location code, everything else to a link to the asset page. Values:
|
||||
# assetpage | assetnumber | serialnumber | location | custom.
|
||||
labelencodesdefaults = {
|
||||
'machine': 'assetnumber',
|
||||
'computer': 'assetpage',
|
||||
'printer': 'assetpage',
|
||||
'network_device': 'assetpage',
|
||||
'measuring_tool': 'location',
|
||||
}
|
||||
printingdefaults += [
|
||||
{
|
||||
'key': f'label_default_encodes_{assettype}',
|
||||
'value': value,
|
||||
'valuetype': 'string',
|
||||
'category': 'printing',
|
||||
'description': f'What a {assettype} label encodes by default: assetpage, '
|
||||
'assetnumber, serialnumber'
|
||||
+ (', location' if assettype == 'measuring_tool' else '')
|
||||
+ ', or custom.',
|
||||
}
|
||||
for assettype, value in labelencodesdefaults.items()
|
||||
]
|
||||
|
||||
# Collector pc-type -> ComputerType mapping is computers-plugin domain;
|
||||
|
||||
@@ -60,13 +60,18 @@ def create_user():
|
||||
if User.query.filter_by(email=data['email']).first():
|
||||
return error_response(ErrorCodes.CONFLICT, 'Email already exists', http_code=409)
|
||||
|
||||
# Admin-created accounts are forced to change the password on first login
|
||||
# unless the admin explicitly opts out.
|
||||
mustchange = data.get('mustchangepassword', True)
|
||||
|
||||
user = User(
|
||||
username=data['username'],
|
||||
email=data['email'],
|
||||
passwordhash=generate_password_hash(data['password']),
|
||||
firstname=data.get('firstname'),
|
||||
lastname=data.get('lastname'),
|
||||
isactive=data.get('isactive', True)
|
||||
isactive=data.get('isactive', True),
|
||||
mustchangepassword=bool(mustchange)
|
||||
)
|
||||
|
||||
# Assign roles
|
||||
@@ -82,7 +87,47 @@ def create_user():
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return success_response(user_to_dict(user), message='User created', http_code=201)
|
||||
# Best-effort welcome email. The account exists regardless of mail outcome;
|
||||
# a failure is surfaced as a warning in the response, never a hard error.
|
||||
warning = None
|
||||
if data.get('sendwelcome', True) and user.email:
|
||||
sent = _send_welcome_email(user, data['password'])
|
||||
if not sent:
|
||||
warning = 'User created but the welcome email could not be sent.'
|
||||
|
||||
payload = user_to_dict(user)
|
||||
if warning:
|
||||
payload['warning'] = warning
|
||||
return success_response(payload, message='User created', http_code=201)
|
||||
|
||||
|
||||
def _send_welcome_email(user, temp_password):
|
||||
"""Send a new-user welcome email with sign-in details. Returns True on send.
|
||||
|
||||
Best-effort: any failure (including email being disabled) returns False so
|
||||
the caller can surface a soft warning without failing user creation.
|
||||
"""
|
||||
from shopdb.core.api.settings import get_cached_settings
|
||||
from shopdb.utils.mailer import render_email, send_email
|
||||
|
||||
settings = get_cached_settings() or {}
|
||||
facility = settings.get('facility_name') or 'ShopDB'
|
||||
base_url = (settings.get('site_base_url') or '').rstrip('/')
|
||||
login_link = f'{base_url}/login' if base_url else 'the ShopDB sign-in page'
|
||||
|
||||
body = (
|
||||
f'<p>An account has been created for you at <strong>{facility}</strong>.</p>'
|
||||
'<table style="border-collapse:collapse;font-size:14px;margin:12px 0;">'
|
||||
f'<tr><td style="padding:4px 12px 4px 0;color:#666;">Username</td>'
|
||||
f'<td><strong>{user.username}</strong></td></tr>'
|
||||
f'<tr><td style="padding:4px 12px 4px 0;color:#666;">Temporary password</td>'
|
||||
f'<td><code>{temp_password}</code></td></tr>'
|
||||
'</table>'
|
||||
f'<p>Sign in at {login_link}. You will be asked to set a new password '
|
||||
'the first time you log in.</p>'
|
||||
)
|
||||
html, text = render_email(f'Welcome to {facility}', body)
|
||||
return send_email(user.email, f'Your {facility} account', html, text=text)
|
||||
|
||||
|
||||
@users_bp.route('/<int:userid>', methods=['PUT'])
|
||||
@@ -335,6 +380,7 @@ def user_to_dict(user: User) -> dict:
|
||||
'lastname': user.lastname,
|
||||
'isactive': user.isactive,
|
||||
'islocked': user.islocked,
|
||||
'mustchangepassword': bool(user.mustchangepassword),
|
||||
'lastlogindate': user.lastlogindate.isoformat() + 'Z' if user.lastlogindate else None,
|
||||
'failedlogins': user.failedlogins,
|
||||
'roles': [{'roleid': r.roleid, 'rolename': r.rolename} for r in user.roles],
|
||||
|
||||
@@ -7,7 +7,7 @@ from .vendor import Vendor
|
||||
from .model import Model
|
||||
from .businessunit import BusinessUnit
|
||||
from .dashboarddefault import DashboardDefault
|
||||
from .location import Location, LocationType
|
||||
from .location import Location, LocationType, derive_locationcode
|
||||
from .operatingsystem import OperatingSystem
|
||||
from .relationship import AssetRelationship, RelationshipType, RelationshipTypePropagation
|
||||
from .communication import Communication, CommunicationType
|
||||
@@ -37,6 +37,7 @@ __all__ = [
|
||||
'DashboardDefault',
|
||||
'Location',
|
||||
'LocationType',
|
||||
'derive_locationcode',
|
||||
'OperatingSystem',
|
||||
# Relationships
|
||||
'AssetRelationship',
|
||||
|
||||
@@ -251,6 +251,12 @@ class Asset(BaseModel, SoftDeleteMixin, AuditMixin):
|
||||
if result.get('mapy') is None:
|
||||
result['mapy'] = inherited['mapy']
|
||||
|
||||
# Operation/short code of the resolved location (own or inherited).
|
||||
# Derived from the location name's leading token; labels can encode a
|
||||
# tool's inspection operation instead of the tool. None when unplaced.
|
||||
from .location import derive_locationcode
|
||||
result['locationcode'] = derive_locationcode(result.get('locationname'))
|
||||
|
||||
# Include extension data if requested
|
||||
if include_type_data:
|
||||
ext_data = self._get_extension_data()
|
||||
|
||||
@@ -4,6 +4,21 @@ from shopdb.extensions import db
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
def derive_locationcode(locationname):
|
||||
"""Operation/short code for a location = leading token of its name.
|
||||
|
||||
Locations have no dedicated code column, so the operation code is the
|
||||
leading whitespace-delimited token of the location name. Example:
|
||||
'0615 Blisk Inspection' -> '0615'. Blank/None name -> None. Used by
|
||||
printed labels that encode a tool's inspection operation rather than the
|
||||
tool itself.
|
||||
"""
|
||||
if not locationname:
|
||||
return None
|
||||
parts = str(locationname).strip().split()
|
||||
return parts[0] if parts else None
|
||||
|
||||
|
||||
class LocationType(BaseModel):
|
||||
"""Location classification (ADR-001 shared reference data).
|
||||
|
||||
@@ -60,10 +75,16 @@ class Location(BaseModel):
|
||||
locationtype = db.relationship('LocationType')
|
||||
parent = db.relationship('Location', remote_side=[locationid])
|
||||
|
||||
@property
|
||||
def locationcode(self):
|
||||
"""Derived operation/short code (leading token of the name)."""
|
||||
return derive_locationcode(self.locationname)
|
||||
|
||||
def to_dict(self):
|
||||
data = super().to_dict()
|
||||
data['locationtypename'] = self.locationtype.locationtype if self.locationtype else None
|
||||
data['parentlocationname'] = self.parent.locationname if self.parent else None
|
||||
data['locationcode'] = self.locationcode
|
||||
return data
|
||||
|
||||
def __repr__(self):
|
||||
|
||||
@@ -198,6 +198,9 @@ class User(BaseModel):
|
||||
lastlogindate = db.Column(db.DateTime)
|
||||
failedlogins = db.Column(db.Integer, default=0)
|
||||
lockeduntil = db.Column(db.DateTime)
|
||||
# Forced password change: set true when an admin creates the account, so the
|
||||
# user is steered through a password change before landing in the app.
|
||||
mustchangepassword = db.Column(db.Boolean, default=False, nullable=False)
|
||||
|
||||
# Relationships
|
||||
roles = db.relationship(
|
||||
|
||||
259
shopdb/utils/mailer.py
Normal file
259
shopdb/utils/mailer.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""Email sending service (stdlib smtplib/ssl/email only).
|
||||
|
||||
Reads SMTP configuration settings-first (via the cached settings map) with an
|
||||
environment-variable fallback when any SMTP_* env var is present. When email is
|
||||
disabled or the host is unset the sender is a graceful no-op that logs a warning
|
||||
and returns False, so an unconfigured site never crashes on a send attempt.
|
||||
|
||||
Public helpers:
|
||||
send_email(to, subject, html, text=None) -> bool
|
||||
try_send(to, subject, html, text=None) -> (bool, error_or_None)
|
||||
send_alert(subject, html, text=None) -> bool
|
||||
render_email(title, body_html, intro=None) -> (html, text)
|
||||
render_table_email(title, columns, rows, intro=None) -> (html, text)
|
||||
|
||||
The SMTP password is never logged.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import formataddr
|
||||
|
||||
from flask import current_app, has_app_context
|
||||
|
||||
# Env var names that back each SMTP setting when settings are blank.
|
||||
_ENV_MAP = {
|
||||
'smtp_host': 'SMTP_HOST',
|
||||
'smtp_port': 'SMTP_PORT',
|
||||
'smtp_username': 'SMTP_USERNAME',
|
||||
'smtp_password': 'SMTP_PASSWORD',
|
||||
'smtp_from_address': 'SMTP_FROM_ADDRESS',
|
||||
'smtp_from_name': 'SMTP_FROM_NAME',
|
||||
'alert_recipients': 'SMTP_ALERT_RECIPIENTS',
|
||||
}
|
||||
|
||||
# Connect/send timeout in seconds. Keeps a wedged relay from hanging a request.
|
||||
_SMTP_TIMEOUT = 10
|
||||
|
||||
|
||||
def _log():
|
||||
"""App logger when in an app context, else a module logger."""
|
||||
if has_app_context():
|
||||
return current_app.logger
|
||||
import logging
|
||||
return logging.getLogger('shopdb.mailer')
|
||||
|
||||
|
||||
def _env_active():
|
||||
"""True when the deployment supplies SMTP_* env overrides."""
|
||||
return any(k.startswith('SMTP_') for k in os.environ)
|
||||
|
||||
|
||||
def get_smtp_config():
|
||||
"""Resolve the SMTP config settings-first with env fallback.
|
||||
|
||||
Returns a dict with typed fields. `enabled` is False when the site has not
|
||||
turned email on; callers should treat that as a no-op signal.
|
||||
"""
|
||||
settings = {}
|
||||
if has_app_context():
|
||||
# Local import avoids a circular import at module load.
|
||||
from shopdb.core.api.settings import get_cached_settings
|
||||
try:
|
||||
settings = get_cached_settings() or {}
|
||||
except Exception:
|
||||
settings = {}
|
||||
|
||||
env_active = _env_active()
|
||||
|
||||
def pick(key, default=''):
|
||||
val = settings.get(key)
|
||||
if (val is None or val == '') and env_active:
|
||||
val = os.environ.get(_ENV_MAP.get(key, ''), default)
|
||||
return default if val is None else val
|
||||
|
||||
enabled = bool(settings.get('smtp_enabled'))
|
||||
if not enabled and env_active:
|
||||
enabled = os.environ.get('SMTP_ENABLED', '').lower() in ('true', '1', 'yes')
|
||||
|
||||
use_tls = settings.get('smtp_use_tls')
|
||||
if use_tls is None:
|
||||
if env_active:
|
||||
use_tls = os.environ.get('SMTP_USE_TLS', 'true').lower() in ('true', '1', 'yes')
|
||||
else:
|
||||
use_tls = True
|
||||
|
||||
try:
|
||||
port = int(pick('smtp_port', 587) or 587)
|
||||
except (ValueError, TypeError):
|
||||
port = 587
|
||||
|
||||
return {
|
||||
'enabled': enabled,
|
||||
'host': pick('smtp_host'),
|
||||
'port': port,
|
||||
'username': pick('smtp_username'),
|
||||
'password': pick('smtp_password'),
|
||||
'use_tls': bool(use_tls),
|
||||
'from_address': pick('smtp_from_address'),
|
||||
'from_name': pick('smtp_from_name') or 'ShopDB',
|
||||
'alert_recipients': pick('alert_recipients'),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_recipients(to):
|
||||
"""Coerce a recipient spec (string, comma/semicolon list, or iterable) to a
|
||||
clean list of addresses."""
|
||||
if not to:
|
||||
return []
|
||||
if isinstance(to, str):
|
||||
parts = re.split(r'[,;]', to)
|
||||
else:
|
||||
parts = list(to)
|
||||
return [p.strip() for p in parts if p and p.strip()]
|
||||
|
||||
|
||||
def try_send(to, subject, html, text=None):
|
||||
"""Send an email. Returns (ok, error).
|
||||
|
||||
ok is False with error=None when email is not configured (a graceful
|
||||
no-op). ok is False with an error string when a real send failed. The SMTP
|
||||
password is never included in the error.
|
||||
"""
|
||||
config = get_smtp_config()
|
||||
recipients = _normalize_recipients(to)
|
||||
|
||||
if not config['enabled'] or not config['host']:
|
||||
_log().warning('Email not sent: SMTP is disabled or host is unset.')
|
||||
return False, None
|
||||
if not recipients:
|
||||
_log().warning('Email not sent: no recipients.')
|
||||
return False, 'No recipients specified'
|
||||
if not config['from_address']:
|
||||
_log().warning('Email not sent: from address is unset.')
|
||||
return False, 'From address is not configured'
|
||||
|
||||
message = MIMEMultipart('alternative')
|
||||
message['Subject'] = subject
|
||||
message['From'] = formataddr((config['from_name'], config['from_address']))
|
||||
message['To'] = ', '.join(recipients)
|
||||
# Plaintext first so alternative-aware clients prefer the HTML part.
|
||||
message.attach(MIMEText(text or _html_to_text(html), 'plain', 'utf-8'))
|
||||
message.attach(MIMEText(html, 'html', 'utf-8'))
|
||||
|
||||
try:
|
||||
context = ssl.create_default_context()
|
||||
if config['port'] == 465:
|
||||
server = smtplib.SMTP_SSL(
|
||||
config['host'], config['port'],
|
||||
timeout=_SMTP_TIMEOUT, context=context)
|
||||
else:
|
||||
server = smtplib.SMTP(
|
||||
config['host'], config['port'], timeout=_SMTP_TIMEOUT)
|
||||
with server:
|
||||
if config['port'] != 465 and config['use_tls']:
|
||||
server.starttls(context=context)
|
||||
if config['username']:
|
||||
server.login(config['username'], config['password'])
|
||||
server.sendmail(config['from_address'], recipients, message.as_string())
|
||||
_log().info('Email sent to %d recipient(s): %s', len(recipients), subject)
|
||||
return True, None
|
||||
except Exception as exception:
|
||||
# Never let the password reach the log or the caller.
|
||||
error = _scrub(str(exception), config['password'])
|
||||
_log().error('Email send failed: %s', error)
|
||||
return False, error
|
||||
|
||||
|
||||
def send_email(to, subject, html, text=None):
|
||||
"""Send an email. Returns True on success, False otherwise (no-op safe)."""
|
||||
ok, _error = try_send(to, subject, html, text=text)
|
||||
return ok
|
||||
|
||||
|
||||
def send_alert(subject, html, text=None):
|
||||
"""Send an alert to the site's configured alert_recipients. Returns False
|
||||
when email is off or no alert recipients are configured."""
|
||||
config = get_smtp_config()
|
||||
recipients = _normalize_recipients(config['alert_recipients'])
|
||||
if not recipients:
|
||||
_log().warning('Alert not sent: no alert_recipients configured.')
|
||||
return False
|
||||
return send_email(recipients, subject, html, text=text)
|
||||
|
||||
|
||||
def _scrub(value, secret):
|
||||
"""Remove a secret substring from a string (defensive log hygiene)."""
|
||||
if secret and secret in value:
|
||||
return value.replace(secret, '***')
|
||||
return value
|
||||
|
||||
|
||||
def _html_to_text(html):
|
||||
"""Very small HTML-to-text fallback for the plaintext alternative."""
|
||||
text = re.sub(r'(?i)<br\s*/?>', '\n', html)
|
||||
text = re.sub(r'(?i)</(p|tr|div|h[1-6]|li)>', '\n', text)
|
||||
text = re.sub(r'<[^>]+>', '', text)
|
||||
text = re.sub(r'\n{3,}', '\n\n', text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def _escape(value):
|
||||
"""HTML-escape a cell value."""
|
||||
return (str('' if value is None else value)
|
||||
.replace('&', '&').replace('<', '<').replace('>', '>'))
|
||||
|
||||
|
||||
def render_email(title, body_html, intro=None):
|
||||
"""Wrap body HTML in a simple branded shell. Returns (html, text)."""
|
||||
intro_html = f'<p style="margin:0 0 16px;color:#444;">{_escape(intro)}</p>' if intro else ''
|
||||
html = (
|
||||
'<div style="font-family:Arial,Helvetica,sans-serif;max-width:640px;'
|
||||
'margin:0 auto;color:#222;">'
|
||||
f'<h2 style="color:#1a1a1a;margin:0 0 12px;">{_escape(title)}</h2>'
|
||||
f'{intro_html}{body_html}'
|
||||
'<hr style="border:none;border-top:1px solid #ddd;margin:24px 0 12px;">'
|
||||
'<p style="font-size:12px;color:#888;margin:0;">Sent by ShopDB.</p>'
|
||||
'</div>'
|
||||
)
|
||||
return html, _html_to_text(html)
|
||||
|
||||
|
||||
def render_table_email(title, columns, rows, intro=None):
|
||||
"""Render tabular report data as an HTML table email. Returns (html, text).
|
||||
|
||||
columns: list of {'key','label'} dicts or of plain strings.
|
||||
rows: list of dicts keyed by the column keys.
|
||||
"""
|
||||
normalized = []
|
||||
for column in columns or []:
|
||||
if isinstance(column, dict):
|
||||
normalized.append((column.get('key'), column.get('label', column.get('key'))))
|
||||
else:
|
||||
normalized.append((column, column))
|
||||
|
||||
header_cells = ''.join(
|
||||
f'<th style="text-align:left;padding:8px 10px;border-bottom:2px solid #ccc;'
|
||||
f'background:#f4f4f4;">{_escape(label)}</th>'
|
||||
for _key, label in normalized)
|
||||
|
||||
body_rows = []
|
||||
for row in rows or []:
|
||||
cells = ''.join(
|
||||
f'<td style="padding:8px 10px;border-bottom:1px solid #eee;">'
|
||||
f'{_escape(row.get(key) if isinstance(row, dict) else row)}</td>'
|
||||
for key, _label in normalized)
|
||||
body_rows.append(f'<tr>{cells}</tr>')
|
||||
|
||||
table = (
|
||||
'<table style="border-collapse:collapse;width:100%;font-size:14px;">'
|
||||
f'<thead><tr>{header_cells}</tr></thead>'
|
||||
f'<tbody>{"".join(body_rows) or "<tr><td>No data</td></tr>"}</tbody>'
|
||||
'</table>'
|
||||
)
|
||||
count_line = f'<p style="color:#666;font-size:13px;">{len(rows or [])} row(s).</p>'
|
||||
return render_email(title, table + count_line, intro=intro)
|
||||
Reference in New Issue
Block a user