feat: Implement email sending utilities and templates for job notifications
Some checks failed
CI/CD Pipeline / test (push) Failing after 4m9s

- Added email_service.py for sending emails with SMTP configuration.
- Introduced email_templates.py to render job alert email subjects and bodies.
- Enhanced scraper.py to extract contact information from job listings.
- Updated settings.js to handle negative keyword input validation.
- Created email.html and email_templates.html for managing email subscriptions and templates in the admin interface.
- Modified base.html to include links for email alerts and templates.
- Expanded user settings.html to allow management of negative keywords.
- Updated utils.py to include functions for retrieving negative keywords and email settings.
- Enhanced job filtering logic to exclude jobs containing negative keywords.
This commit is contained in:
2025-11-28 18:15:08 +01:00
parent 8afb208985
commit 2185a07ff0
23 changed files with 2660 additions and 63 deletions

View File

@@ -18,8 +18,10 @@ from web.db import (
get_user_by_id,
get_user_regions,
get_user_keywords,
get_user_negative_keywords,
set_user_regions,
set_user_keywords,
set_user_negative_keywords,
get_all_regions,
get_all_keywords,
stats_overview,
@@ -30,7 +32,15 @@ from web.db import (
rename_region,
rename_keyword,
change_region_color,
change_keyword_color
change_keyword_color,
subscribe_email,
unsubscribe_email,
list_email_subscriptions,
list_email_templates,
create_email_template,
update_email_template,
delete_email_template,
get_email_template,
)
from web.utils import (
initialize_users_from_settings,
@@ -39,6 +49,7 @@ from web.utils import (
now_iso,
)
from web.db import get_all_regions, get_all_keywords
from web.email_templates import render_job_alert_email
app = Flask(__name__)
app.secret_key = os.environ.get("FLASK_SECRET", "dev-secret-change-me")
@@ -109,24 +120,30 @@ def index():
# Apply user preference filters if no explicit filters provided
selected_region = request.args.get("region")
selected_keyword = request.args.get("keyword")
if not selected_region and session.get('username'):
user_negative_keywords = []
if session.get('username'):
try:
prefs = get_user_regions(session['username'])
if prefs:
# If user has region prefs, filter to them by default
all_jobs = [j for j in all_jobs if j.get(
'region') in set(prefs)]
username = session['username']
if not selected_region:
prefs = get_user_regions(username)
if prefs:
# If user has region prefs, filter to them by default
all_jobs = [j for j in all_jobs if j.get(
'region') in set(prefs)]
if not selected_keyword:
prefs = get_user_keywords(username)
if prefs:
all_jobs = [j for j in all_jobs if j.get(
'keyword') in set(prefs)]
# Always fetch negative keywords for logged-in users
user_negative_keywords = get_user_negative_keywords(username)
except Exception:
pass
if not selected_keyword and session.get('username'):
try:
prefs = get_user_keywords(session['username'])
if prefs:
all_jobs = [j for j in all_jobs if j.get(
'keyword') in set(prefs)]
except Exception:
pass
filtered_jobs = filter_jobs(all_jobs, selected_region, selected_keyword)
filtered_jobs = filter_jobs(
all_jobs, selected_region, selected_keyword, negative_keywords=user_negative_keywords)
return render_template(
"index.html",
@@ -180,23 +197,26 @@ def jobs():
# Respect user preferences when no explicit filters provided
region = request.args.get("region")
keyword = request.args.get("keyword")
if not region and session.get('username'):
user_negative_keywords = []
if session.get('username'):
try:
prefs = get_user_regions(session['username'])
if prefs:
all_jobs = [j for j in all_jobs if j.get(
'region') in set(prefs)]
username = session['username']
if not region:
prefs = get_user_regions(username)
if prefs:
all_jobs = [j for j in all_jobs if j.get(
'region') in set(prefs)]
if not keyword:
prefs = get_user_keywords(username)
if prefs:
all_jobs = [j for j in all_jobs if j.get(
'keyword') in set(prefs)]
user_negative_keywords = get_user_negative_keywords(username)
except Exception:
pass
if not keyword and session.get('username'):
try:
prefs = get_user_keywords(session['username'])
if prefs:
all_jobs = [j for j in all_jobs if j.get(
'keyword') in set(prefs)]
except Exception:
pass
return jsonify(filter_jobs(all_jobs, region, keyword))
return jsonify(filter_jobs(all_jobs, region, keyword, negative_keywords=user_negative_keywords))
@app.route('/job_details', methods=['GET'])
@@ -358,6 +378,130 @@ def admin_user_delete(user_id):
return redirect(url_for('admin_users'))
@app.route('/admin/emails', methods=['GET', 'POST'])
def admin_emails():
if not require_admin():
return redirect(url_for('login'))
if request.method == 'POST':
action = (request.form.get('action') or '').strip().lower()
email = (request.form.get('email') or '').strip()
try:
if action == 'subscribe':
subscribe_email(email)
flash('Subscription saved')
elif action == 'unsubscribe':
if unsubscribe_email(email):
flash('Subscription deactivated')
else:
flash('No matching subscription found')
elif action == 'reactivate':
subscribe_email(email)
flash('Subscription reactivated')
else:
flash('Unknown action')
except ValueError as exc:
flash(f'Error: {exc}')
except Exception as exc:
flash(f'Error: {exc}')
return redirect(url_for('admin_emails'))
subscriptions = list_email_subscriptions()
class Sub(dict):
__getattr__ = dict.get
subscription_rows = [Sub(s) for s in subscriptions]
active_count = sum(1 for s in subscription_rows if s.get('is_active'))
return render_template(
'admin/email.html',
title='Email Subscriptions',
subscriptions=subscription_rows,
total_active=active_count,
total=len(subscription_rows),
)
@app.route('/admin/email-templates', methods=['GET', 'POST'])
def admin_email_templates():
if not require_admin():
return redirect(url_for('login'))
if request.method == 'POST':
action = (request.form.get('action') or '').strip().lower()
template_id = request.form.get('template_id')
name = request.form.get('name') or ''
slug = request.form.get('slug') or ''
subject = request.form.get('subject') or ''
body = request.form.get('body') or ''
is_active = request.form.get('is_active') == 'on'
try:
if action == 'create':
create_email_template(
name=name, slug=slug, subject=subject, body=body, is_active=is_active)
flash('Template created')
elif action == 'update':
update_email_template(
int(template_id or 0),
name=name,
slug=slug or None,
subject=subject,
body=body,
is_active=is_active,
)
flash('Template updated')
elif action == 'delete':
if delete_email_template(int(template_id or 0)):
flash('Template deleted')
else:
flash('Template not found')
else:
flash('Unknown action')
except ValueError as exc:
flash(f'Error: {exc}')
except Exception as exc:
flash(f'Error: {exc}')
return redirect(url_for('admin_email_templates'))
templates = list_email_templates(include_inactive=True)
edit_id = request.args.get('template_id', type=int)
editing = get_email_template(edit_id) if edit_id else None
preview_payload = None
preview_template = None
preview_id = request.args.get('preview_id', type=int)
if preview_id:
preview_template = get_email_template(preview_id)
if preview_template:
sample_jobs = [
{
'title': 'Senior Python Engineer',
'company': 'ACME Corp',
'location': 'Remote',
'url': 'https://example.com/jobs/1',
},
{
'title': 'Data Engineer',
'company': 'Globex',
'location': 'New York, NY',
'url': 'https://example.com/jobs/2',
},
]
preview_payload = render_job_alert_email(
sample_jobs,
region='preview-region',
keyword='preview-keyword',
template_override=preview_template,
)
return render_template(
'admin/email_templates.html',
title='Email Templates',
templates=templates,
editing=editing,
preview=preview_payload,
preview_template=preview_template,
)
# ---------------- User settings (regions/keywords) -------------------------
@app.route('/settings', methods=['GET', 'POST'])
@@ -369,6 +513,8 @@ def user_settings():
# Accept JSON or form posts. Normalize singular/plural names.
sel_regions: list[str] = []
sel_keywords: list[str] = []
sel_negative_keywords: list[str] = []
if request.is_json:
data = request.get_json(silent=True) or {}
sel_regions = [
@@ -377,16 +523,25 @@ def user_settings():
sel_keywords = [
(v or '').strip() for v in (data.get('keywords') or []) if v and (v or '').strip()
]
sel_negative_keywords = [
(v or '').strip() for v in (data.get('negative_keywords') or []) if v and (v or '').strip()
]
else:
# HTML form fallback: support names 'regions' or 'region', 'keywords' or 'keyword'
r_vals = request.form.getlist(
'regions') + request.form.getlist('region')
k_vals = request.form.getlist(
'keywords') + request.form.getlist('keyword')
nk_vals = request.form.getlist(
'negative_keywords') + request.form.getlist('negative_keyword')
sel_regions = [(v or '').strip()
for v in r_vals if v and (v or '').strip()]
sel_keywords = [(v or '').strip()
for v in k_vals if v and (v or '').strip()]
sel_negative_keywords = [(v or '').strip()
for v in nk_vals if v and (v or '').strip()]
# Upsert any new values into master lists
for r in sel_regions:
try:
@@ -398,9 +553,14 @@ def user_settings():
upsert_keyword(k)
except Exception:
pass
# Negative keywords are upserted inside set_user_negative_keywords implicitly if we wanted,
# but let's stick to the pattern. Actually set_user_negative_keywords calls upsert_negative_keyword.
try:
set_user_regions(username, sel_regions)
set_user_keywords(username, sel_keywords)
set_user_negative_keywords(username, sel_negative_keywords)
# For JSON callers, return 200 without redirect
if request.is_json:
return jsonify({"status": "ok"})
@@ -415,6 +575,8 @@ def user_settings():
all_keywords = get_all_keywords()
user_regions = get_user_regions(username)
user_keywords = get_user_keywords(username)
user_negative_keywords = get_user_negative_keywords(username)
return render_template(
'user/settings.html',
title='Your Preferences',
@@ -422,6 +584,7 @@ def user_settings():
all_keywords=all_keywords,
user_regions=user_regions,
user_keywords=user_keywords,
user_negative_keywords=user_negative_keywords,
)