From fed51f8c28eeec1161049b5687625e8049e6c755 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Tue, 7 Apr 2026 11:24:08 -0400 Subject: [PATCH] 04/07 updated timezone, report via email, etc --- .env | 6 +- app/__init__.py | 88 ++++- app/models.py | 34 ++ app/routes/admin.py | 194 ++++++++++- app/routes/tickets.py | 81 ++++- app/services/email_ingestion_service.py | 316 ++++++++++++++++++ app/templates/admin/kb_list.html | 9 +- app/templates/admin/settings.html | 131 ++++++++ app/templates/admin/tickets.html | 107 +++++- app/templates/tickets/dashboard_employee.html | 2 +- app/templates/tickets/detail.html | 14 +- app/templates/tickets/kb_article.html | 65 +++- app/templates/tickets/list.html | 2 +- app/templates/tickets/notifications.html | 2 +- 14 files changed, 1022 insertions(+), 29 deletions(-) create mode 100644 app/services/email_ingestion_service.py diff --git a/.env b/.env index 56d68d1..c5a6bb3 100644 --- a/.env +++ b/.env @@ -17,9 +17,9 @@ MAIL_SERVER=mail.ltservicesinc.com MAIL_PORT=465 MAIL_USE_TLS=False MAIL_USE_SSL=True -MAIL_USERNAME=it.helpdesk@ltservicesinc.com -MAIL_PASSWORD=IT*H3lpD35k! -MAIL_DEFAULT_SENDER=IT Helpdesk +MAIL_USERNAME=donotreply@ltservicesinc.com +MAIL_PASSWORD=M-6ZW+omp7n] +MAIL_DEFAULT_SENDER=IT Helpdesk # IT Department Email (receives all new ticket notifications) IT_DEPT_EMAIL=da.nguyen8744@gmail.com diff --git a/app/__init__.py b/app/__init__.py index 9b6839d..047a43d 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -117,6 +117,33 @@ def create_app(config_name=None): # object is already in session), then fall back to a SELECT by PK. return db.session.get(User, int(user_id)) + # ── Timezone filter ────────────────────────────────────────────────────── + # All datetimes in the DB are stored as UTC (naive). The localtime filter + # converts them to the admin-configured display timezone for templates. + # Python routes should call app_localtime(dt) when they need a local datetime. + from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + + def _get_tz(app_obj): + """Return the configured ZoneInfo, falling back to UTC on bad input.""" + from app.models import SystemSetting + tz_name = SystemSetting.get('app_timezone', 'America/New_York') or 'America/New_York' + try: + return ZoneInfo(tz_name) + except (ZoneInfoNotFoundError, KeyError): + app_obj.logger.warning(f'[TZ] Unknown timezone {tz_name!r}, falling back to UTC') + return ZoneInfo('UTC') + + def localtime_filter(dt, fmt='%b %d, %Y %H:%M %Z'): + """Jinja2 filter: convert a naive UTC datetime to local display time.""" + if dt is None: + return '' + from datetime import timezone as _tz + tz = _get_tz(app) + aware = dt.replace(tzinfo=_tz.utc) + return aware.astimezone(tz).strftime(fmt) + + app.jinja_env.filters['localtime'] = localtime_filter + # ── Context processors ──────────────────────────────────────────────────── @app.context_processor def inject_globals(): @@ -136,7 +163,17 @@ def create_app(config_name=None): 'logo_initials' : SystemSetting.get('logo_initials', 'TD'), 'primary_color' : SystemSetting.get('primary_color', '#2563eb'), } - return dict(unread_notifications=unread, branding=branding) + from datetime import datetime as _dt, timezone as _tz + from zoneinfo import ZoneInfo as _ZI + from app.models import SystemSetting as _SS + _tz_name = _SS.get('app_timezone', 'America/New_York') or 'America/New_York' + try: + _zone = _ZI(_tz_name) + except Exception: + _zone = _ZI('UTC') + now_local = _dt.now(_tz.utc).astimezone(_zone) + return dict(unread_notifications=unread, branding=branding, + now_local=now_local, app_tz_name=_tz_name) # ── DB initialisation (first run) ───────────────────────────────────────── with app.app_context(): @@ -144,6 +181,9 @@ def create_app(config_name=None): _seed_admin(app) _seed_settings() + # ── Email ingestion background scheduler ──────────────────────────────────── + _start_email_ingestion_scheduler(app) + # ── SLA background scheduler ────────────────────────────────────────────── # APScheduler runs inside the gunicorn worker process (single-worker # eventlet setup), so no cross-process coordination is needed. @@ -188,12 +228,58 @@ def _start_sla_scheduler(app): app.logger.error(f'[SLA] Failed to start APScheduler: {exc}') +def _start_email_ingestion_scheduler(app): + """Start the APScheduler job that polls the inbound mailbox for new emails. + + The interval is read from SystemSetting at job creation time (default 5 min). + The job is a no-op when email_ingestion_enabled = '0', so it is safe to + always register it — no credentials are required until the admin enables it. + """ + import os as _os + if _os.environ.get('WERKZEUG_RUN_MAIN') == 'true': + return # skip reloader child process + + try: + from apscheduler.schedulers.background import BackgroundScheduler + from apscheduler.triggers.interval import IntervalTrigger + from app.services.email_ingestion_service import check_inbound_email + from app.models import SystemSetting + + with app.app_context(): + interval = int(SystemSetting.get('email_ingestion_interval', '5') or '5') + + scheduler = BackgroundScheduler(daemon=True) + scheduler.add_job( + func = check_inbound_email, + trigger = IntervalTrigger(minutes=interval), + id = 'email_ingest', + name = 'Inbound Email Ingestion', + replace_existing = True, + args = [app], + ) + scheduler.start() + app.logger.info( + f'[EMAIL INGEST] APScheduler started — polling every {interval} minute(s)' + ) + except Exception as exc: + app.logger.error(f'[EMAIL INGEST] Failed to start scheduler: {exc}') + + def _seed_settings(): """Ensure all required system settings exist with safe defaults.""" from app.models import SystemSetting defaults = [ ('registration_enabled', 'true', 'Allow new users to self-register via /auth/register'), + ('app_timezone', 'America/New_York', 'Display timezone for all dates and times in the UI'), ('sla_critical_hours', '4', 'Hours before a CRITICAL ticket is considered overdue'), + ('email_ingestion_enabled', '0', 'Enable automatic ticket creation from inbound email (1=on, 0=off)'), + ('email_ingestion_host', '', 'IMAP server hostname (e.g. imap.gmail.com)'), + ('email_ingestion_port', '993', 'IMAP SSL port'), + ('email_ingestion_user', '', 'Mailbox username / email address'), + ('email_ingestion_password', '', 'Mailbox password (stored in plaintext — use a dedicated app password)'), + ('email_ingestion_folder', 'INBOX', 'IMAP folder to watch for new mail'), + ('email_ingestion_move_to', 'Processed', 'IMAP folder to move processed mail into'), + ('email_ingestion_interval', '5', 'Poll interval in minutes'), ('sla_high_hours', '8', 'Hours before a HIGH ticket is considered overdue'), ('sla_medium_hours', '48', 'Hours before a MEDIUM ticket is considered overdue'), ('sla_low_hours', '120', 'Hours before a LOW ticket is considered overdue'), diff --git a/app/models.py b/app/models.py index 0278c5e..e6439c0 100644 --- a/app/models.py +++ b/app/models.py @@ -419,3 +419,37 @@ class CannedResponse(db.Model): def __repr__(self): return f'' + + +class KBFeedback(db.Model): + """One thumbs-up or thumbs-down vote per user per KB article. + + The unique constraint ensures each user can only vote once per article. + Updating a vote replaces the existing row via the route logic (upsert). + Votes are stored anonymously in aggregate on the KnowledgeBase table via + two counters (helpful_count, not_helpful_count) for fast display in the + admin list without joining this table on every page load. + """ + __tablename__ = 'kb_feedback' + + id = db.Column(db.Integer, primary_key=True) + article_id = db.Column(db.Integer, db.ForeignKey('knowledge_base.id', + ondelete='CASCADE'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('users.id', + ondelete='CASCADE'), nullable=False) + is_helpful = db.Column(db.Boolean, nullable=False) # True=👍 False=👎 + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, + onupdate=datetime.utcnow) + + article = db.relationship('KnowledgeBase', + backref=db.backref('feedback', lazy='dynamic', + cascade='all, delete-orphan')) + user = db.relationship('User', foreign_keys=[user_id]) + + __table_args__ = ( + db.UniqueConstraint('article_id', 'user_id', name='uq_kb_feedback'), + ) + + def __repr__(self): + return f'' diff --git a/app/routes/admin.py b/app/routes/admin.py index ca596b9..390a950 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -12,7 +12,7 @@ import bleach from app import db, limiter from app.models import (User, Ticket, Comment, ActivityLog, KnowledgeBase, KBAttachment, UserRole, TicketStatus, CannedResponse) -from app.services.log_service import log_action +from app.services.log_service import log_action, log_ticket_history from app.services.validation_service import validate_password, validate_file admin_bp = Blueprint('admin', __name__, url_prefix='/admin') @@ -930,6 +930,109 @@ def canned_response_delete(item_id): return redirect(url_for('admin.canned_responses')) +# ─── Bulk Ticket Actions ───────────────────────────────────────────────────── + +@admin_bp.route('/tickets/bulk-action', methods=['POST']) +@login_required +@it_required +def bulk_ticket_action(): + """Apply a single action to multiple selected tickets at once. + + Accepted actions: + - resolve → set status to Resolved, stamp resolved_at + - close → set status to Closed, stamp closed_at + - assign_me → assign all selected tickets to current user + - unassign → clear assigned_to_id + """ + from app.services.notification_service import notify_status_change + from app.services.sla_service import clear_sla_notification + + action = request.form.get('action', '') + ticket_ids = request.form.getlist('ticket_ids', type=int) + + if not ticket_ids: + flash('No tickets selected.', 'warning') + return redirect(url_for('admin.all_tickets')) + + valid_actions = ('resolve', 'close', 'assign_me', 'unassign') + if action not in valid_actions: + flash('Invalid action.', 'danger') + return redirect(url_for('admin.all_tickets')) + + tickets = Ticket.query.filter(Ticket.id.in_(ticket_ids)).all() + now = datetime.utcnow() + count = 0 + notif_tickets = [] # collect for post-commit notifications + + for ticket in tickets: + old_status = ticket.status + old_assigned = ticket.assigned_to_id + changed = False + + if action == 'resolve' and ticket.status not in ( + TicketStatus.RESOLVED, TicketStatus.CLOSED): + ticket.status = TicketStatus.RESOLVED + ticket.resolved_at = now + clear_sla_notification(ticket.id) + log_ticket_history(ticket, 'status', old_status, + TicketStatus.RESOLVED, current_user.id) + notif_tickets.append((ticket, old_status)) + changed = True + + elif action == 'close' and ticket.status != TicketStatus.CLOSED: + ticket.status = TicketStatus.CLOSED + ticket.closed_at = now + clear_sla_notification(ticket.id) + log_ticket_history(ticket, 'status', old_status, + TicketStatus.CLOSED, current_user.id) + notif_tickets.append((ticket, old_status)) + changed = True + + elif action == 'assign_me' and ticket.assigned_to_id != current_user.id: + ticket.assigned_to_id = current_user.id + log_ticket_history(ticket, 'assigned_to', + old_assigned or 'Unassigned', + current_user.full_name, current_user.id) + changed = True + + elif action == 'unassign' and ticket.assigned_to_id is not None: + ticket.assigned_to_id = None + log_ticket_history(ticket, 'assigned_to', + old_assigned or 'Unassigned', + 'Unassigned', current_user.id) + changed = True + + if changed: + log_action(current_user.id, f'ticket_bulk_{action}', 'ticket', + ticket.id, f'action={action}') + count += 1 + + db.session.commit() + logger.info(f'[BULK ACTION] action={action} affected={count} ' + f'ticket_ids={ticket_ids} by user_id={current_user.id}') + + # Send status-change notifications after commit + for ticket, old_status in notif_tickets: + notify_status_change(ticket, old_status, current_user) + + action_labels = { + 'resolve': 'resolved', + 'close': 'closed', + 'assign_me': 'assigned to you', + 'unassign': 'unassigned', + } + flash(f'{count} ticket{"s" if count != 1 else ""} {action_labels[action]}.', 'success') + + # Preserve current filter params on redirect + return redirect(url_for('admin.all_tickets', + status = request.form.get('status', ''), + priority = request.form.get('priority', ''), + assigned = request.form.get('assigned', ''), + q = request.form.get('q', ''), + page = request.form.get('page', 1), + )) + + def _roles(): return [UserRole.EMPLOYEE, UserRole.IT_STAFF, UserRole.ADMIN] @@ -957,6 +1060,58 @@ def settings(): flash(f'User registration has been {state_label}.', 'success') return redirect(url_for('admin.settings')) + # ── Timezone setting ────────────────────────────────────────────────── + if form_type == 'timezone': + from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + new_tz = request.form.get('app_timezone', 'America/New_York').strip() + try: + ZoneInfo(new_tz) # validate before saving + except (ZoneInfoNotFoundError, KeyError): + flash(f'Unknown timezone "{new_tz}". Please select a valid timezone.', 'danger') + return redirect(url_for('admin.settings')) + old_tz = SystemSetting.get('app_timezone', 'America/New_York') + SystemSetting.set('app_timezone', new_tz, 'Display timezone for all dates and times in the UI') + db.session.commit() + log_action(current_user.id, 'setting_update', 'system_setting', None, + f'app_timezone changed from {old_tz!r} to {new_tz!r}') + logger.info(f'[ADMIN SETTINGS] app_timezone={new_tz} by admin_id={current_user.id}') + flash(f'Timezone updated to {new_tz}.', 'success') + return redirect(url_for('admin.settings')) + + # ── Email ingestion settings ────────────────────────────────────────── + if form_type == 'email_ingestion': + fields = { + 'email_ingestion_enabled' : request.form.get('email_ingestion_enabled', '0'), + 'email_ingestion_host' : request.form.get('email_ingestion_host', '').strip(), + 'email_ingestion_port' : request.form.get('email_ingestion_port', '993').strip(), + 'email_ingestion_user' : request.form.get('email_ingestion_user', '').strip(), + 'email_ingestion_folder' : request.form.get('email_ingestion_folder', 'INBOX').strip(), + 'email_ingestion_move_to' : request.form.get('email_ingestion_move_to', 'Processed').strip(), + 'email_ingestion_interval': request.form.get('email_ingestion_interval', '5').strip(), + } + # Password: only update if a new value was provided + new_pw = request.form.get('email_ingestion_password', '').strip() + if new_pw: + fields['email_ingestion_password'] = new_pw + + changes = [] + for key, value in fields.items(): + old_val = SystemSetting.get(key, '') + if str(value) != str(old_val): + SystemSetting.set(key, value) + safe_key = key.replace('email_ingestion_', '') + # Never log the password value + changes.append(f'{safe_key}={"[updated]" if "password" in key else repr(value)}') + + db.session.commit() + if changes: + log_action(current_user.id, 'email_ingestion_settings_update', + 'system_setting', None, ', '.join(changes)) + logger.info(f'[ADMIN EMAIL INGEST] Settings updated: {", ".join(changes)} ' + f'by admin_id={current_user.id}') + flash('Email ingestion settings saved.', 'success') + return redirect(url_for('admin.settings')) + # ── Branding update ─────────────────────────────────────────────────── if form_type == 'branding': fields = { @@ -1022,6 +1177,16 @@ def settings(): return redirect(url_for('admin.settings')) registration_enabled = SystemSetting.get_bool('registration_enabled', default=True) + email_settings = { + 'enabled' : SystemSetting.get_bool('email_ingestion_enabled', default=False), + 'host' : SystemSetting.get('email_ingestion_host', ''), + 'port' : SystemSetting.get('email_ingestion_port', '993'), + 'user' : SystemSetting.get('email_ingestion_user', ''), + 'password': SystemSetting.get('email_ingestion_password', ''), + 'folder' : SystemSetting.get('email_ingestion_folder', 'INBOX'), + 'move_to' : SystemSetting.get('email_ingestion_move_to', 'Processed'), + 'interval': SystemSetting.get('email_ingestion_interval', '5'), + } branding_settings = { 'app_name' : SystemSetting.get('app_name', 'TechDesk'), 'app_subtitle' : SystemSetting.get('app_subtitle', 'IT Helpdesk System'), @@ -1030,6 +1195,31 @@ def settings(): 'logo_initials' : SystemSetting.get('logo_initials', 'TD'), 'primary_color' : SystemSetting.get('primary_color', '#2563eb'), } + current_tz = SystemSetting.get('app_timezone', 'America/New_York') + # Common timezone list for the selector + common_timezones = [ + ('America/New_York', 'Eastern Time (ET) — New York'), + ('America/Chicago', 'Central Time (CT) — Chicago'), + ('America/Denver', 'Mountain Time (MT) — Denver'), + ('America/Phoenix', 'Mountain Time, no DST — Phoenix'), + ('America/Los_Angeles', 'Pacific Time (PT) — Los Angeles'), + ('America/Anchorage', 'Alaska Time — Anchorage'), + ('Pacific/Honolulu', 'Hawaii Time — Honolulu'), + ('America/Puerto_Rico', 'Atlantic Time — Puerto Rico'), + ('UTC', 'UTC — Coordinated Universal Time'), + ('Europe/London', 'GMT/BST — London'), + ('Europe/Paris', 'CET/CEST — Paris, Berlin'), + ('Europe/Helsinki', 'EET/EEST — Helsinki, Athens'), + ('Asia/Dubai', 'GST — Dubai'), + ('Asia/Kolkata', 'IST — India'), + ('Asia/Singapore', 'SGT — Singapore'), + ('Asia/Tokyo', 'JST — Tokyo'), + ('Australia/Sydney', 'AEST/AEDT — Sydney'), + ('Pacific/Auckland', 'NZST/NZDT — Auckland'), + ] return render_template('admin/settings.html', registration_enabled=registration_enabled, - branding_settings=branding_settings) \ No newline at end of file + branding_settings=branding_settings, + email_settings=email_settings, + current_tz=current_tz, + common_timezones=common_timezones) \ No newline at end of file diff --git a/app/routes/tickets.py b/app/routes/tickets.py index 1119e87..e8f6868 100644 --- a/app/routes/tickets.py +++ b/app/routes/tickets.py @@ -9,7 +9,8 @@ from werkzeug.utils import secure_filename from app import db from app.models import (Ticket, Comment, Attachment, Notification, TicketStatus, TicketPriority, TicketCategory, - User, UserRole, KnowledgeBase, TicketLink, CannedResponse) + User, UserRole, KnowledgeBase, TicketLink, CannedResponse, + KBFeedback) from app.services.notification_service import ( notify_new_ticket, notify_status_change, notify_comment_added, notify_assignment, @@ -674,7 +675,19 @@ def kb_article(article_id): db.session.commit() # Re-fetch so the template receives the post-increment value. db.session.refresh(article) - return render_template('tickets/kb_article.html', article=article) + # Load the current user's vote (if any) so the feedback widget shows state + user_feedback = KBFeedback.query.filter_by( + article_id=article.id, user_id=current_user.id + ).first() + # Aggregate counts for the widget + helpful_count = KBFeedback.query.filter_by(article_id=article.id, is_helpful=True).count() + not_helpful_count = KBFeedback.query.filter_by(article_id=article.id, is_helpful=False).count() + return render_template('tickets/kb_article.html', + article=article, + user_feedback=user_feedback, + helpful_count=helpful_count, + not_helpful_count=not_helpful_count, + ) # ─── Ticket Link / Unlink ──────────────────────────────────────────────────── @@ -840,6 +853,70 @@ def get_canned_responses(): ]}) +# ─── KB Article Feedback ───────────────────────────────────────────────────── + +@tickets_bp.route('/kb//feedback', methods=['POST']) +@login_required +def kb_feedback(article_id): + """Record or update a thumbs-up / thumbs-down vote for a KB article. + + One vote per user per article — subsequent submissions update the existing + row. Submitting the same vote a second time toggles it off (removes it), + giving users an undo path. + """ + article = db.session.get(KnowledgeBase, article_id) or abort(404) + value = request.form.get('helpful') # '1' = helpful, '0' = not helpful + + if value not in ('0', '1'): + abort(400) + + is_helpful = value == '1' + existing = KBFeedback.query.filter_by( + article_id=article.id, user_id=current_user.id + ).first() + + if existing: + if existing.is_helpful == is_helpful: + # Same vote again → toggle off (remove) + db.session.delete(existing) + log_action(current_user.id, 'kb_feedback_remove', 'knowledge_base', + article.id, f'was_helpful={is_helpful}') + logger.info(f'[KB FEEDBACK REMOVE] article_id={article.id} ' + f'user_id={current_user.id} was_helpful={is_helpful}') + else: + # Changed vote → update + existing.is_helpful = is_helpful + log_action(current_user.id, 'kb_feedback_update', 'knowledge_base', + article.id, f'is_helpful={is_helpful}') + logger.info(f'[KB FEEDBACK UPDATE] article_id={article.id} ' + f'user_id={current_user.id} is_helpful={is_helpful}') + else: + fb = KBFeedback( + article_id = article.id, + user_id = current_user.id, + is_helpful = is_helpful, + ) + db.session.add(fb) + log_action(current_user.id, 'kb_feedback_create', 'knowledge_base', + article.id, f'is_helpful={is_helpful}') + logger.info(f'[KB FEEDBACK CREATE] article_id={article.id} ' + f'user_id={current_user.id} is_helpful={is_helpful}') + + db.session.commit() + + # Return JSON so the widget can update without a full reload + helpful_count = KBFeedback.query.filter_by(article_id=article.id, is_helpful=True).count() + not_helpful_count = KBFeedback.query.filter_by(article_id=article.id, is_helpful=False).count() + from flask import jsonify + return jsonify({ + 'ok' : True, + 'helpful_count' : helpful_count, + 'not_helpful_count': not_helpful_count, + 'user_vote' : None if (existing and existing.is_helpful == is_helpful) + else ('helpful' if is_helpful else 'not_helpful'), + }) + + # ─── Helpers ───────────────────────────────────────────────────────────────── def _sla_due_date(priority: str) -> 'datetime': diff --git a/app/services/email_ingestion_service.py b/app/services/email_ingestion_service.py new file mode 100644 index 0000000..4ab5752 --- /dev/null +++ b/app/services/email_ingestion_service.py @@ -0,0 +1,316 @@ +""" +Email Ingestion Service — convert inbound emails into tickets. + +Architecture +------------ +A scheduled APScheduler job (check_inbound_email) runs every N minutes, +connects to a configured IMAP mailbox, and converts unread messages into +tickets. The job is wired into create_app() alongside the SLA scheduler. + +Sender resolution +----------------- +The From address is matched against existing User.email rows. +- Match found → ticket is created under that user's account. +- No match found → ticket is created under a configurable fallback user + (default: the system admin). A comment is prepended noting the external + sender so IT staff can follow up. + +Duplicate suppression +--------------------- +Message-IDs (from the Message-ID header) are stored in the SystemSetting +key email_ingested_message_ids as a comma-separated list (capped at 500 +entries). Re-delivering an already-processed message is a no-op. + +Configuration (all stored in SystemSetting, editable from admin/settings) +---------- +email_ingestion_enabled '1' / '0' +email_ingestion_host IMAP server hostname +email_ingestion_port IMAP port (default 993) +email_ingestion_user Mailbox username / email address +email_ingestion_password Mailbox password +email_ingestion_interval Poll interval in minutes (default 5) +email_ingestion_folder IMAP folder to watch (default INBOX) +email_ingestion_move_to Folder to move processed mail into (default Processed) +""" + +import email +import imaplib +import logging +import re +from datetime import datetime +from email.header import decode_header +from email.utils import parseaddr, getaddresses + +logger = logging.getLogger(__name__) + +_MAX_STORED_IDS = 500 # cap on the message-ID suppression list + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _decode_header_value(raw): + """Decode an RFC-2047 encoded email header value to a plain string.""" + if raw is None: + return '' + parts = [] + for chunk, charset in decode_header(raw): + if isinstance(chunk, bytes): + try: + parts.append(chunk.decode(charset or 'utf-8', errors='replace')) + except (LookupError, UnicodeDecodeError): + parts.append(chunk.decode('utf-8', errors='replace')) + else: + parts.append(chunk) + return ''.join(parts).strip() + + +def _extract_plain_text(msg): + """Walk a MIME message and return the first text/plain part, or a + stripped-down version of the first text/html part as a fallback.""" + plain = None + html = None + if msg.is_multipart(): + for part in msg.walk(): + ct = part.get_content_type() + cd = str(part.get('Content-Disposition', '')) + if 'attachment' in cd: + continue + if ct == 'text/plain' and plain is None: + try: + plain = part.get_payload(decode=True).decode( + part.get_content_charset() or 'utf-8', errors='replace' + ) + except Exception: + pass + elif ct == 'text/html' and html is None: + try: + html = part.get_payload(decode=True).decode( + part.get_content_charset() or 'utf-8', errors='replace' + ) + except Exception: + pass + else: + ct = msg.get_content_type() + try: + body = msg.get_payload(decode=True).decode( + msg.get_content_charset() or 'utf-8', errors='replace' + ) + except Exception: + body = '' + if ct == 'text/plain': + plain = body + elif ct == 'text/html': + html = body + + if plain: + return plain.strip() + + if html: + # Minimal HTML → plain text strip + text = re.sub(r'', '\n', html, flags=re.IGNORECASE) + text = re.sub(r'<[^>]+>', '', text) + import html as html_module + return html_module.unescape(text).strip() + + return '' + + +def _get_setting(key, default=''): + """Read a SystemSetting value inside an existing app context.""" + from app.models import SystemSetting + return SystemSetting.get(key, default) + + +def _load_seen_ids(): + raw = _get_setting('email_ingested_message_ids', '') + return set(x.strip() for x in raw.split(',') if x.strip()) + + +def _save_seen_ids(seen: set): + from app.models import SystemSetting + from app import db + # Keep the most recent N IDs to prevent unbounded growth + trimmed = sorted(seen)[-_MAX_STORED_IDS:] + SystemSetting.set( + 'email_ingested_message_ids', + ','.join(trimmed), + 'Message-IDs of emails already converted to tickets', + ) + db.session.commit() + + +# ── Core ingestion logic ────────────────────────────────────────────────────── + +def check_inbound_email(app): + """Entry point called by APScheduler. Wraps _run_ingestion with + error isolation so a transient IMAP failure never kills the worker.""" + with app.app_context(): + try: + if _get_setting('email_ingestion_enabled', '0') != '1': + return + _run_ingestion(app) + except Exception as exc: + logger.error(f'[EMAIL INGEST] Unhandled error: {exc}', exc_info=True) + + +def _run_ingestion(app): + from app import db + from app.models import (Ticket, TicketStatus, TicketPriority, + TicketCategory, User, UserRole) + from app.services.notification_service import notify_new_ticket + from app.services.sla_service import set_due_date + + host = _get_setting('email_ingestion_host', '') + port = int(_get_setting('email_ingestion_port', '993')) + username = _get_setting('email_ingestion_user', '') + password = _get_setting('email_ingestion_password', '') + folder = _get_setting('email_ingestion_folder', 'INBOX') + move_to = _get_setting('email_ingestion_move_to', 'Processed') + + if not host or not username or not password: + logger.warning('[EMAIL INGEST] Missing IMAP credentials — skipping') + return + + seen_ids = _load_seen_ids() + new_ids = set() + created = 0 + + try: + imap = imaplib.IMAP4_SSL(host, port) + imap.login(username, password) + except Exception as exc: + logger.error(f'[EMAIL INGEST] IMAP login failed: {exc}') + return + + try: + imap.select(folder) + # Search for unseen messages only + status, data = imap.search(None, 'UNSEEN') + if status != 'OK' or not data[0]: + return + + msg_ids = data[0].split() + logger.info(f'[EMAIL INGEST] Found {len(msg_ids)} unseen message(s) in {folder}') + + for num in msg_ids: + try: + _, raw = imap.fetch(num, '(RFC822)') + msg = email.message_from_bytes(raw[0][1]) + + message_id = msg.get('Message-ID', '').strip() + if message_id and message_id in seen_ids: + logger.debug(f'[EMAIL INGEST] Skipping duplicate {message_id}') + continue + + # ── Parse headers ───────────────────────────────────────────── + subject = _decode_header_value(msg.get('Subject', '(No Subject)')) + from_raw = msg.get('From', '') + from_name, from_email = parseaddr(from_raw) + from_email = from_email.lower().strip() + body = _extract_plain_text(msg) + + if not body: + body = f'[Email received from {from_email} with no readable body]' + + # Truncate very long bodies to 8000 chars + if len(body) > 8000: + body = body[:8000] + '\n\n[…message truncated…]' + + # ── Resolve sender to a user ────────────────────────────────── + sender_user = User.query.filter_by( + email=from_email, is_active=True + ).first() + + if sender_user: + created_by_id = sender_user.id + external_note = None + else: + # Fall back to the first active admin + fallback = User.query.filter( + User.role == UserRole.ADMIN, + User.is_active == True, + ).first() + if not fallback: + logger.warning( + f'[EMAIL INGEST] No fallback admin found, skipping: {from_email}' + ) + continue + created_by_id = fallback.id + external_note = ( + f'**[Email received from unknown sender]**\n\n' + f'From: {from_name} <{from_email}>\n\n' + f'This ticket was automatically created from an inbound email. ' + f'The sender is not a registered user — please follow up directly.' + ) + + # ── Create the ticket ───────────────────────────────────────── + ticket = Ticket( + title = subject[:200], + description = body, + category = TicketCategory.OTHER, + priority = TicketPriority.MEDIUM, + status = TicketStatus.OPEN, + created_by_id = created_by_id, + ai_generated = False, + ) + ticket.ticket_number = ticket.generate_ticket_number() + set_due_date(ticket, app) + db.session.add(ticket) + db.session.flush() + + # Prepend external-sender note as a comment if needed + if external_note: + from app.models import Comment + from app.services.validation_service import render_comment_body + comment = Comment( + ticket_id = ticket.id, + author_id = created_by_id, + body = render_comment_body(external_note), + is_internal= True, # IT staff only + ) + db.session.add(comment) + + from app.services.log_service import log_action + log_action( + created_by_id, 'ticket_create_email', 'ticket', ticket.id, + f'ticket_number={ticket.ticket_number} ' + f'from_email={from_email} message_id={message_id or "none"}' + ) + db.session.commit() + logger.info( + f'[EMAIL INGEST] Created ticket {ticket.ticket_number} ' + f'from={from_email} subject="{subject[:60]}"' + ) + notify_new_ticket(ticket) + + # Track message ID for deduplication + if message_id: + new_ids.add(message_id) + created += 1 + + # Move processed message to done folder + try: + imap.create(move_to) + except Exception: + pass # folder may already exist + imap.copy(num, move_to) + imap.store(num, '+FLAGS', '\\Deleted') + + except Exception as exc: + db.session.rollback() + logger.error(f'[EMAIL INGEST] Failed to process message {num}: {exc}', + exc_info=True) + + imap.expunge() + + finally: + try: + imap.logout() + except Exception: + pass + + if new_ids: + _save_seen_ids(seen_ids | new_ids) + + if created: + logger.info(f'[EMAIL INGEST] Run complete — {created} ticket(s) created') diff --git a/app/templates/admin/kb_list.html b/app/templates/admin/kb_list.html index 5a8f1f0..a510acc 100644 --- a/app/templates/admin/kb_list.html +++ b/app/templates/admin/kb_list.html @@ -65,7 +65,7 @@ {% if articles %} - + {% for art in articles %} @@ -81,6 +81,13 @@ {% endif %} +
TitleCategoryAuthorPublishedViewsUpdatedActions
TitleCategoryAuthorPublishedViews👍 / 👎UpdatedActions
{{ art.view_count }} + {% set hc = art.feedback.filter_by(is_helpful=True).count() %} + {% set nc = art.feedback.filter_by(is_helpful=False).count() %} + 👍 {{ hc }} + / + 👎 {{ nc }} + {{ art.updated_at.strftime('%b %d, %Y') }}
diff --git a/app/templates/admin/settings.html b/app/templates/admin/settings.html index 1f61506..211798d 100644 --- a/app/templates/admin/settings.html +++ b/app/templates/admin/settings.html @@ -18,6 +18,45 @@ {% endfor %} {% endwith %} + +
+
+
+ Date & Time +
+
+
+

+ All timestamps are stored in UTC internally. This setting controls how they are + displayed throughout the application — tickets, comments, history, and logs. +

+
+ + +
+ + +
+ Current setting: {{ current_tz }} — + local time is {{ now_local.strftime('%b %d, %Y %H:%M %Z') }} +
+
+ +
+
+
+
@@ -113,6 +152,98 @@
+ + +
+
+
+ Email-to-Ticket Ingestion +
+
+
+

+ When enabled, TechDesk polls a dedicated IMAP mailbox and automatically converts + inbound emails into tickets. Use a dedicated support inbox with an app-specific password. +

+
+ + + + +
+
+
+ Enable Email Ingestion +
+
+ Start polling the mailbox below for new tickets. +
+
+
+ +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + Note: For Gmail, enable IMAP in Settings → Forwarding and POP/IMAP, + and use an App Password (Google Account → Security → 2-Step Verification → App passwords). + For other providers, ensure IMAP is enabled and check their specific settings. +
+ + +
+
+
+
diff --git a/app/templates/admin/tickets.html b/app/templates/admin/tickets.html index 8f650b8..092c3f7 100644 --- a/app/templates/admin/tickets.html +++ b/app/templates/admin/tickets.html @@ -65,6 +65,10 @@ + @@ -79,6 +83,10 @@ {% for t in tickets.items %} + @@ -86,7 +94,7 @@ - + {% endfor %} @@ -121,4 +129,101 @@ {% endif %} + + + +
+ +
+
+ + + + + +
+
+ + + + +
+ + +
+ + + {% endblock %} diff --git a/app/templates/tickets/dashboard_employee.html b/app/templates/tickets/dashboard_employee.html index 2561e0f..502a84a 100644 --- a/app/templates/tickets/dashboard_employee.html +++ b/app/templates/tickets/dashboard_employee.html @@ -69,7 +69,7 @@ - + {% endfor %} diff --git a/app/templates/tickets/detail.html b/app/templates/tickets/detail.html index c21c1a6..9681a91 100644 --- a/app/templates/tickets/detail.html +++ b/app/templates/tickets/detail.html @@ -25,7 +25,7 @@ {% endif %} {{ ticket.category.replace('_',' ').title() }} - {{ ticket.created_at.strftime('%b %d, %Y %H:%M') }} + {{ ticket.created_at | localtime("%b %d, %Y %H:%M") }} {% if ticket.location %}{{ ticket.location }}{% endif %} {% if ticket.asset_tag %}{{ ticket.asset_tag }}{% endif %} {% if ticket.ai_generated %}AI-generated{% endif %} @@ -113,7 +113,7 @@ {% endif %}
- {{ comment.created_at.strftime('%b %d, %Y %H:%M') }} + {{ comment.created_at | localtime("%b %d, %Y %H:%M") }} {% if current_user.is_it_staff or comment.author_id == current_user.id %}
@@ -744,10 +744,10 @@ function buildCommentEl(c) { {{ info_row('bi-person-badge', 'Filed by (IT)', '' ~ ticket.filed_by_staff.full_name ~ '') }} {% endif %} {{ info_row('bi-person-check', 'Assigned to', ticket.assignee.full_name if ticket.assignee else '—') }} - {{ info_row('bi-calendar3', 'Created', ticket.created_at.strftime('%b %d, %Y')) }} - {{ info_row('bi-calendar-check', 'Updated', ticket.updated_at.strftime('%b %d, %Y')) }} - {% if ticket.due_date %}{{ info_row('bi-alarm', 'Due Date', ticket.due_date.strftime('%b %d, %Y')) }}{% endif %} - {% if ticket.resolved_at %}{{ info_row('bi-check-circle', 'Resolved', ticket.resolved_at.strftime('%b %d, %Y')) }}{% endif %} + {{ info_row('bi-calendar3', 'Created', ticket.created_at | localtime("%b %d, %Y")) }} + {{ info_row('bi-calendar-check', 'Updated', ticket.updated_at | localtime("%b %d, %Y")) }} + {% if ticket.due_date %}{{ info_row('bi-alarm', 'Due Date', ticket.due_date | localtime("%b %d, %Y")) }}{% endif %} + {% if ticket.resolved_at %}{{ info_row('bi-check-circle', 'Resolved', ticket.resolved_at | localtime("%b %d, %Y")) }}{% endif %}
@@ -778,7 +778,7 @@ function buildCommentEl(c) { {% endif %} changed from {{ old }} to {{ new }}
- by {{ h.changer.full_name }} · {{ h.changed_at.strftime('%b %d %H:%M') }} + by {{ h.changer.full_name }} · {{ h.changed_at | localtime("%b %d %H:%M") }} {% endfor %} diff --git a/app/templates/tickets/kb_article.html b/app/templates/tickets/kb_article.html index d7e151a..9a07fda 100644 --- a/app/templates/tickets/kb_article.html +++ b/app/templates/tickets/kb_article.html @@ -83,18 +83,65 @@
-
- Did this article solve your issue? -
- - Back - - - Still need help - +
+
+ +
+ Was this article helpful? + + + +
+ +
+ +
{% endblock %} \ No newline at end of file diff --git a/app/templates/tickets/list.html b/app/templates/tickets/list.html index 28d53b6..3d7c2bb 100644 --- a/app/templates/tickets/list.html +++ b/app/templates/tickets/list.html @@ -93,7 +93,7 @@ {% if current_user.is_it_staff %} {% endif %} - +
+ + Ticket # Title Category
+ + {{ t.ticket_number }} {{ t.title }} {{ t.category.replace('_',' ').title() }}{{ t.priority.upper() }} {{ t.creator.full_name }} {{ t.assignee.full_name if t.assignee else '—' }}{{ t.created_at.strftime('%b %d') }}{{ t.created_at | localtime("%b %d") }}
{{ t.title[:45] }}{% if t.title|length > 45 %}…{% endif %} {{ t.status.replace('_',' ').upper() }} {{ t.priority.upper() }}{{ t.created_at.strftime('%b %d') }}{{ t.created_at | localtime("%b %d") }}
{{ t.creator.full_name }}{{ t.created_at.strftime('%b %d, %Y') }}{{ t.created_at | localtime("%b %d, %Y") }} diff --git a/app/templates/tickets/notifications.html b/app/templates/tickets/notifications.html index c32ede5..568caf0 100644 --- a/app/templates/tickets/notifications.html +++ b/app/templates/tickets/notifications.html @@ -39,7 +39,7 @@
{{ n.message[:120] }}
{% endif %}
- {{ n.created_at.strftime('%b %d, %Y at %H:%M') }} + {{ n.created_at | localtime("%b %d, %Y at %H:%M") }}