04/13/2026 Fixed email ingres function
This commit is contained in:
+32
-51
@@ -182,39 +182,47 @@ def create_app(config_name=None):
|
|||||||
_seed_settings()
|
_seed_settings()
|
||||||
_seed_ticket_templates()
|
_seed_ticket_templates()
|
||||||
|
|
||||||
# ── Email ingestion background scheduler ────────────────────────────────────
|
# ── Background schedulers (SLA + Email Ingestion) ───────────────────────────
|
||||||
_start_email_ingestion_scheduler(app)
|
# Both jobs share a single BackgroundScheduler instance to avoid duplicate
|
||||||
|
# thread pools and simplify startup/shutdown. The scheduler is only started
|
||||||
# ── SLA background scheduler ──────────────────────────────────────────────
|
# in the main process — not during Flask's reloader child — to prevent
|
||||||
# APScheduler runs inside the gunicorn worker process (single-worker
|
# duplicate job execution.
|
||||||
# eventlet setup), so no cross-process coordination is needed.
|
_start_background_schedulers(app)
|
||||||
# The scheduler is only started in the main process — not during Flask's
|
|
||||||
# reloader child process — to prevent duplicate job execution.
|
|
||||||
_start_sla_scheduler(app)
|
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
def _start_sla_scheduler(app):
|
|
||||||
"""Start the APScheduler background job that checks for SLA breaches.
|
|
||||||
|
|
||||||
Safe to call on every app startup: the scheduler is idempotent and
|
def _start_background_schedulers(app):
|
||||||
jobstore deduplication prevents double-registration on hot-reloads.
|
"""Start a single shared APScheduler instance hosting both the SLA breach
|
||||||
Under gunicorn with preload_app=False each worker calls create_app()
|
check and the inbound email ingestion job.
|
||||||
once, so there is exactly one scheduler per worker.
|
|
||||||
|
A single BackgroundScheduler is used rather than two separate instances to
|
||||||
|
avoid creating duplicate thread pools under eventlet. Both jobs are
|
||||||
|
registered here so they share one daemon thread pool.
|
||||||
|
|
||||||
|
The scheduler is skipped in Flask's reloader subprocess (identified by the
|
||||||
|
WERKZEUG_RUN_MAIN env var) to prevent double-registration on hot-reloads.
|
||||||
|
Under gunicorn with preload_app=False, each worker calls create_app() once,
|
||||||
|
giving exactly one scheduler per worker.
|
||||||
"""
|
"""
|
||||||
# Skip inside Flask's reloader subprocess (identified by the env var it sets).
|
|
||||||
import os as _os
|
import os as _os
|
||||||
if _os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
|
if _os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
|
||||||
# Reloader is active — the child process will start its own scheduler.
|
return # skip reloader child process
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
from apscheduler.triggers.interval import IntervalTrigger
|
from apscheduler.triggers.interval import IntervalTrigger
|
||||||
from app.services.sla_service import check_sla_breaches
|
from app.services.sla_service import check_sla_breaches
|
||||||
|
from app.services.email_ingestion_service import check_inbound_email
|
||||||
|
from app.models import SystemSetting
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
email_interval = int(SystemSetting.get('email_ingestion_interval', '5') or '5')
|
||||||
|
|
||||||
scheduler = BackgroundScheduler(daemon=True)
|
scheduler = BackgroundScheduler(daemon=True)
|
||||||
|
|
||||||
|
# SLA breach check — every 30 minutes
|
||||||
scheduler.add_job(
|
scheduler.add_job(
|
||||||
func = check_sla_breaches,
|
func = check_sla_breaches,
|
||||||
trigger = IntervalTrigger(minutes=30),
|
trigger = IntervalTrigger(minutes=30),
|
||||||
@@ -223,49 +231,22 @@ def _start_sla_scheduler(app):
|
|||||||
replace_existing = True,
|
replace_existing = True,
|
||||||
args = [app],
|
args = [app],
|
||||||
)
|
)
|
||||||
scheduler.start()
|
|
||||||
app.logger.info('[SLA] APScheduler started — SLA breach check every 30 minutes')
|
|
||||||
except Exception as exc:
|
|
||||||
app.logger.error(f'[SLA] Failed to start APScheduler: {exc}')
|
|
||||||
|
|
||||||
|
# Inbound email ingestion — interval from SystemSetting
|
||||||
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(
|
scheduler.add_job(
|
||||||
func = check_inbound_email,
|
func = check_inbound_email,
|
||||||
trigger = IntervalTrigger(minutes=interval),
|
trigger = IntervalTrigger(minutes=email_interval),
|
||||||
id = 'email_ingest',
|
id = 'email_ingest',
|
||||||
name = 'Inbound Email Ingestion',
|
name = 'Inbound Email Ingestion',
|
||||||
replace_existing = True,
|
replace_existing = True,
|
||||||
args = [app],
|
args = [app],
|
||||||
)
|
)
|
||||||
|
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
app.logger.info(
|
app.logger.info('[SCHEDULER] APScheduler started — SLA check every 30 min, '
|
||||||
f'[EMAIL INGEST] APScheduler started — polling every {interval} minute(s)'
|
f'email ingestion every {email_interval} min')
|
||||||
)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
app.logger.error(f'[EMAIL INGEST] Failed to start scheduler: {exc}')
|
app.logger.error(f'[SCHEDULER] Failed to start APScheduler: {exc}')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _seed_ticket_templates():
|
def _seed_ticket_templates():
|
||||||
|
|||||||
@@ -1435,3 +1435,121 @@ def settings():
|
|||||||
email_settings=email_settings,
|
email_settings=email_settings,
|
||||||
current_tz=current_tz,
|
current_tz=current_tz,
|
||||||
common_timezones=common_timezones)
|
common_timezones=common_timezones)
|
||||||
|
|
||||||
|
# ── Email Ingestion: Test Connection ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@admin_bp.route('/settings/email-ingestion/test', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def email_ingestion_test():
|
||||||
|
"""AJAX endpoint — attempt an IMAP login with the supplied credentials
|
||||||
|
and return a JSON result. The password field is optional: if left blank
|
||||||
|
the stored password is used so the admin does not have to re-enter it
|
||||||
|
just to run a connection test.
|
||||||
|
"""
|
||||||
|
from app.models import SystemSetting
|
||||||
|
import imaplib
|
||||||
|
|
||||||
|
host = request.form.get('host', '').strip()
|
||||||
|
port_str = request.form.get('port', '993').strip()
|
||||||
|
user = request.form.get('user', '').strip()
|
||||||
|
password = request.form.get('password', '').strip()
|
||||||
|
folder = request.form.get('folder', 'INBOX').strip()
|
||||||
|
|
||||||
|
# Fall back to the stored password if the admin left the field blank
|
||||||
|
if not password:
|
||||||
|
password = SystemSetting.get('email_ingestion_password', '')
|
||||||
|
|
||||||
|
if not host or not user or not password:
|
||||||
|
return jsonify(ok=False, message='Host, username, and password are required.')
|
||||||
|
|
||||||
|
try:
|
||||||
|
port = int(port_str)
|
||||||
|
except ValueError:
|
||||||
|
return jsonify(ok=False, message=f'Invalid port number: {port_str!r}')
|
||||||
|
|
||||||
|
try:
|
||||||
|
imap = imaplib.IMAP4_SSL(host, port)
|
||||||
|
imap.login(user, password)
|
||||||
|
# Verify the watch folder exists
|
||||||
|
quoted_folder = f'"{folder}"'
|
||||||
|
status, data = imap.select(quoted_folder, readonly=True)
|
||||||
|
if status != 'OK':
|
||||||
|
imap.logout()
|
||||||
|
return jsonify(
|
||||||
|
ok=False,
|
||||||
|
message=(
|
||||||
|
f'Login succeeded but folder {folder!r} was not found or '
|
||||||
|
f'is not accessible. Check the Watch Folder setting.'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Count unseen messages for informational feedback
|
||||||
|
_, msg_data = imap.search(None, 'UNSEEN')
|
||||||
|
unseen = len(msg_data[0].split()) if msg_data and msg_data[0] else 0
|
||||||
|
imap.logout()
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
current_user.id, 'email_ingestion_test', 'system_setting', None,
|
||||||
|
f'host={host} port={port} user={user} folder={folder} unseen={unseen}'
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f'[ADMIN EMAIL INGEST] Connection test OK: host={host} user={user} '
|
||||||
|
f'folder={folder} unseen={unseen} by admin_id={current_user.id}'
|
||||||
|
)
|
||||||
|
return jsonify(
|
||||||
|
ok=True,
|
||||||
|
message=(
|
||||||
|
f'Connection successful. Folder {folder!r} is accessible '
|
||||||
|
f'({unseen} unseen message(s)).'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
except imaplib.IMAP4.error as exc:
|
||||||
|
logger.warning(
|
||||||
|
f'[ADMIN EMAIL INGEST] Connection test failed: host={host} user={user} '
|
||||||
|
f'error={exc} by admin_id={current_user.id}'
|
||||||
|
)
|
||||||
|
return jsonify(ok=False, message=f'IMAP error: {exc}')
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning(
|
||||||
|
f'[ADMIN EMAIL INGEST] Connection test network error: host={host} '
|
||||||
|
f'port={port} error={exc} by admin_id={current_user.id}'
|
||||||
|
)
|
||||||
|
return jsonify(ok=False, message=f'Network error: {exc}')
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
f'[ADMIN EMAIL INGEST] Connection test unexpected error: {exc}',
|
||||||
|
exc_info=True
|
||||||
|
)
|
||||||
|
return jsonify(ok=False, message=f'Unexpected error: {exc}')
|
||||||
|
|
||||||
|
|
||||||
|
# ── Email Ingestion: Run Now ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@admin_bp.route('/settings/email-ingestion/run-now', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def email_ingestion_run_now():
|
||||||
|
"""Trigger an immediate ingestion run outside the scheduler cycle.
|
||||||
|
|
||||||
|
Runs synchronously in the request (the job is fast for small mailboxes).
|
||||||
|
Returns JSON so the UI can surface the outcome without a full page reload.
|
||||||
|
"""
|
||||||
|
from app.models import SystemSetting
|
||||||
|
from app.services.email_ingestion_service import _run_ingestion
|
||||||
|
|
||||||
|
if SystemSetting.get('email_ingestion_enabled', '0') != '1':
|
||||||
|
return jsonify(ok=False, message='Email ingestion is currently disabled. Enable it first.')
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
current_user.id, 'email_ingestion_run_now', 'system_setting', None,
|
||||||
|
f'manual run triggered by admin_id={current_user.id}'
|
||||||
|
)
|
||||||
|
logger.info(f'[ADMIN EMAIL INGEST] Manual run triggered by admin_id={current_user.id}')
|
||||||
|
|
||||||
|
try:
|
||||||
|
_run_ingestion(current_app._get_current_object())
|
||||||
|
return jsonify(ok=True, message='Ingestion run completed. Check the activity log for details.')
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f'[ADMIN EMAIL INGEST] Manual run error: {exc}', exc_info=True)
|
||||||
|
return jsonify(ok=False, message=f'Run failed: {exc}')
|
||||||
|
|||||||
@@ -436,19 +436,28 @@ def _run_ingestion(app):
|
|||||||
f'from={from_email} subject="{subject[:60]}" '
|
f'from={from_email} subject="{subject[:60]}" '
|
||||||
f'attachments={attach_count}'
|
f'attachments={attach_count}'
|
||||||
)
|
)
|
||||||
notify_new_ticket(ticket)
|
try:
|
||||||
|
notify_new_ticket(ticket)
|
||||||
|
except Exception as notify_exc:
|
||||||
|
logger.error(
|
||||||
|
f'[EMAIL INGEST] Notification failed for ticket '
|
||||||
|
f'{ticket.ticket_number}: {notify_exc}'
|
||||||
|
)
|
||||||
|
|
||||||
# Track message ID for deduplication
|
# Track message ID for deduplication
|
||||||
if message_id:
|
if message_id:
|
||||||
new_ids.add(message_id)
|
new_ids.add(message_id)
|
||||||
created += 1
|
created += 1
|
||||||
|
|
||||||
# Move processed message to done folder
|
# Move processed message to done folder.
|
||||||
|
# RFC 3501: folder names with spaces or special chars must
|
||||||
|
# be double-quoted in IMAP commands.
|
||||||
|
quoted_move_to = f'"{move_to}"'
|
||||||
try:
|
try:
|
||||||
imap.create(move_to)
|
imap.create(quoted_move_to)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # folder may already exist
|
pass # folder may already exist
|
||||||
imap.copy(num, move_to)
|
imap.copy(num, quoted_move_to)
|
||||||
imap.store(num, '+FLAGS', '\\Deleted')
|
imap.store(num, '+FLAGS', '\\Deleted')
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -237,13 +237,103 @@
|
|||||||
For other providers, ensure IMAP is enabled and check their specific settings.
|
For other providers, ensure IMAP is enabled and check their specific settings.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary mt-3">
|
<div class="d-flex align-items-center gap-2 flex-wrap mt-3">
|
||||||
<i class="bi bi-check2 me-2"></i>Save Email Settings
|
<button type="submit" class="btn btn-primary">
|
||||||
</button>
|
<i class="bi bi-check2 me-2"></i>Save Email Settings
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" id="btn-test-ingest"
|
||||||
|
onclick="emailIngestTest()">
|
||||||
|
<i class="bi bi-plug me-2"></i>Test Connection
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-primary" id="btn-run-ingest"
|
||||||
|
onclick="emailIngestRunNow()">
|
||||||
|
<i class="bi bi-play-circle me-2"></i>Run Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="ingest-result" class="mt-3" style="display:none;"></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var csrfToken = document.querySelector('meta[name="csrf-token"]').content;
|
||||||
|
|
||||||
|
function setResult(ok, message) {
|
||||||
|
var el = document.getElementById('ingest-result');
|
||||||
|
el.style.display = '';
|
||||||
|
el.className = ok
|
||||||
|
? 'alert alert-success py-2 px-3'
|
||||||
|
: 'alert alert-danger py-2 px-3';
|
||||||
|
el.style.fontSize = '13px';
|
||||||
|
el.innerHTML = '<i class="bi bi-' + (ok ? 'check-circle' : 'x-circle') + ' me-2"></i>' +
|
||||||
|
message.replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFields() {
|
||||||
|
return {
|
||||||
|
host : document.querySelector('[name="email_ingestion_host"]').value.trim(),
|
||||||
|
port : document.querySelector('[name="email_ingestion_port"]').value.trim(),
|
||||||
|
user : document.querySelector('[name="email_ingestion_user"]').value.trim(),
|
||||||
|
password: document.querySelector('[name="email_ingestion_password"]').value.trim(),
|
||||||
|
folder : document.querySelector('[name="email_ingestion_folder"]').value.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.emailIngestTest = function () {
|
||||||
|
var btn = document.getElementById('btn-test-ingest');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Testing…';
|
||||||
|
|
||||||
|
var fields = collectFields();
|
||||||
|
var body = new URLSearchParams(fields);
|
||||||
|
body.append('csrf_token', csrfToken);
|
||||||
|
|
||||||
|
fetch('{{ url_for("admin.email_ingestion_test") }}', {
|
||||||
|
method : 'POST',
|
||||||
|
headers: { 'X-CSRFToken': csrfToken, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body : body.toString(),
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
setResult(data.ok, data.message);
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
setResult(false, 'Request failed: ' + err);
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-plug me-2"></i>Test Connection';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.emailIngestRunNow = function () {
|
||||||
|
var btn = document.getElementById('btn-run-ingest');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Running…';
|
||||||
|
|
||||||
|
var body = new URLSearchParams({ csrf_token: csrfToken });
|
||||||
|
|
||||||
|
fetch('{{ url_for("admin.email_ingestion_run_now") }}', {
|
||||||
|
method : 'POST',
|
||||||
|
headers: { 'X-CSRFToken': csrfToken, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body : body.toString(),
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
setResult(data.ok, data.message);
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
setResult(false, 'Request failed: ' + err);
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-play-circle me-2"></i>Run Now';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<!-- Satisfaction Survey -->
|
<!-- Satisfaction Survey -->
|
||||||
<div class="card mb-4" style="max-width:680px;">
|
<div class="card mb-4" style="max-width:680px;">
|
||||||
<div class="card-header" style="padding:16px 20px;border-bottom:1px solid var(--border);">
|
<div class="card-header" style="padding:16px 20px;border-bottom:1px solid var(--border);">
|
||||||
|
|||||||
Reference in New Issue
Block a user