1
1
#!/usr/bin/env python3
2
- from flask import Flask , render_template , request , redirect , url_for , session , jsonify
2
+ from flask import Flask , render_template , request , redirect , url_for , session , jsonify , flash
3
3
import json # My preffered method of "database" replacements.
4
4
import smtplib # Required protocol for sending emails by code.
5
5
import imaplib # Required protocol for receiving/logging into email provider.
6
6
import re # Regex support for reading emails and subject lines.
7
7
import email # Required to read the content of the emails.
8
8
import threading # Background process.
9
9
import time # Used for script sleeping.
10
+ import logging
11
+ import requests # CF Turnstiles.
10
12
import os # Required to load DOTENV files.
11
13
import fcntl # Unix file locking support.
12
- from dotenv import load_dotenv # Dependant on OS module
14
+ from dotenv import load_dotenv # Dependant on OS module.
13
15
from email .mime .text import MIMEText
14
16
from email .mime .multipart import MIMEMultipart # Required for new-ticket-email.html
15
17
from email .header import decode_header
16
- from datetime import datetime # Timestamps on tickets .
18
+ from datetime import datetime # Timestamps.
17
19
from local_webhook_handler import send_discord_notification # Webhook handler, local to this repo.
18
20
from local_webhook_handler import send_TktUpdate_discord_notification # I need to find a better way to handle this import but I learned this new thing!
19
21
20
22
app = Flask (__name__ )
21
- app .secret_key = "secretdemokey "
23
+ app .secret_key = "thegardenisfullofcolorstosee "
22
24
23
25
# Load environment variables from .env in the local folder.
24
26
load_dotenv (dotenv_path = ".env" )
30
32
SMTP_SERVER = os .getenv ("SMTP_SERVER" ) # Provider SMTP Server Address.
31
33
SMTP_PORT = os .getenv ("SMTP_PORT" ) # Provider SMTP Server Port. Default is TCP/587.
32
34
DISCORD_WEBHOOK_URL = os .getenv ("DISCORD_WEBHOOK_URL" )
35
+ LOG_FILE = os .getenv ("LOG_FILE" )
36
+ CF_TURNSTILE_SITE_KEY = os .getenv ("CF_TURNSTILE_SITE_KEY" )
37
+ CF_TURNSTILE_SECRET_KEY = os .getenv ("CF_TURNSTILE_SECRET_KEY" )
38
+
39
+ # Standard Logging. basicConfig makes it reusable in other local py modules.
40
+ logging .basicConfig (filename = "LOG_FILE" , level = logging .INFO , format = "%(asctime)s - %(levelname)s - %(message)s" )
33
41
34
42
# Read/Loads the ticket file into memory. This is the original load_tickets function that works on Windows and Unix.
35
43
#def load_tickets():
@@ -51,9 +59,11 @@ def load_tickets(retries=5, delay=0.2):
51
59
fcntl .flock (file , fcntl .LOCK_UN ) # Unlock the file.
52
60
return tickets
53
61
except (json .JSONDecodeError , FileNotFoundError ) as e :
62
+ logging .critical (f"Error loading tickets: { e } " )
54
63
print (f"ERROR - Error loading tickets: { e } " )
55
64
return []
56
65
except BlockingIOError :
66
+ logging .warning (f"File is locked, retrying... ({ attempt + 1 } /{ retries } )" )
57
67
print (f"DEBUG - File is locked, retrying... ({ attempt + 1 } /{ retries } )" )
58
68
time .sleep (delay ) # Wait before retrying
59
69
raise Exception ("ERROR - Failed to load tickets after multiple attempts." )
@@ -97,7 +107,9 @@ def send_email(requestor_email, ticket_subject, ticket_message, html=True):
97
107
server .login (EMAIL_ACCOUNT , EMAIL_PASSWORD )
98
108
server .sendmail (EMAIL_ACCOUNT , requestor_email , msg .as_string ())
99
109
except Exception as e :
110
+ logging .error (f"Email sending failed: { e } " )
100
111
print (f"ERROR - Email sending failed: { e } " )
112
+ logging .info (f"Confirmation Email sent to { requestor_email } " )
101
113
print (f"INFO - Confirmation Email sent to { requestor_email } " )
102
114
103
115
# extract_email_body is attempting to scrape the content of the "valid" TKT email replies. It skips attachments. I do not currently need this feature.
@@ -116,18 +128,21 @@ def extract_email_body(msg):
116
128
try :
117
129
body = part .get_payload (decode = True ).decode (errors = "ignore" ).strip ()
118
130
except Exception as e :
131
+ logging .error (f"Error decoding email part: { e } " )
119
132
print (f"ERROR - Error decoding email part: { e } " )
120
133
continue
121
134
elif content_type == "text/html" and not body :
122
135
try :
123
136
body = part .get_payload (decode = True ).decode (errors = "ignore" ).strip ()
124
137
except Exception as e :
138
+ logging .error (f"Error decoding HTML part: { e } " )
125
139
print (f"ERROR - Error decoding HTML part: { e } " )
126
140
continue
127
141
else :
128
142
try :
129
143
body = msg .get_payload (decode = True ).decode (errors = "ignore" ).strip ()
130
144
except Exception as e :
145
+ logging .error (f"Error decoding single-part email: { e } " )
131
146
print (f"ERROR - Error decoding single-part email: { e } " )
132
147
133
148
return body
@@ -141,6 +156,7 @@ def fetch_email_replies():
141
156
142
157
mail_server_response_status , messages = mail .search (None , "UNSEEN" )
143
158
if mail_server_response_status != "OK" :
159
+ logging .debug ("Reading Inbox via IMAP failed for an unknown reason." )
144
160
print ("DEBUG - Reading Inbox via IMAP failed for an unknown reason." )
145
161
return
146
162
@@ -151,6 +167,7 @@ def fetch_email_replies():
151
167
email_id = email_id .decode () # Ensure it's a string
152
168
mail_server_response_status , msg_data = mail .fetch (email_id , "(RFC822)" )
153
169
if mail_server_response_status != "OK" or not msg_data :
170
+ logging .error (f"Unable to fetch email { email_id } " )
154
171
print (f"ERROR - Unable to fetch email { email_id } " )
155
172
continue
156
173
@@ -163,7 +180,8 @@ def fetch_email_replies():
163
180
164
181
from_email = msg .get ("From" )
165
182
match_ticket_reply = re .search (r"(?i)\bTKT-\d{4}-\d+\b" , subject )
166
- ticket_id = match_ticket_reply .group (0 ) if match_ticket_reply else None
183
+ ticket_id = match_ticket_reply .group (0 ) if match_ticket_reply else None
184
+ logging .debug (f"Extracted ticket ID: { ticket_id } from subject: { subject } " )
167
185
print (f"DEBUG - Extracted ticket ID: { ticket_id } from subject: { subject } " )
168
186
169
187
if not ticket_id :
@@ -174,68 +192,108 @@ def fetch_email_replies():
174
192
for ticket in tickets :
175
193
if ticket ["ticket_number" ] == ticket_id :
176
194
ticket ["ticket_notes" ].append ({"ticket_message" : body })
177
- save_tickets (tickets )
195
+ save_tickets (tickets )
196
+ logging .debug (f"Updated ticket { ticket_id } with reply from { from_email } " )
178
197
print (f"DEBUG - Updated ticket { ticket_id } with reply from { from_email } " )
179
198
break
180
199
181
- mail .logout ()
200
+ mail .logout ()
201
+ logging .info ("Email fetch job completed successfully." )
182
202
print ("INFO - Email fetch job completed successfully." )
183
203
except Exception as e :
204
+ logging .error (f"Error fetching emails: { e } " )
184
205
print (f"ERROR - Error fetching emails: { e } " )
185
206
186
- # Background email monitoring
207
+ # Background email monitoring. This is a running process using modules above.
187
208
def background_email_monitor ():
188
209
while True :
189
210
fetch_email_replies ()
190
211
time .sleep (300 ) # Wait for emails every 5 minutes.
191
212
192
213
threading .Thread (target = background_email_monitor , daemon = True ).start ()
193
214
194
- # BELOW THIS LINE IS RESERVED FOR FLASK APP ROUTES
195
- # This is the "default" route for the home/index/landing page. This is where users submit a ticket.
196
215
@app .route ("/" , methods = ["GET" , "POST" ])
197
216
def home ():
198
217
if request .method == "POST" :
199
- requestor_name = request .form ["requestor_name" ]
200
- requestor_email = request .form ["requestor_email" ]
201
- ticket_subject = request .form ["ticket_subject" ]
202
- ticket_message = request .form ["ticket_message" ]
203
- ticket_impact = request .form ["ticket_impact" ]
204
- ticket_urgency = request .form ["ticket_urgency" ]
205
- request_type = request .form ["request_type" ]
206
- ticket_number = generate_ticket_number ()
207
-
208
- new_ticket = {
209
- "ticket_number" : ticket_number ,
210
- "requestor_name" : requestor_name ,
211
- "requestor_email" : requestor_email ,
212
- "ticket_subject" : ticket_subject ,
213
- "ticket_message" : ticket_message ,
214
- "request_type" : request_type ,
215
- "ticket_impact" : ticket_impact ,
216
- "ticket_urgency" : ticket_urgency ,
217
- "ticket_status" : "Open" ,
218
- "submission_date" : datetime .now ().strftime ("%Y-%m-%d %H:%M:%S" ),
219
- "ticket_notes" : []
220
- }
221
-
222
- tickets = load_tickets ()
223
- tickets .append (new_ticket )
224
- print (f"INFO - a new { ticket_number } has been created" )
225
- save_tickets (tickets )
226
-
227
- # Render the HTML email template
228
- email_body = render_template ("/new-ticket-email.html" , ticket = new_ticket )
218
+ try :
219
+ # Cloudflare Turnstile CAPTCHA validation
220
+ turnstile_token = request .form .get ("cf-turnstile-response" )
221
+ if not turnstile_token :
222
+ flash ("CAPTCHA verification failed. Please try again." , "danger" )
223
+ return redirect (url_for ("home" ))
224
+
225
+ turnstile_url = "https://challenges.cloudflare.com/turnstile/v0/siteverify"
226
+ turnstile_data = {
227
+ "secret" : CF_TURNSTILE_SECRET_KEY ,
228
+ "response" : turnstile_token ,
229
+ "remoteip" : request .remote_addr
230
+ }
231
+
232
+ try :
233
+ turnstile_response = requests .post (turnstile_url , data = turnstile_data )
234
+ result = turnstile_response .json ()
235
+ if not result .get ("success" ):
236
+ logging .warning (f"Turnstile verification failed: { result } " )
237
+ flash ("CAPTCHA verification failed. Please try again." , "danger" )
238
+ return redirect (url_for ("home" ))
239
+ except Exception as e :
240
+ logging .error (f"Turnstile verification error: { str (e )} " )
241
+ flash ("Error verifying CAPTCHA. Please try again later." , "danger" )
242
+ return redirect (url_for ("home" ))
243
+
244
+ # Process ticket submission
245
+ requestor_name = request .form ["requestor_name" ]
246
+ requestor_email = request .form ["requestor_email" ]
247
+ ticket_subject = request .form ["ticket_subject" ]
248
+ ticket_message = request .form ["ticket_message" ]
249
+ ticket_impact = request .form ["ticket_impact" ]
250
+ ticket_urgency = request .form ["ticket_urgency" ]
251
+ request_type = request .form ["request_type" ]
252
+ ticket_number = generate_ticket_number ()
253
+
254
+ new_ticket = {
255
+ "ticket_number" : ticket_number ,
256
+ "requestor_name" : requestor_name ,
257
+ "requestor_email" : requestor_email ,
258
+ "ticket_subject" : ticket_subject ,
259
+ "ticket_message" : ticket_message ,
260
+ "request_type" : request_type ,
261
+ "ticket_impact" : ticket_impact ,
262
+ "ticket_urgency" : ticket_urgency ,
263
+ "ticket_status" : "Open" ,
264
+ "submission_date" : datetime .now ().strftime ("%Y-%m-%d %H:%M:%S" ),
265
+ "ticket_notes" : []
266
+ }
267
+
268
+ tickets = load_tickets ()
269
+ tickets .append (new_ticket )
270
+ logging .info (f"{ ticket_number } has been created." )
271
+ print (f"INFO - a new { ticket_number } has been created." )
272
+ save_tickets (tickets )
273
+
274
+ # Send the email with error han dling
275
+ try :
276
+ email_body = render_template ("/new-ticket-email.html" , ticket = new_ticket )
277
+ send_email (requestor_email , f"{ ticket_number } - { ticket_subject } " , email_body , html = True )
278
+ except Exception as e :
279
+ logging .error (f"Failed to send email for { ticket_number } : { str (e )} " )
280
+ print (f"ERROR - Failed to send email for { ticket_number } : { str (e )} " )
281
+
282
+ # Send a Discord webhook notification with error handling
283
+ try :
284
+ send_discord_notification (ticket_number , ticket_message )
285
+ except Exception as e :
286
+ logging .error (f"Failed to send Discord notification for { ticket_number } : { str (e )} " )
287
+ print (f"ERROR - Failed to send Discord notification for { ticket_number } : { str (e )} " )
288
+
289
+ return redirect (url_for ("home" ))
229
290
230
- # Send the email with HTML format
231
- send_email (requestor_email , f"{ ticket_number } - { ticket_subject } " , email_body , html = True )
291
+ except Exception as e :
292
+ logging .critical (f"Failed to process ticket submission: { str (e )} " )
293
+ print (f"CRITICAL ERROR - Failed to process ticket submission: { str (e )} " )
294
+ return "An error occurred while submitting your ticket. Please try again later." , 500
232
295
233
- # Send a Discord webhook notification.
234
- send_discord_notification (ticket_number , ticket_message )
235
-
236
- return redirect (url_for ("home" ))
237
-
238
- return render_template ("index.html" )
296
+ return render_template ("index.html" , sitekey = CF_TURNSTILE_SITE_KEY )
239
297
240
298
# Route/routine for the technician login page/process.
241
299
@app .route ("/login" , methods = ["GET" , "POST" ])
@@ -254,7 +312,7 @@ def login():
254
312
else :
255
313
return render_template ("404.html" ), 404 # Send our custom 404 page.
256
314
257
- return render_template ("login.html" )
315
+ return render_template ("login.html" , sitekey = CF_TURNSTILE_SITE_KEY )
258
316
259
317
# Route/routine for rendering the core technician dashboard. Displays all Open and In-Progress tickets.
260
318
@app .route ("/dashboard" )
@@ -283,7 +341,7 @@ def ticket_detail(ticket_number):
283
341
284
342
# Route/routine for updating a ticket. Called from Dashboard and Ticket Commander.
285
343
@app .route ("/ticket/<ticket_number>/update_status/<ticket_status>" , methods = ["POST" ])
286
- def update_ticket_status (ticket_number , ticket_status ):
344
+ def update_ticket_status (ticket_number , ticket_status ):
287
345
if not session .get ("technician" ): # Ensure only authenticated techs can update tickets.
288
346
return render_template ("403.html" ), 403
289
347
0 commit comments