feat: Implement email sending utilities and templates for job notifications
Some checks failed
CI/CD Pipeline / test (push) Failing after 4m9s
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:
223
web/app.py
223
web/app.py
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user