AD232025 commited on
Commit
21a8272
·
verified ·
1 Parent(s): 7ac4a69

Upload 17 files

Browse files
Dockerfile ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /code
4
+
5
+ RUN apt-get update && apt-get install -y build-essential && rm -rf /var/lib/apt/lists/*
6
+
7
+ COPY requirements.txt .
8
+ RUN pip install --no-cache-dir -r requirements.txt
9
+
10
+ COPY . .
11
+
12
+ ENV PORT=7860
13
+ EXPOSE 7860
14
+
15
+ CMD ["python", "app.py"]
16
+
app.py ADDED
@@ -0,0 +1,753 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import csv
4
+ import secrets
5
+ import unicodedata
6
+ from datetime import datetime, timedelta
7
+ from io import BytesIO
8
+
9
+ from flask import (
10
+ Flask,
11
+ render_template,
12
+ request,
13
+ redirect,
14
+ url_for,
15
+ send_file,
16
+ flash,
17
+ session,
18
+ abort,
19
+ )
20
+ from flask_sqlalchemy import SQLAlchemy
21
+ from flask_login import (
22
+ LoginManager,
23
+ UserMixin,
24
+ login_user,
25
+ login_required,
26
+ current_user,
27
+ logout_user,
28
+ )
29
+ from werkzeug.security import generate_password_hash, check_password_hash
30
+ from reportlab.pdfgen import canvas
31
+ from reportlab.lib.pagesizes import letter
32
+
33
+ from sendgrid import SendGridAPIClient
34
+ from sendgrid.helpers.mail import Mail
35
+
36
+ # your HF-based classifier
37
+ from model import classify_tone_rich
38
+
39
+
40
+ # =========================================================
41
+ # APP CONFIG
42
+ # =========================================================
43
+
44
+ app = Flask(__name__)
45
+
46
+ app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "change-this-in-prod")
47
+ app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///data.db"
48
+ app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
49
+
50
+ # cookie security
51
+ app.config["SESSION_COOKIE_HTTPONLY"] = True
52
+ app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
53
+
54
+ db = SQLAlchemy(app)
55
+ login_manager = LoginManager(app)
56
+ login_manager.login_view = "login"
57
+
58
+ # Email (SendGrid)
59
+ SENDGRID_API_KEY = os.getenv("SENDGRID_API_KEY")
60
+ SENDER_EMAIL = os.getenv("SENDER_EMAIL", "[email protected]")
61
+
62
+ # simple email regex
63
+ EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
64
+
65
+ os.makedirs("exports", exist_ok=True)
66
+
67
+
68
+ # =========================================================
69
+ # HELPER FUNCTIONS – SANITIZATION, CSRF, PASSWORDS
70
+ # =========================================================
71
+
72
+ def normalize_text(value: str) -> str:
73
+ if not value:
74
+ return ""
75
+ value = unicodedata.normalize("NFKC", value)
76
+ value = value.replace("\u200b", "").replace("\u200c", "").replace("\u200d", "")
77
+ return value.strip()
78
+
79
+
80
+ def sanitize_string(value: str, max_len: int = 255) -> str:
81
+ value = normalize_text(value)
82
+ if len(value) > max_len:
83
+ value = value[:max_len]
84
+ return value
85
+
86
+
87
+ def sanitize_long_text(value: str, max_len: int = 4000) -> str:
88
+ value = normalize_text(value)
89
+ if len(value) > max_len:
90
+ value = value[:max_len]
91
+ return value
92
+
93
+
94
+ def is_valid_email(email: str) -> bool:
95
+ return bool(email and EMAIL_RE.match(email))
96
+
97
+
98
+ def is_strong_password(pw: str) -> bool:
99
+ if not pw or len(pw) < 8:
100
+ return False
101
+ has_letter = any(c.isalpha() for c in pw)
102
+ has_digit = any(c.isdigit() for c in pw)
103
+ return has_letter and has_digit
104
+
105
+
106
+ def generate_code() -> str:
107
+ """6-digit numeric code used for verify + reset."""
108
+ return f"{secrets.randbelow(1000000):06d}"
109
+
110
+
111
+ def generate_csrf_token() -> str:
112
+ token = session.get("csrf_token")
113
+ if not token:
114
+ token = secrets.token_hex(16)
115
+ session["csrf_token"] = token
116
+ return token
117
+
118
+
119
+ @app.before_request
120
+ def csrf_protect():
121
+ # ensure CSRF token exists
122
+ generate_csrf_token()
123
+
124
+ if request.method == "POST":
125
+ form_token = request.form.get("csrf_token", "")
126
+ sess_token = session.get("csrf_token", "")
127
+ if not form_token or form_token != sess_token:
128
+ abort(400, description="Invalid CSRF token")
129
+
130
+
131
+ @app.context_processor
132
+ def inject_csrf():
133
+ return {"csrf_token": session.get("csrf_token", "")}
134
+
135
+
136
+ # =========================================================
137
+ # MODELS
138
+ # =========================================================
139
+
140
+ class User(UserMixin, db.Model):
141
+ id = db.Column(db.Integer, primary_key=True)
142
+ email = db.Column(db.String(255), unique=True, nullable=False)
143
+ password_hash = db.Column(db.String(255), nullable=False)
144
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
145
+
146
+ # login security
147
+ failed_logins = db.Column(db.Integer, default=0)
148
+ lock_until = db.Column(db.DateTime, nullable=True)
149
+
150
+ # email verification
151
+ is_verified = db.Column(db.Boolean, default=False)
152
+ verification_code = db.Column(db.String(6), nullable=True)
153
+ verification_expires = db.Column(db.DateTime, nullable=True)
154
+
155
+ # password reset
156
+ reset_code = db.Column(db.String(6), nullable=True)
157
+ reset_expires = db.Column(db.DateTime, nullable=True)
158
+
159
+ # activity (for possible retention rules)
160
+ last_active_at = db.Column(db.DateTime, nullable=True)
161
+
162
+
163
+ class Entry(db.Model):
164
+ id = db.Column(db.Integer, primary_key=True)
165
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
166
+ text = db.Column(db.Text, nullable=False)
167
+ label = db.Column(db.String(32))
168
+ confidence = db.Column(db.Float)
169
+ severity = db.Column(db.Integer)
170
+ threat_score = db.Column(db.Integer)
171
+ politeness_score = db.Column(db.Integer)
172
+ friendly_score = db.Column(db.Integer)
173
+ has_threat = db.Column(db.Boolean, default=False)
174
+ has_profanity = db.Column(db.Boolean, default=False)
175
+ has_sarcasm = db.Column(db.Boolean, default=False)
176
+ user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
177
+
178
+ user = db.relationship("User", backref="entries")
179
+
180
+
181
+ @login_manager.user_loader
182
+ def load_user(user_id):
183
+ try:
184
+ return User.query.get(int(user_id))
185
+ except Exception:
186
+ return None
187
+
188
+
189
+ # =========================================================
190
+ # EMAIL HELPERS
191
+ # =========================================================
192
+
193
+ def send_email(to_email: str, subject: str, html: str):
194
+ if not SENDGRID_API_KEY:
195
+ print("[WARN] SENDGRID_API_KEY not set. Skipping email send.")
196
+ print(f"Subject: {subject}\nTo: {to_email}\n{html}")
197
+ return
198
+
199
+ message = Mail(
200
+ from_email=SENDER_EMAIL,
201
+ to_emails=to_email,
202
+ subject=subject,
203
+ html_content=html,
204
+ )
205
+ try:
206
+ sg = SendGridAPIClient(SENDGRID_API_KEY)
207
+ sg.send(message)
208
+ print(f"[INFO] Sent email to {to_email}: {subject}")
209
+ except Exception as e:
210
+ print(f"[ERROR] Failed to send email to {to_email}: {e}")
211
+
212
+
213
+ def send_verification_email(to_email: str, code: str):
214
+ html = f"""
215
+ <p>Thanks for signing up for the AI Email Tone Classifier.</p>
216
+ <p>Your verification code is: <strong>{code}</strong></p>
217
+ <p>This code will expire in 15 minutes.</p>
218
+ """
219
+ send_email(to_email, "Verify your email", html)
220
+
221
+
222
+ def send_password_reset_email(to_email: str, code: str):
223
+ html = f"""
224
+ <p>You requested to reset your password for the AI Email Tone Classifier.</p>
225
+ <p>Your password reset code is: <strong>{code}</strong></p>
226
+ <p>This code will expire in 15 minutes.</p>
227
+ <p>If you did not request this, you can ignore this email.</p>
228
+ """
229
+ send_email(to_email, "Password reset code", html)
230
+
231
+
232
+ # =========================================================
233
+ # AUTH ROUTES: REGISTER / LOGIN / LOGOUT / VERIFY
234
+ # =========================================================
235
+
236
+ @app.route("/register", methods=["GET", "POST"])
237
+ def register():
238
+ if current_user.is_authenticated:
239
+ return redirect(url_for("index"))
240
+
241
+ if request.method == "POST":
242
+ email = sanitize_string(request.form.get("email", ""), 255).lower()
243
+ password = normalize_text(request.form.get("password", ""))
244
+ consent = request.form.get("consent_privacy") == "on"
245
+
246
+ if not email or not password:
247
+ flash("Email and password are required.", "error")
248
+ return redirect(url_for("register"))
249
+
250
+ if not is_valid_email(email):
251
+ flash("Please enter a valid email address.", "error")
252
+ return redirect(url_for("register"))
253
+
254
+ if not is_strong_password(password):
255
+ flash("Password must be at least 8 characters and contain letters and numbers.", "error")
256
+ return redirect(url_for("register"))
257
+
258
+ if not consent:
259
+ flash("You must agree to the Privacy Policy to create an account.", "error")
260
+ return redirect(url_for("register"))
261
+
262
+ existing = User.query.filter_by(email=email).first()
263
+ if existing:
264
+ flash("An account with that email already exists.", "error")
265
+ return redirect(url_for("register"))
266
+
267
+ user = User(
268
+ email=email,
269
+ password_hash=generate_password_hash(password),
270
+ last_active_at=datetime.utcnow(),
271
+ )
272
+
273
+ code = generate_code()
274
+ user.verification_code = code
275
+ user.verification_expires = datetime.utcnow() + timedelta(minutes=15)
276
+ user.is_verified = False
277
+
278
+ db.session.add(user)
279
+ db.session.commit()
280
+
281
+ send_verification_email(email, code)
282
+ session["pending_email"] = email
283
+
284
+ flash("Account created. Check your email for the verification code.", "success")
285
+ return redirect(url_for("verify"))
286
+
287
+ return render_template("login.html", mode="register", title="Register")
288
+
289
+
290
+ @app.route("/login", methods=["GET", "POST"])
291
+ def login():
292
+ if current_user.is_authenticated:
293
+ return redirect(url_for("index"))
294
+
295
+ if request.method == "POST":
296
+ email = sanitize_string(request.form.get("email", ""), 255).lower()
297
+ password = normalize_text(request.form.get("password", ""))
298
+
299
+ if not email or not password:
300
+ flash("Email and password are required.", "error")
301
+ return redirect(url_for("login"))
302
+
303
+ user = User.query.filter_by(email=email).first()
304
+ if not user:
305
+ flash("Invalid email or password.", "error")
306
+ return redirect(url_for("login"))
307
+
308
+ now = datetime.utcnow()
309
+
310
+ # lockout check
311
+ if user.lock_until and user.lock_until > now:
312
+ remaining = int((user.lock_until - now).total_seconds() // 60) + 1
313
+ flash(f"Account locked due to too many failed attempts. Try again in ~{remaining} minutes.", "error")
314
+ return redirect(url_for("login"))
315
+
316
+ if not check_password_hash(user.password_hash, password):
317
+ user.failed_logins = (user.failed_logins or 0) + 1
318
+ if user.failed_logins >= 5:
319
+ user.lock_until = now + timedelta(minutes=10)
320
+ user.failed_logins = 0
321
+ db.session.commit()
322
+ flash("Invalid email or password.", "error")
323
+ return redirect(url_for("login"))
324
+
325
+ # reset counters
326
+ user.failed_logins = 0
327
+ user.lock_until = None
328
+ user.last_active_at = now
329
+ db.session.commit()
330
+
331
+ if not user.is_verified:
332
+ session["pending_email"] = user.email
333
+ flash("Please verify your email before logging in.", "error")
334
+ return redirect(url_for("verify"))
335
+
336
+ login_user(user)
337
+ flash("Logged in successfully.", "success")
338
+ return redirect(url_for("index"))
339
+
340
+ return render_template("login.html", mode="login", title="Login")
341
+
342
+
343
+ @app.route("/logout")
344
+ @login_required
345
+ def logout():
346
+ logout_user()
347
+ flash("You have been logged out.", "success")
348
+ return redirect(url_for("login"))
349
+
350
+
351
+ @app.route("/verify", methods=["GET", "POST"])
352
+ def verify():
353
+ email = sanitize_string(
354
+ request.args.get("email", "") or session.get("pending_email", ""), 255
355
+ ).lower()
356
+
357
+ if not email:
358
+ flash("No email specified for verification. Please register or log in again.", "error")
359
+ return redirect(url_for("register"))
360
+
361
+ user = User.query.filter_by(email=email).first()
362
+ if not user:
363
+ flash("Account not found. Please register again.", "error")
364
+ return redirect(url_for("register"))
365
+
366
+ if user.is_verified:
367
+ flash("Your email is already verified. You can log in.", "success")
368
+ return redirect(url_for("login"))
369
+
370
+ if request.method == "POST":
371
+ action = request.form.get("action", "verify")
372
+
373
+ if action == "resend":
374
+ code = generate_code()
375
+ user.verification_code = code
376
+ user.verification_expires = datetime.utcnow() + timedelta(minutes=15)
377
+ db.session.commit()
378
+ send_verification_email(user.email, code)
379
+ flash("A new verification code has been sent.", "success")
380
+ return redirect(url_for("verify", email=user.email))
381
+
382
+ code_input = sanitize_string(request.form.get("code", ""), 6)
383
+
384
+ if not code_input:
385
+ flash("Please enter the verification code.", "error")
386
+ return redirect(url_for("verify", email=user.email))
387
+
388
+ if not user.verification_code or not user.verification_expires:
389
+ flash("No active verification code. Please resend.", "error")
390
+ return redirect(url_for("verify", email=user.email))
391
+
392
+ if datetime.utcnow() > user.verification_expires:
393
+ flash("Verification code expired. Please request a new one.", "error")
394
+ return redirect(url_for("verify", email=user.email))
395
+
396
+ if code_input != user.verification_code:
397
+ flash("Invalid verification code.", "error")
398
+ return redirect(url_for("verify", email=user.email))
399
+
400
+ user.is_verified = True
401
+ user.verification_code = None
402
+ user.verification_expires = None
403
+ user.last_active_at = datetime.utcnow()
404
+ db.session.commit()
405
+
406
+ flash("Email verified successfully. You can now log in.", "success")
407
+ return redirect(url_for("login"))
408
+
409
+ session["pending_email"] = email
410
+ return render_template("verify.html", email=email, title="Verify Email")
411
+
412
+
413
+ # =========================================================
414
+ # FORGOT PASSWORD + RESET
415
+ # =========================================================
416
+
417
+ @app.route("/forgot", methods=["GET", "POST"])
418
+ def forgot_password():
419
+ if request.method == "POST":
420
+ email = sanitize_string(request.form.get("email", ""), 255).lower()
421
+
422
+ if not is_valid_email(email):
423
+ flash("If that email exists, a reset code has been sent. Check your email, then enter the code below.", "success")
424
+ return redirect(url_for("reset_password"))
425
+
426
+ user = User.query.filter_by(email=email).first()
427
+ if user:
428
+ code = generate_code()
429
+ user.reset_code = code
430
+ user.reset_expires = datetime.utcnow() + timedelta(minutes=15)
431
+ db.session.commit()
432
+ send_password_reset_email(user.email, code)
433
+
434
+ flash("If that email exists, a reset code has been sent. Check your email, then enter the code below.", "success")
435
+ return redirect(url_for("reset_password"))
436
+
437
+ return render_template("forgot.html", title="Forgot Password")
438
+
439
+
440
+
441
+ @app.route("/reset", methods=["GET", "POST"])
442
+ def reset_password():
443
+ if request.method == "POST":
444
+ email = sanitize_string(request.form.get("email", ""), 255).lower()
445
+ code_input = sanitize_string(request.form.get("code", ""), 6)
446
+ new_pw = normalize_text(request.form.get("password", ""))
447
+ confirm_pw = normalize_text(request.form.get("confirm_password", ""))
448
+
449
+ if not email or not code_input or not new_pw or not confirm_pw:
450
+ flash("All fields are required.", "error")
451
+ return redirect(url_for("reset_password"))
452
+
453
+ if new_pw != confirm_pw:
454
+ flash("Passwords do not match.", "error")
455
+ return redirect(url_for("reset_password"))
456
+
457
+ if not is_strong_password(new_pw):
458
+ flash("Password must be at least 8 characters and contain letters and numbers.", "error")
459
+ return redirect(url_for("reset_password"))
460
+
461
+ user = User.query.filter_by(email=email).first()
462
+ if (
463
+ not user
464
+ or not user.reset_code
465
+ or not user.reset_expires
466
+ or datetime.utcnow() > user.reset_expires
467
+ or code_input != user.reset_code
468
+ ):
469
+ flash("Invalid or expired reset code.", "error")
470
+ return redirect(url_for("reset_password"))
471
+
472
+ user.password_hash = generate_password_hash(new_pw)
473
+ user.reset_code = None
474
+ user.reset_expires = None
475
+ db.session.commit()
476
+
477
+ flash("Password reset successfully. You can now log in.", "success")
478
+ return redirect(url_for("login"))
479
+
480
+ return render_template("reset_password.html", title="Reset Password")
481
+
482
+
483
+ # =========================================================
484
+ # MAIN CLASSIFIER
485
+ # =========================================================
486
+
487
+ @app.route("/", methods=["GET", "POST"])
488
+ @login_required
489
+ def index():
490
+ if not current_user.is_verified:
491
+ flash("Please verify your email to use the classifier.", "error")
492
+ return redirect(url_for("verify", email=current_user.email))
493
+
494
+ result = None
495
+ text = ""
496
+
497
+ if request.method == "POST":
498
+ text = sanitize_long_text(request.form.get("email_text", ""))
499
+ if text:
500
+ result = classify_tone_rich(text)
501
+
502
+ entry = Entry(
503
+ text=text,
504
+ label=result["label"],
505
+ confidence=float(result["confidence"]),
506
+ severity=int(result["severity"]),
507
+ threat_score=int(result["threat_score"]),
508
+ politeness_score=int(result["politeness_score"]),
509
+ friendly_score=int(result["friendly_score"]),
510
+ has_threat=bool(result["has_threat"]),
511
+ has_profanity=bool(result["has_profanity"]),
512
+ has_sarcasm=bool(result["has_sarcasm"]),
513
+ user_id=current_user.id,
514
+ )
515
+ db.session.add(entry)
516
+ db.session.commit()
517
+
518
+ return render_template(
519
+ "index.html",
520
+ title="Analyze Email",
521
+ email_text=text,
522
+ result=result,
523
+ )
524
+
525
+
526
+ # =========================================================
527
+ # HISTORY + EXPORTS
528
+ # =========================================================
529
+
530
+ @app.route("/history")
531
+ @login_required
532
+ def history_view():
533
+ if not current_user.is_verified:
534
+ flash("Please verify your email to view history.", "error")
535
+ return redirect(url_for("verify", email=current_user.email))
536
+
537
+ q = sanitize_string(request.args.get("q", ""), 255).lower()
538
+ filter_label = sanitize_string(request.args.get("label", ""), 32).lower()
539
+
540
+ query = Entry.query.filter_by(user_id=current_user.id)
541
+
542
+ if q:
543
+ query = query.filter(Entry.text.ilike(f"%{q}%"))
544
+
545
+ if filter_label:
546
+ query = query.filter(Entry.label.ilike(filter_label))
547
+
548
+ entries = query.order_by(Entry.created_at.desc()).all()
549
+
550
+ return render_template(
551
+ "history.html",
552
+ title="History",
553
+ history=entries,
554
+ search=q,
555
+ active_filter=filter_label,
556
+ )
557
+
558
+
559
+ @app.route("/export/csv")
560
+ @login_required
561
+ def export_csv():
562
+ if not current_user.is_verified:
563
+ flash("Please verify your email to export data.", "error")
564
+ return redirect(url_for("verify", email=current_user.email))
565
+
566
+ filepath = os.path.join("exports", f"history_{current_user.id}.csv")
567
+ entries = Entry.query.filter_by(user_id=current_user.id).order_by(Entry.created_at.asc())
568
+
569
+ with open(filepath, "w", newline="", encoding="utf-8") as f:
570
+ writer = csv.writer(f)
571
+ writer.writerow(
572
+ [
573
+ "Time UTC",
574
+ "Label",
575
+ "Confidence",
576
+ "Severity",
577
+ "ThreatScore",
578
+ "PolitenessScore",
579
+ "FriendlyScore",
580
+ "HasThreat",
581
+ "HasProfanity",
582
+ "HasSarcasm",
583
+ "Text",
584
+ ]
585
+ )
586
+ for e in entries:
587
+ writer.writerow(
588
+ [
589
+ e.created_at.isoformat(),
590
+ e.label,
591
+ f"{e.confidence:.1f}",
592
+ e.severity,
593
+ e.threat_score,
594
+ e.politeness_score,
595
+ e.friendly_score,
596
+ int(e.has_threat),
597
+ int(e.has_profanity),
598
+ int(e.has_sarcasm),
599
+ e.text,
600
+ ]
601
+ )
602
+
603
+ return send_file(filepath, as_attachment=True)
604
+
605
+
606
+ @app.route("/export/pdf")
607
+ @login_required
608
+ def export_pdf():
609
+ if not current_user.is_verified:
610
+ flash("Please verify your email to export data.", "error")
611
+ return redirect(url_for("verify", email=current_user.email))
612
+
613
+ buffer = BytesIO()
614
+ c = canvas.Canvas(buffer, pagesize=letter)
615
+ width, height = letter
616
+
617
+ c.setFillColorRGB(0.12, 0.15, 0.20)
618
+ c.rect(0, height - 60, width, 60, fill=1)
619
+ c.setFillColorRGB(1, 1, 1)
620
+ c.setFont("Helvetica-Bold", 18)
621
+ c.drawString(40, height - 35, "Tone Classifier – History Report")
622
+
623
+ entries = (
624
+ Entry.query.filter_by(user_id=current_user.id)
625
+ .order_by(Entry.created_at.desc())
626
+ .all()
627
+ )
628
+
629
+ y = height - 80
630
+ for e in entries:
631
+ if y < 90:
632
+ c.showPage()
633
+ y = height - 60
634
+
635
+ c.setFont("Helvetica-Bold", 10)
636
+ c.setFillColorRGB(0, 0, 0)
637
+ c.drawString(
638
+ 40,
639
+ y,
640
+ f"{e.created_at.isoformat()} | {e.label} | Severity {e.severity}",
641
+ )
642
+ y -= 12
643
+
644
+ meta = f"Threat:{e.threat_score} Polite:{e.politeness_score} Friendly:{e.friendly_score}"
645
+ c.setFont("Helvetica", 9)
646
+ c.drawString(40, y, meta)
647
+ y -= 12
648
+
649
+ text = e.text
650
+ while len(text) > 90:
651
+ idx = text.rfind(" ", 0, 90)
652
+ if idx == -1:
653
+ idx = 90
654
+ c.drawString(50, y, text[:idx])
655
+ text = text[idx:].strip()
656
+ y -= 11
657
+
658
+ c.drawString(50, y, text)
659
+ y -= 20
660
+
661
+ c.showPage()
662
+ c.save()
663
+
664
+ buffer.seek(0)
665
+ filepath = os.path.join("exports", f"history_{current_user.id}.pdf")
666
+ with open(filepath, "wb") as f:
667
+ f.write(buffer.getvalue())
668
+
669
+ return send_file(filepath, as_attachment=True)
670
+
671
+
672
+ @app.route("/history/clear", methods=["POST"])
673
+ @login_required
674
+ def clear_history():
675
+ if not current_user.is_verified:
676
+ flash("Please verify your email to clear history.", "error")
677
+ return redirect(url_for("verify", email=current_user.email))
678
+
679
+ Entry.query.filter_by(user_id=current_user.id).delete()
680
+ db.session.commit()
681
+ flash("History cleared.", "success")
682
+ return redirect(url_for("history_view"))
683
+
684
+
685
+ # =========================================================
686
+ # DELETE ACCOUNT + GDPR PAGES
687
+ # =========================================================
688
+
689
+ @app.route("/account/delete", methods=["GET", "POST"])
690
+ @login_required
691
+ def delete_account():
692
+ if request.method == "POST":
693
+ password = normalize_text(request.form.get("password", ""))
694
+
695
+ if not check_password_hash(current_user.password_hash, password):
696
+ flash("Incorrect password. Account not deleted.", "error")
697
+ return redirect(url_for("delete_account"))
698
+
699
+ try:
700
+ uid = current_user.id
701
+ Entry.query.filter_by(user_id=uid).delete()
702
+ user = User.query.get(uid)
703
+ logout_user()
704
+ db.session.delete(user)
705
+ db.session.commit()
706
+ flash("Your account and all data have been deleted.", "success")
707
+ except Exception as e:
708
+ db.session.rollback()
709
+ flash("Error deleting account. Please try again.", "error")
710
+ print(f"[ERROR] delete_account failed: {e}")
711
+ return redirect(url_for("delete_account"))
712
+
713
+ return redirect(url_for("register"))
714
+
715
+ return render_template("delete_account.html", title="Delete Account")
716
+
717
+
718
+ @app.route("/privacy")
719
+ def privacy():
720
+ # pass datetime for template display
721
+ from datetime import datetime as dt
722
+ return render_template("privacy.html", title="Privacy Policy", datetime=dt)
723
+
724
+
725
+ @app.route("/do-not-sell")
726
+ def do_not_sell():
727
+ return render_template("do_not_sell.html", title="Do Not Sell My Info")
728
+
729
+
730
+ # =========================================================
731
+ # INIT DB & RUN (LOCAL)
732
+ # =========================================================
733
+
734
+ with app.app_context():
735
+ db.create_all()
736
+
737
+ if __name__ == "__main__":
738
+ # local dev
739
+ app.run(debug=True, host="0.0.0.0", port=7860)
740
+
741
+
742
+
743
+
744
+
745
+
746
+
747
+
748
+
749
+
750
+
751
+
752
+
753
+
model.py ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ from typing import Dict, List
4
+
5
+ from huggingface_hub import InferenceClient
6
+
7
+ # =========================================================
8
+ # HUGGING FACE INFERENCE CLIENT
9
+ # =========================================================
10
+
11
+ HF_API_TOKEN = os.getenv("HF_API_TOKEN") # optional, set in HF Space secrets
12
+ if HF_API_TOKEN:
13
+ client = InferenceClient(token=HF_API_TOKEN)
14
+ else:
15
+ client = InferenceClient() # anonymous for public models (rate-limited)
16
+
17
+ # Model IDs
18
+ TOX_MODEL_ID = "unitary/toxic-bert"
19
+ OFF_MODEL_ID = "cardiffnlp/twitter-roberta-base-offensive"
20
+ EMO_MODEL_ID = "j-hartmann/emotion-english-distilroberta-base"
21
+ SENT_MODEL_ID = "distilbert-base-uncased-finetuned-sst-2-english"
22
+
23
+ # =========================================================
24
+ # RULE KEYWORDS / PATTERNS
25
+ # =========================================================
26
+
27
+ AGGRESSION_KEYWORDS = [
28
+ "stupid", "idiot", "dumb", "incompetent", "useless",
29
+ "trash", "garbage", "worthless", "pathetic", "clown",
30
+ "moron", "failure", "shut up", "hate you"
31
+ ]
32
+
33
+ THREAT_PHRASES = [
34
+ "you will regret", "there will be consequences", "watch your back",
35
+ "this is your last warning", "i'm coming for you",
36
+ "or else", "i'll ruin you", "i'll make you pay",
37
+ "i am gonna hurt you", "i'm going to hurt you",
38
+ ]
39
+
40
+ PROFANITY = [
41
+ "fuck", "shit", "bitch", "asshole", "bastard",
42
+ "motherfucker", "prick", "dickhead"
43
+ ]
44
+
45
+ POLITE_KEYWORDS = [
46
+ "please", "thank you", "thanks", "would you mind",
47
+ "if possible", "kindly", "when you have a chance",
48
+ "if you don't mind"
49
+ ]
50
+
51
+ FRIENDLY_KEYWORDS = [
52
+ "awesome", "amazing", "great job", "fantastic",
53
+ "love this", "appreciate you", "good vibes",
54
+ "wonderful", "you're the best", "you are the best",
55
+ ]
56
+
57
+ SARCASM_PATTERNS = [
58
+ r"yeah right",
59
+ r"sure you did",
60
+ r"great job (idiot|genius)",
61
+ r"nice work (moron|buddy)",
62
+ r"well done.*not",
63
+ r"nice job.*not",
64
+ ]
65
+
66
+
67
+ # =========================================================
68
+ # HF INFERENCE HELPERS
69
+ # =========================================================
70
+
71
+ def _safe_text_classification(model_id: str, text: str) -> List[Dict]:
72
+ """
73
+ Wrapper around HF Inference API text classification.
74
+
75
+ Returns a list of dicts like:
76
+ [
77
+ {"label": "POSITIVE", "score": 0.95},
78
+ ...
79
+ ]
80
+ or [] on error.
81
+ """
82
+ try:
83
+ out = client.text_classification(text, model=model_id)
84
+ # Some clients may return a single dict; normalize to list
85
+ if isinstance(out, dict):
86
+ return [out]
87
+ return out or []
88
+ except Exception as e:
89
+ print(f"[WARN] HF Inference error for {model_id}: {e}")
90
+ return []
91
+
92
+
93
+ def _get_sentiment(text: str):
94
+ """
95
+ Returns (pos, neg) based on distilbert sentiment.
96
+ """
97
+ results = _safe_text_classification(SENT_MODEL_ID, text)
98
+ pos = 0.5
99
+ neg = 0.5
100
+
101
+ if results:
102
+ scores = {r["label"].upper(): float(r["score"]) for r in results}
103
+ # typical labels: POSITIVE / NEGATIVE
104
+ if "POSITIVE" in scores:
105
+ pos = scores["POSITIVE"]
106
+ neg = 1.0 - pos
107
+ elif "NEGATIVE" in scores:
108
+ neg = scores["NEGATIVE"]
109
+ pos = 1.0 - neg
110
+
111
+ return pos, neg
112
+
113
+
114
+ def _get_toxicity(text: str) -> float:
115
+ """
116
+ Return a toxicity-like score in [0, 1].
117
+ For unitary/toxic-bert, we consider any 'toxic-like' label as signal.
118
+ """
119
+ results = _safe_text_classification(TOX_MODEL_ID, text)
120
+ if not results:
121
+ return 0.0
122
+
123
+ toxic_score = 0.0
124
+ for r in results:
125
+ label = r["label"].lower()
126
+ if any(key in label for key in ["toxic", "obscene", "insult", "hate", "threat"]):
127
+ toxic_score = max(toxic_score, float(r["score"]))
128
+ return toxic_score
129
+
130
+
131
+ def _get_offensive(text: str) -> float:
132
+ """
133
+ Return an offensive score in [0, 1].
134
+ For cardiffnlp/twitter-roberta-base-offensive, look for OFFENSE-like labels.
135
+ """
136
+ results = _safe_text_classification(OFF_MODEL_ID, text)
137
+ if not results:
138
+ return 0.0
139
+
140
+ off_score = 0.0
141
+ for r in results:
142
+ label = r["label"].lower()
143
+ if "offense" in label or "offensive" in label:
144
+ off_score = max(off_score, float(r["score"]))
145
+ return off_score
146
+
147
+
148
+ def _get_emotions(text: str):
149
+ """
150
+ Returns a dict like {"anger": 0.3, "joy": 0.6}.
151
+ """
152
+ results = _safe_text_classification(EMO_MODEL_ID, text)
153
+ if not results:
154
+ return {"anger": 0.0, "joy": 0.0}
155
+
156
+ emo = {}
157
+ for r in results:
158
+ emo[r["label"].lower()] = float(r["score"])
159
+
160
+ anger = emo.get("anger", 0.0)
161
+ joy = emo.get("joy", 0.0)
162
+ return {"anger": anger, "joy": joy}
163
+
164
+
165
+ # =========================================================
166
+ # MAIN CLASSIFIER (STRICT OPTION A)
167
+ # =========================================================
168
+
169
+ def classify_tone_rich(text: str):
170
+ lowered = text.lower()
171
+ explanation = []
172
+
173
+ # --- Model signals ---
174
+ pos, neg = _get_sentiment(text)
175
+ tox_score = _get_toxicity(text)
176
+ off_score = _get_offensive(text)
177
+ emo = _get_emotions(text)
178
+ anger = emo.get("anger", 0.0)
179
+ joy = emo.get("joy", 0.0)
180
+
181
+ explanation.append(f"Sentiment pos={pos:.2f}, neg={neg:.2f}")
182
+ explanation.append(f"Toxicity={tox_score:.2f}, Offensive={off_score:.2f}")
183
+ explanation.append(f"Emotion anger={anger:.2f}, joy={joy:.2f}")
184
+
185
+ # --- Rule flags ---
186
+ has_insult = any(w in lowered for w in AGGRESSION_KEYWORDS)
187
+ has_threat = any(p in lowered for p in THREAT_PHRASES)
188
+ has_profanity = any(bad in lowered for bad in PROFANITY)
189
+ has_polite = any(w in lowered for w in POLITE_KEYWORDS)
190
+ has_friendly = any(w in lowered for w in FRIENDLY_KEYWORDS)
191
+ has_sarcasm = any(re.search(p, lowered) for p in SARCASM_PATTERNS)
192
+
193
+ if has_insult:
194
+ explanation.append("Detected explicit insult keyword.")
195
+ if has_threat:
196
+ explanation.append("Detected explicit threat phrase.")
197
+ if has_profanity:
198
+ explanation.append("Detected profanity.")
199
+ if has_polite:
200
+ explanation.append("Detected polite phrasing.")
201
+ if has_friendly:
202
+ explanation.append("Detected friendly / appreciative wording.")
203
+ if has_sarcasm:
204
+ explanation.append("Matched a sarcasm pattern.")
205
+
206
+ # =====================================================
207
+ # STRICT AGGRESSIVE RULES
208
+ # =====================================================
209
+
210
+ # 1) Threats override everything
211
+ if has_threat:
212
+ return {
213
+ "label": "Aggressive",
214
+ "confidence": 95,
215
+ "severity": 95,
216
+ "threat_score": 95,
217
+ "politeness_score": 0,
218
+ "friendly_score": 0,
219
+ "has_threat": True,
220
+ "has_profanity": has_profanity,
221
+ "has_sarcasm": has_sarcasm,
222
+ "explanation": explanation,
223
+ }
224
+
225
+ # 2) Profanity → aggressive
226
+ if has_profanity:
227
+ sev = max(85, int((tox_score + off_score) / 2 * 100))
228
+ return {
229
+ "label": "Aggressive",
230
+ "confidence": 90,
231
+ "severity": sev,
232
+ "threat_score": int(tox_score * 100),
233
+ "politeness_score": 0,
234
+ "friendly_score": 0,
235
+ "has_threat": has_threat,
236
+ "has_profanity": True,
237
+ "has_sarcasm": has_sarcasm,
238
+ "explanation": explanation,
239
+ }
240
+
241
+ # 3) Direct insults → aggressive
242
+ if has_insult:
243
+ sev = max(80, int((tox_score + off_score) / 2 * 100))
244
+ return {
245
+ "label": "Aggressive",
246
+ "confidence": 88,
247
+ "severity": sev,
248
+ "threat_score": int(tox_score * 100),
249
+ "politeness_score": 0,
250
+ "friendly_score": 0,
251
+ "has_threat": has_threat,
252
+ "has_profanity": has_profanity,
253
+ "has_sarcasm": has_sarcasm,
254
+ "explanation": explanation,
255
+ }
256
+
257
+ # 4) Sarcasm + negative sentiment → aggressive
258
+ if has_sarcasm and neg > 0.55:
259
+ return {
260
+ "label": "Aggressive",
261
+ "confidence": 85,
262
+ "severity": 85,
263
+ "threat_score": int(tox_score * 100),
264
+ "politeness_score": 0,
265
+ "friendly_score": 0,
266
+ "has_threat": has_threat,
267
+ "has_profanity": has_profanity,
268
+ "has_sarcasm": True,
269
+ "explanation": explanation,
270
+ }
271
+
272
+ # 5) High anger + toxicity
273
+ if anger + tox_score > 1.1:
274
+ return {
275
+ "label": "Aggressive",
276
+ "confidence": 80,
277
+ "severity": 80,
278
+ "threat_score": int(tox_score * 100),
279
+ "politeness_score": 0,
280
+ "friendly_score": 0,
281
+ "has_threat": has_threat,
282
+ "has_profanity": has_profanity,
283
+ "has_sarcasm": has_sarcasm,
284
+ "explanation": explanation,
285
+ }
286
+
287
+ # =====================================================
288
+ # POSITIVE LABELS – FRIENDLY / POLITE
289
+ # =====================================================
290
+ if has_friendly and pos > 0.60:
291
+ return {
292
+ "label": "Friendly",
293
+ "confidence": int(pos * 100),
294
+ "severity": 0,
295
+ "threat_score": int(tox_score * 100),
296
+ "politeness_score": int(pos * 100),
297
+ "friendly_score": int(pos * 100),
298
+ "has_threat": has_threat,
299
+ "has_profanity": has_profanity,
300
+ "has_sarcasm": has_sarcasm,
301
+ "explanation": explanation,
302
+ }
303
+
304
+ if has_polite and pos > 0.50:
305
+ return {
306
+ "label": "Polite",
307
+ "confidence": int(pos * 100),
308
+ "severity": 0,
309
+ "threat_score": int(tox_score * 100),
310
+ "politeness_score": int(pos * 100),
311
+ "friendly_score": 0,
312
+ "has_threat": has_threat,
313
+ "has_profanity": has_profanity,
314
+ "has_sarcasm": has_sarcasm,
315
+ "explanation": explanation,
316
+ }
317
+
318
+ # =====================================================
319
+ # NEUTRAL FALLBACK
320
+ # =====================================================
321
+ return {
322
+ "label": "Neutral",
323
+ "confidence": int((1 - neg) * 100),
324
+ "severity": 0,
325
+ "threat_score": int(tox_score * 100),
326
+ "politeness_score": int(pos * 100),
327
+ "friendly_score": int(pos * 100),
328
+ "has_threat": has_threat,
329
+ "has_profanity": has_profanity,
330
+ "has_sarcasm": has_sarcasm,
331
+ "explanation": explanation,
332
+ }
333
+
334
+
335
+ # Optional wrapper for backwards compatibility
336
+ def classify_tone(text: str):
337
+ r = classify_tone_rich(text)
338
+ aggressive_prob = r["severity"] / 100.0
339
+ positive_prob = r["friendly_score"] / 100.0
340
+ return r["label"], r["confidence"], aggressive_prob, positive_prob
requirements.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Flask==3.0.0
2
+ flask_sqlalchemy
3
+ flask_login
4
+ werkzeug
5
+ sendgrid
6
+ reportlab
7
+ python-dotenv
8
+ SQLAlchemy
9
+ huggingface_hub
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
send_test.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dotenv import load_dotenv
2
+ load_dotenv()
3
+ import os
4
+ from sendgrid import SendGridAPIClient
5
+ from sendgrid.helpers.mail import Mail
6
+
7
+ api_key = os.getenv("SENDGRID_API_KEY")
8
+ sender = os.getenv("SENDER_EMAIL")
9
+
10
+ print("Using API KEY:", api_key[:15] + "...")
11
+ print("Using sender:", sender)
12
+
13
+ message = Mail(
14
+ from_email=sender,
15
+ to_emails=sender, # send to yourself
16
+ subject="Test Email from Tone Classifier",
17
+ html_content="<p>This is a test email.</p>",
18
+ )
19
+
20
+ try:
21
+ sg = SendGridAPIClient(api_key)
22
+ response = sg.send(message)
23
+ print("Status:", response.status_code)
24
+ print("Body:", response.body)
25
+ print("Headers:", response.headers)
26
+ except Exception as e:
27
+ print("ERROR:", e)
static/app.js ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // =========================================================
2
+ // Theme Toggle (Light / Dark)
3
+ // =========================================================
4
+
5
+ document.addEventListener("DOMContentLoaded", () => {
6
+ const html = document.documentElement;
7
+ const toggleBtn = document.getElementById("themeToggle");
8
+
9
+ // Load saved theme
10
+ let theme = localStorage.getItem("theme") || "light";
11
+ html.dataset.theme = theme;
12
+
13
+ if (toggleBtn) {
14
+ toggleBtn.addEventListener("click", () => {
15
+ theme = theme === "light" ? "dark" : "light";
16
+ html.dataset.theme = theme;
17
+ localStorage.setItem("theme", theme);
18
+ });
19
+ }
20
+
21
+ // =========================================================
22
+ // Animate severity bar (confidence / aggression meter)
23
+ // =========================================================
24
+ const severityFill = document.querySelector(".severity-fill");
25
+ if (severityFill) {
26
+ const targetWidth = severityFill.getAttribute("data-width");
27
+ if (targetWidth) {
28
+ setTimeout(() => {
29
+ severityFill.style.width = targetWidth + "%";
30
+ }, 100);
31
+ }
32
+ }
33
+
34
+ // =========================================================
35
+ // Smooth fade-in for result cards
36
+ // =========================================================
37
+ const resultCard = document.querySelector(".result-card");
38
+ if (resultCard) {
39
+ resultCard.style.opacity = 0;
40
+ setTimeout(() => {
41
+ resultCard.style.transition = "opacity 0.4s ease, transform 0.4s ease";
42
+ resultCard.style.opacity = 1;
43
+ resultCard.style.transform = "translateY(0)";
44
+ }, 80);
45
+ }
46
+ });
47
+
48
+
49
+
static/style.css ADDED
@@ -0,0 +1,588 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =========================================
2
+ SaaS PRO THEME – DRAGONTECH STYLE
3
+ Works with sidebar layout + dark mode
4
+ ========================================= */
5
+
6
+ /* ---------- THEME TOKENS ---------- */
7
+
8
+ :root {
9
+ --bg: #f4f5fb;
10
+ --bg-alt: #ffffff;
11
+ --text: #111827;
12
+ --text-light: #6b7280;
13
+ --border: #e5e7eb;
14
+
15
+ --primary: #2563eb;
16
+ --primary-soft: #dbeafe;
17
+ --primary-hover: #1e40af;
18
+
19
+ --danger: #dc2626;
20
+ --danger-soft: #fee2e2;
21
+
22
+ --success: #16a34a;
23
+ --success-soft: #dcfce7;
24
+
25
+ --warning: #f97316;
26
+ --warning-soft: #ffedd5;
27
+
28
+ --card-bg: #ffffff;
29
+
30
+ --sidebar-bg: #020617;
31
+ --sidebar-text: #cbd5e1;
32
+ --sidebar-active: #111827;
33
+
34
+ --chip-red: #fee2e2;
35
+ --chip-red-text: #b91c1c;
36
+
37
+ --chip-green: #dcfce7;
38
+ --chip-green-text: #166534;
39
+
40
+ --chip-blue: #dbeafe;
41
+ --chip-blue-text: #1d4ed8;
42
+
43
+ --shadow-soft: 0 18px 40px rgba(15, 23, 42, 0.12);
44
+ }
45
+
46
+ html[data-theme="dark"] {
47
+ --bg: #020617;
48
+ --bg-alt: #020617;
49
+ --text: #e5e7eb;
50
+ --text-light: #9ca3af;
51
+ --border: #1f2933;
52
+
53
+ --card-bg: #020617;
54
+
55
+ --sidebar-bg: #020617;
56
+ --sidebar-text: #cbd5e1;
57
+ --sidebar-active: #0b1120;
58
+
59
+ --shadow-soft: 0 18px 40px rgba(0, 0, 0, 0.6);
60
+ }
61
+
62
+ /* ---------- RESET ---------- */
63
+
64
+ *,
65
+ *::before,
66
+ *::after {
67
+ box-sizing: border-box;
68
+ }
69
+
70
+ body {
71
+ margin: 0;
72
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Inter",
73
+ "Segoe UI", sans-serif;
74
+ background: radial-gradient(circle at top left, #e5edff 0, #f4f5fb 40%, #f9fafb 100%);
75
+ color: var(--text);
76
+ }
77
+
78
+ a {
79
+ color: var(--primary);
80
+ text-decoration: none;
81
+ }
82
+
83
+ a:hover {
84
+ text-decoration: underline;
85
+ }
86
+
87
+ /* =========================================
88
+ LAYOUT
89
+ ========================================= */
90
+
91
+ .layout {
92
+ display: flex;
93
+ min-height: 100vh;
94
+ }
95
+
96
+ /* ---------- SIDEBAR ---------- */
97
+
98
+ .sidebar {
99
+ width: 260px;
100
+ background: radial-gradient(circle at top, #0b1220 0, #020617 45%, #000 100%);
101
+ color: var(--sidebar-text);
102
+ padding: 22px 18px;
103
+ display: flex;
104
+ flex-direction: column;
105
+ gap: 10px;
106
+ border-right: 1px solid rgba(148, 163, 184, 0.2);
107
+ }
108
+
109
+ .sidebar-title {
110
+ font-size: 20px;
111
+ font-weight: 600;
112
+ margin: 0 0 16px;
113
+ color: #f9fafb;
114
+ }
115
+
116
+ .sidebar > p {
117
+ margin: 0 0 10px;
118
+ }
119
+
120
+ /* nav links */
121
+
122
+ .nav-link {
123
+ display: block;
124
+ padding: 9px 11px;
125
+ border-radius: 8px;
126
+ margin-bottom: 4px;
127
+ color: var(--sidebar-text);
128
+ font-size: 14px;
129
+ transition: background 0.18s ease, color 0.18s ease, transform 0.1s ease;
130
+ }
131
+
132
+ .nav-link.active {
133
+ background: linear-gradient(135deg, #2563eb, #38bdf8);
134
+ color: #f9fafb;
135
+ box-shadow: 0 10px 25px rgba(37, 99, 235, 0.45);
136
+ }
137
+
138
+ .nav-link:hover {
139
+ background: rgba(248, 250, 252, 0.09);
140
+ color: #e5e7eb;
141
+ transform: translateY(-1px);
142
+ }
143
+
144
+ .small-link {
145
+ font-size: 12px;
146
+ opacity: 0.9;
147
+ }
148
+
149
+ /* theme button */
150
+
151
+ .theme-btn {
152
+ margin-top: auto;
153
+ margin-bottom: 4px;
154
+ padding: 8px 10px;
155
+ border-radius: 999px;
156
+ border: 1px solid rgba(148, 163, 184, 0.35);
157
+ background: rgba(15, 23, 42, 0.9);
158
+ color: #e5e7eb;
159
+ cursor: pointer;
160
+ font-size: 13px;
161
+ display: inline-flex;
162
+ align-items: center;
163
+ gap: 6px;
164
+ justify-content: center;
165
+ }
166
+
167
+ .theme-btn:hover {
168
+ border-color: rgba(248, 250, 252, 0.6);
169
+ }
170
+
171
+ /* ---------- MAIN CONTENT ---------- */
172
+
173
+ .content {
174
+ flex: 1;
175
+ padding: 22px 30px;
176
+ display: flex;
177
+ flex-direction: column;
178
+ }
179
+
180
+ .topbar {
181
+ display: flex;
182
+ align-items: center;
183
+ justify-content: space-between;
184
+ margin-bottom: 14px;
185
+ }
186
+
187
+ .topbar h1 {
188
+ font-size: 22px;
189
+ font-weight: 600;
190
+ letter-spacing: -0.02em;
191
+ margin: 0;
192
+ color: var(--text);
193
+ }
194
+
195
+ .page-content {
196
+ max-width: 960px;
197
+ width: 100%;
198
+ padding-bottom: 40px;
199
+ }
200
+
201
+ /* =========================================
202
+ COOKIE / NOTICE
203
+ ========================================= */
204
+
205
+ .cookie-banner {
206
+ font-size: 11px;
207
+ padding: 7px 10px;
208
+ background: rgba(15, 23, 42, 0.92);
209
+ color: #e5e7eb;
210
+ border-radius: 8px;
211
+ margin-bottom: 12px;
212
+ }
213
+
214
+ /* =========================================
215
+ CARDS
216
+ ========================================= */
217
+
218
+ .card {
219
+ background: rgba(255, 255, 255, 0.98);
220
+ border-radius: 14px;
221
+ padding: 18px 18px 16px;
222
+ border: 1px solid rgba(148, 163, 184, 0.25);
223
+ box-shadow: var(--shadow-soft);
224
+ backdrop-filter: blur(24px);
225
+ margin-bottom: 18px;
226
+ }
227
+
228
+ html[data-theme="dark"] .card {
229
+ background: rgba(15, 23, 42, 0.98);
230
+ }
231
+
232
+ .subtitle {
233
+ font-size: 13px;
234
+ color: var(--text-light);
235
+ margin-top: 4px;
236
+ margin-bottom: 18px;
237
+ }
238
+
239
+ /* =========================================
240
+ FORMS & INPUTS
241
+ ========================================= */
242
+
243
+ label {
244
+ font-size: 13px;
245
+ font-weight: 500;
246
+ display: block;
247
+ margin-bottom: 4px;
248
+ }
249
+
250
+ textarea,
251
+ input[type="email"],
252
+ input[type="password"],
253
+ input[type="text"] {
254
+ width: 100%;
255
+ padding: 10px 11px;
256
+ border-radius: 10px;
257
+ border: 1px solid var(--border);
258
+ background: var(--bg-alt);
259
+ color: var(--text);
260
+ font-size: 14px;
261
+ transition: border 0.16s ease, box-shadow 0.16s ease, background 0.16s ease;
262
+ }
263
+
264
+ textarea {
265
+ min-height: 150px;
266
+ resize: vertical;
267
+ }
268
+
269
+ textarea:focus,
270
+ input:focus {
271
+ outline: none;
272
+ border-color: var(--primary);
273
+ box-shadow: 0 0 0 1px rgba(37, 99, 235, 0.25);
274
+ }
275
+
276
+ button {
277
+ background: linear-gradient(135deg, #2563eb, #1d4ed8);
278
+ color: white;
279
+ padding: 9px 14px;
280
+ border-radius: 999px;
281
+ border: none;
282
+ cursor: pointer;
283
+ font-size: 14px;
284
+ font-weight: 500;
285
+ display: inline-flex;
286
+ align-items: center;
287
+ gap: 7px;
288
+ transition: transform 0.1s ease, box-shadow 0.1s ease, opacity 0.1s ease;
289
+ }
290
+
291
+ button:hover {
292
+ transform: translateY(-1px);
293
+ box-shadow: 0 12px 25px rgba(37, 99, 235, 0.4);
294
+ }
295
+
296
+ button:active {
297
+ transform: translateY(0);
298
+ box-shadow: none;
299
+ }
300
+
301
+ .btn-secondary {
302
+ background: var(--bg-alt);
303
+ color: var(--text);
304
+ border: 1px solid var(--border);
305
+ box-shadow: none;
306
+ }
307
+
308
+ .btn-secondary:hover {
309
+ box-shadow: 0 8px 16px rgba(15, 23, 42, 0.12);
310
+ }
311
+
312
+ .btn-danger {
313
+ background: var(--danger);
314
+ box-shadow: 0 10px 25px rgba(220, 38, 38, 0.45);
315
+ }
316
+
317
+ /* =========================================
318
+ HISTORY FILTERS & ACTIONS
319
+ ========================================= */
320
+
321
+ .history-search {
322
+ margin-bottom: 10px;
323
+ }
324
+
325
+ .history-search input {
326
+ max-width: 260px;
327
+ }
328
+
329
+ .filter-buttons {
330
+ display: flex;
331
+ flex-wrap: wrap;
332
+ gap: 6px;
333
+ margin-bottom: 12px;
334
+ }
335
+
336
+ .filter-btn {
337
+ padding: 6px 10px;
338
+ border-radius: 999px;
339
+ font-size: 12px;
340
+ border: 1px solid var(--border);
341
+ background: var(--bg-alt);
342
+ color: var(--text-light);
343
+ text-decoration: none;
344
+ transition: background 0.16s ease, color 0.16s ease, border 0.16s ease;
345
+ }
346
+
347
+ .filter-btn.active {
348
+ background: var(--primary-soft);
349
+ color: var(--primary-hover);
350
+ border-color: var(--primary);
351
+ }
352
+
353
+ /* history actions row */
354
+
355
+ .history-actions {
356
+ display: flex;
357
+ align-items: center;
358
+ gap: 10px;
359
+ margin-bottom: 14px;
360
+ flex-wrap: wrap;
361
+ }
362
+
363
+ /* =========================================
364
+ TABLES
365
+ ========================================= */
366
+
367
+ .table-wrapper {
368
+ overflow-x: auto;
369
+ }
370
+
371
+ .table {
372
+ width: 100%;
373
+ border-collapse: collapse;
374
+ font-size: 13px;
375
+ }
376
+
377
+ .table th,
378
+ .table td {
379
+ text-align: left;
380
+ padding: 9px 8px;
381
+ border-bottom: 1px solid var(--border);
382
+ }
383
+
384
+ .table th {
385
+ font-weight: 600;
386
+ color: var(--text-light);
387
+ }
388
+
389
+ /* =========================================
390
+ CHIPS & FLAGS
391
+ ========================================= */
392
+
393
+ .chip {
394
+ padding: 4px 10px;
395
+ border-radius: 999px;
396
+ font-size: 11px;
397
+ font-weight: 600;
398
+ display: inline-block;
399
+ }
400
+
401
+ .chip.red {
402
+ background: var(--chip-red);
403
+ color: var(--chip-red-text);
404
+ }
405
+
406
+ .chip.green {
407
+ background: var(--chip-green);
408
+ color: var(--chip-green-text);
409
+ }
410
+
411
+ .chip.blue {
412
+ background: var(--chip-blue);
413
+ color: var(--chip-blue-text);
414
+ }
415
+
416
+ /* flags in result card */
417
+
418
+ .flag-section {
419
+ margin-top: 8px;
420
+ margin-bottom: 6px;
421
+ }
422
+
423
+ .flag {
424
+ display: inline-block;
425
+ padding: 3px 8px;
426
+ border-radius: 999px;
427
+ font-size: 11px;
428
+ font-weight: 500;
429
+ margin-right: 6px;
430
+ }
431
+
432
+ .flag.red {
433
+ background: var(--danger-soft);
434
+ color: var(--danger);
435
+ }
436
+
437
+ .flag.orange {
438
+ background: var(--warning-soft);
439
+ color: var(--warning);
440
+ }
441
+
442
+ /* =========================================
443
+ RESULT CARD & CONFIDENCE METER
444
+ ========================================= */
445
+
446
+ .result-card {
447
+ border-left: 4px solid var(--primary);
448
+ animation: fadeInUp 0.3s ease-out;
449
+ }
450
+
451
+ .result-card.aggressive {
452
+ border-left-color: var(--danger);
453
+ }
454
+
455
+ .result-card.polite {
456
+ border-left-color: var(--success);
457
+ }
458
+
459
+ .result-card.friendly {
460
+ border-left-color: var(--chip-blue-text);
461
+ }
462
+
463
+ .severity-bar {
464
+ width: 100%;
465
+ height: 7px;
466
+ border-radius: 999px;
467
+ background: var(--bg);
468
+ overflow: hidden;
469
+ margin: 10px 0 8px;
470
+ }
471
+
472
+ .severity-fill {
473
+ width: 0;
474
+ height: 100%;
475
+ border-radius: 999px;
476
+ background: linear-gradient(90deg, #22c55e, #eab308, #ef4444);
477
+ transition: width 0.6s ease-out;
478
+ }
479
+
480
+ /* you can set width inline from HTML: style="width: {{ result.severity }}%" */
481
+
482
+ .score-boxes {
483
+ display: flex;
484
+ flex-wrap: wrap;
485
+ gap: 8px;
486
+ margin-top: 4px;
487
+ font-size: 12px;
488
+ color: var(--text-light);
489
+ }
490
+
491
+ .score-item {
492
+ padding: 4px 8px;
493
+ border-radius: 999px;
494
+ background: var(--bg);
495
+ }
496
+
497
+ /* explanation list */
498
+
499
+ .explanation-list {
500
+ margin: 8px 0 0;
501
+ padding-left: 18px;
502
+ font-size: 13px;
503
+ color: var(--text-light);
504
+ }
505
+
506
+ /* empty states */
507
+
508
+ .empty {
509
+ font-size: 13px;
510
+ color: var(--text-light);
511
+ }
512
+
513
+ /* =========================================
514
+ ANIMATIONS
515
+ ========================================= */
516
+
517
+ @keyframes fadeInUp {
518
+ from {
519
+ opacity: 0;
520
+ transform: translateY(6px);
521
+ }
522
+ to {
523
+ opacity: 1;
524
+ transform: translateY(0);
525
+ }
526
+ }
527
+
528
+ /* =========================================
529
+ RESPONSIVE (MOBILE / TABLET)
530
+ ========================================= */
531
+
532
+ @media (max-width: 900px) {
533
+
534
+ .layout {
535
+ flex-direction: column;
536
+ }
537
+
538
+ .sidebar {
539
+ width: 100%;
540
+ flex-direction: row;
541
+ align-items: center;
542
+ gap: 10px;
543
+ overflow-x: auto;
544
+ padding: 10px 12px;
545
+ border-bottom: 1px solid rgba(148, 163, 184, 0.3);
546
+ }
547
+
548
+ .sidebar-title {
549
+ display: none;
550
+ }
551
+
552
+ .nav-link {
553
+ white-space: nowrap;
554
+ margin-bottom: 0;
555
+ }
556
+
557
+ .theme-btn {
558
+ margin-top: 0;
559
+ }
560
+
561
+ .content {
562
+ padding: 16px 14px 22px;
563
+ }
564
+
565
+ .page-content {
566
+ max-width: 100%;
567
+ }
568
+
569
+ textarea {
570
+ min-height: 120px;
571
+ }
572
+
573
+ .history-actions {
574
+ flex-direction: row;
575
+ align-items: stretch;
576
+ }
577
+ }
578
+
579
+
580
+
581
+
582
+
583
+
584
+
585
+
586
+
587
+
588
+
templates/base.html ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>{{ title or "Tone Classifier" }}</title>
6
+ <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
7
+ <script defer src="{{ url_for('static', filename='app.js') }}"></script>
8
+ </head>
9
+
10
+ <body>
11
+ <div class="layout">
12
+
13
+ <!-- SIDEBAR -->
14
+ <aside class="sidebar">
15
+ <h2 class="sidebar-title">Tone Classifier</h2>
16
+
17
+ {% if current_user.is_authenticated %}
18
+ <p style="font-size: 13px; margin-bottom: 16px; color: var(--text-light);">
19
+ Logged in as<br><strong>{{ current_user.email }}</strong>
20
+ </p>
21
+
22
+ <a href="{{ url_for('index') }}"
23
+ class="nav-link {% if title == 'Analyze Email' %}active{% endif %}">
24
+ Analyze
25
+ </a>
26
+
27
+ <a href="{{ url_for('history_view') }}"
28
+ class="nav-link {% if title == 'History' %}active{% endif %}">
29
+ History
30
+ </a>
31
+
32
+ <a href="{{ url_for('delete_account') }}"
33
+ class="nav-link">
34
+ Delete Account
35
+ </a>
36
+
37
+ <a href="{{ url_for('logout') }}" class="nav-link">
38
+ Logout
39
+ </a>
40
+ {% else %}
41
+ <a href="{{ url_for('login') }}"
42
+ class="nav-link {% if title == 'Login' %}active{% endif %}">
43
+ Login
44
+ </a>
45
+ <a href="{{ url_for('register') }}"
46
+ class="nav-link {% if title == 'Register' %}active{% endif %}">
47
+ Register
48
+ </a>
49
+ {% endif %}
50
+
51
+ <hr style="border: none; border-top: 1px solid rgba(255,255,255,0.1); margin: 16px 0;">
52
+
53
+ <a href="{{ url_for('privacy') }}" class="nav-link small-link">
54
+ Privacy Policy
55
+ </a>
56
+ <a href="{{ url_for('do_not_sell') }}" class="nav-link small-link">
57
+ Do Not Sell My Info
58
+ </a>
59
+
60
+ <button id="themeToggle" class="theme-btn">Toggle Theme</button>
61
+ </aside>
62
+
63
+ <!-- MAIN CONTENT -->
64
+ <div class="content">
65
+ <div class="topbar">
66
+ <h1>{{ title }}</h1>
67
+ </div>
68
+
69
+ <!-- Cookie / privacy notice -->
70
+ <div class="cookie-banner">
71
+ This service uses essential cookies for authentication and security only.
72
+ No tracking or advertising cookies are used.
73
+ </div>
74
+
75
+ <!-- Flash messages -->
76
+ <div class="page-content">
77
+ {% with messages = get_flashed_messages(with_categories=true) %}
78
+ {% if messages %}
79
+ <div style="margin-bottom: 12px;">
80
+ {% for category, message in messages %}
81
+ <div style="
82
+ padding: 8px 10px;
83
+ border-radius: 6px;
84
+ margin-bottom: 6px;
85
+ font-size: 14px;
86
+ {% if category == 'error' %}
87
+ background: #fee2e2;
88
+ color: #b91c1c;
89
+ {% else %}
90
+ background: #dcfce7;
91
+ color: #166534;
92
+ {% endif %}
93
+ ">
94
+ {{ message }}
95
+ </div>
96
+ {% endfor %}
97
+ </div>
98
+ {% endif %}
99
+ {% endwith %}
100
+
101
+ {% block content %}{% endblock %}
102
+ </div>
103
+ </div>
104
+ </div>
105
+ </body>
106
+ </html>
107
+
108
+
109
+
110
+
111
+
templates/delete_account.html ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block content %}
3
+
4
+ <div class="card">
5
+ <h2>Delete Account</h2>
6
+ <p class="subtitle">
7
+ This will permanently delete your account and all associated analysis history.
8
+ This action cannot be undone.
9
+ </p>
10
+
11
+ <form method="POST">
12
+ <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
13
+
14
+ <label>Confirm your password to continue</label>
15
+ <input type="password" name="password" required
16
+ style="width: 100%; padding: 8px; margin-bottom: 16px;">
17
+
18
+ <button type="submit" class="btn-danger"
19
+ onclick="return confirm('Are you sure you want to permanently delete your account and all associated data?');">
20
+ Permanently Delete My Account
21
+ </button>
22
+ </form>
23
+ </div>
24
+
25
+ {% endblock %}
templates/do_not_sell.html ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block content %}
3
+
4
+ <div class="card">
5
+ <h2>Do Not Sell My Personal Information</h2>
6
+ <p class="subtitle">CCPA / CPRA Notice</p>
7
+
8
+ <p>
9
+ This application does not sell your personal information.
10
+ We do not share your data with third parties for advertising,
11
+ profiling, or cross-context behavioral advertising.
12
+ </p>
13
+
14
+ <p>
15
+ The data we collect is used solely for:
16
+ </p>
17
+ <ul>
18
+ <li>Providing the tone classification service</li>
19
+ <li>Securing accounts and preventing abuse</li>
20
+ <li>Improving the accuracy and reliability of the tool</li>
21
+ </ul>
22
+
23
+ <p>
24
+ You can delete your account and all associated data at any time
25
+ from the "Delete Account" page. You may also export your data via
26
+ the History export features.
27
+ </p>
28
+ </div>
29
+
30
+ {% endblock %}
templates/forgot.html ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block content %}
3
+
4
+ <div class="card">
5
+ <h2>Forgot Password</h2>
6
+ <p class="subtitle">
7
+ Enter your account email. If it exists, we’ll send a 6-digit reset code.
8
+ </p>
9
+
10
+ <form method="POST">
11
+ <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
12
+
13
+ <label>Email</label>
14
+ <input type="email" name="email" required
15
+ style="width: 100%; padding: 8px; margin-bottom: 16px;">
16
+
17
+ <button type="submit">Send Reset Code</button>
18
+ </form>
19
+
20
+ <p class="subtitle" style="margin-top: 12px;">
21
+ Remembered your password?
22
+ <a href="{{ url_for('login') }}">Back to login</a>.
23
+ </p>
24
+ </div>
25
+
26
+ {% endblock %}
templates/history.html ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block content %}
3
+
4
+ <div class="card">
5
+ <h2>History</h2>
6
+ <p class="subtitle">
7
+ View and export your past tone analyses. Data is private to your account.
8
+ </p>
9
+
10
+ <!-- Search -->
11
+ <div class="history-search">
12
+ <form method="GET">
13
+ <input type="text" name="q" value="{{ search or '' }}"
14
+ placeholder="Search text..."
15
+ style="max-width: 260px; padding: 8px;">
16
+ <button type="submit" class="btn-secondary">Search</button>
17
+ </form>
18
+ </div>
19
+
20
+ <!-- Filter buttons -->
21
+ <div class="filter-buttons">
22
+ <a href="{{ url_for('history_view') }}"
23
+ class="filter-btn {% if not active_filter %}active{% endif %}">
24
+ All
25
+ </a>
26
+ <a href="{{ url_for('history_view', label='Aggressive', q=search) }}"
27
+ class="filter-btn {% if active_filter|lower == 'aggressive' %}active{% endif %}">
28
+ Aggressive
29
+ </a>
30
+ <a href="{{ url_for('history_view', label='Neutral', q=search) }}"
31
+ class="filter-btn {% if active_filter|lower == 'neutral' %}active{% endif %}">
32
+ Neutral
33
+ </a>
34
+ <a href="{{ url_for('history_view', label='Polite', q=search) }}"
35
+ class="filter-btn {% if active_filter|lower == 'polite' %}active{% endif %}">
36
+ Polite
37
+ </a>
38
+ <a href="{{ url_for('history_view', label='Friendly', q=search) }}"
39
+ class="filter-btn {% if active_filter|lower == 'friendly' %}active{% endif %}">
40
+ Friendly
41
+ </a>
42
+ </div>
43
+
44
+ <!-- Actions -->
45
+ <div class="history-actions">
46
+ <a href="{{ url_for('export_csv') }}">
47
+ <button type="button" class="btn-secondary">Export CSV</button>
48
+ </a>
49
+ <a href="{{ url_for('export_pdf') }}">
50
+ <button type="button" class="btn-secondary">Export PDF</button>
51
+ </a>
52
+
53
+ <form method="POST" action="{{ url_for('clear_history') }}"
54
+ onsubmit="return confirm('Clear all history for your account?');">
55
+ <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
56
+ <button type="submit" class="btn-danger">Clear History</button>
57
+ </form>
58
+ </div>
59
+
60
+ <!-- Table -->
61
+ {% if history %}
62
+ <div class="table-wrapper">
63
+ <table class="table">
64
+ <thead>
65
+ <tr>
66
+ <th>Time (UTC)</th>
67
+ <th>Label</th>
68
+ <th>Severity</th>
69
+ <th>Threat</th>
70
+ <th>Polite</th>
71
+ <th>Friendly</th>
72
+ <th>Text</th>
73
+ </tr>
74
+ </thead>
75
+ <tbody>
76
+ {% for e in history %}
77
+ <tr>
78
+ <td>{{ e.created_at.isoformat() }}</td>
79
+ <td>{{ e.label }}</td>
80
+ <td>{{ e.severity }}</td>
81
+ <td>{{ e.threat_score }}</td>
82
+ <td>{{ e.politeness_score }}</td>
83
+ <td>{{ e.friendly_score }}</td>
84
+ <td>{{ e.text[:120] }}{% if e.text|length > 120 %}...{% endif %}</td>
85
+ </tr>
86
+ {% endfor %}
87
+ </tbody>
88
+ </table>
89
+ </div>
90
+ {% else %}
91
+ <p class="empty">No entries yet. Analyze some text to see your history here.</p>
92
+ {% endif %}
93
+ </div>
94
+
95
+ {% endblock %}
96
+
97
+
98
+
templates/index.html ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block content %}
3
+
4
+ <div class="card">
5
+ <h2>Enter Email or Message</h2>
6
+ <p class="subtitle">The system detects Aggressive, Neutral, Polite, and Friendly tone.</p>
7
+
8
+ <form method="POST">
9
+ <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
10
+ <textarea name="email_text" placeholder="Type or paste text here..." required>{{ email_text or "" }}</textarea>
11
+ <button type="submit">Analyze Tone</button>
12
+ </form>
13
+ </div>
14
+
15
+ {% if result %}
16
+ <div class="card result-card {{ result.label|lower }}">
17
+ <h2>Prediction: {{ result.label }}</h2>
18
+
19
+ <div class="severity-bar">
20
+ <div class="severity-fill" data-width="{{ result.severity }}"></div>
21
+ </div>
22
+
23
+ <div class="score-boxes">
24
+ <div class="score-item">Confidence: {{ result.confidence }}%</div>
25
+ <div class="score-item">Threat: {{ result.threat_score }}</div>
26
+ <div class="score-item">Politeness: {{ result.politeness_score }}</div>
27
+ <div class="score-item">Friendly: {{ result.friendly_score }}</div>
28
+ </div>
29
+
30
+ <div class="flag-section">
31
+ {% if result.has_threat %}
32
+ <span class="flag red">Threat</span>
33
+ {% endif %}
34
+ {% if result.has_profanity %}
35
+ <span class="flag red">Profanity</span>
36
+ {% endif %}
37
+ {% if result.has_sarcasm %}
38
+ <span class="flag orange">Sarcasm</span>
39
+ {% endif %}
40
+ </div>
41
+
42
+ <h3>Why this label?</h3>
43
+ <ul class="explanation-list">
44
+ {% for line in result.explanation %}
45
+ <li>{{ line }}</li>
46
+ {% endfor %}
47
+ </ul>
48
+ </div>
49
+ {% endif %}
50
+
51
+ {% endblock %}
52
+
53
+
54
+
55
+
56
+
57
+
58
+
59
+
templates/login.html ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block content %}
3
+
4
+ <div class="card">
5
+ {% if mode == "register" %}
6
+ <h2>Create an Account</h2>
7
+ <p class="subtitle">
8
+ Register to save your tone analysis history and access exports.
9
+ </p>
10
+ {% else %}
11
+ <h2>Login</h2>
12
+ <p class="subtitle">
13
+ Log in to access your saved analyses and exports.
14
+ </p>
15
+ {% endif %}
16
+
17
+ <form method="POST">
18
+ <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
19
+
20
+ <label>Email</label>
21
+ <input type="email" name="email" required
22
+ style="width: 100%; padding: 8px; margin-bottom: 12px;">
23
+
24
+ <label>Password</label>
25
+ <input type="password" name="password" required
26
+ style="width: 100%; padding: 8px; margin-bottom: 16px;">
27
+
28
+ {% if mode == "register" %}
29
+ <label style="display:flex; align-items:center; gap:6px; font-size:13px; margin-bottom: 16px;">
30
+ <input type="checkbox" name="consent_privacy" required>
31
+ <span>
32
+ I agree to the
33
+ <a href="{{ url_for('privacy') }}">Privacy Policy</a>
34
+ and understand how my data is used.
35
+ </span>
36
+ </label>
37
+ {% endif %}
38
+
39
+ <button type="submit">
40
+ {% if mode == "register" %}Create Account{% else %}Login{% endif %}
41
+ </button>
42
+ </form>
43
+
44
+ <p class="subtitle" style="margin-top: 12px;">
45
+ {% if mode == "register" %}
46
+ Already have an account?
47
+ <a href="{{ url_for('login') }}">Log in here</a>.
48
+ {% else %}
49
+ Need an account?
50
+ <a href="{{ url_for('register') }}">Register here</a>.<br>
51
+ <a href="{{ url_for('forgot_password') }}">Forgot your password?</a>
52
+ {% endif %}
53
+ </p>
54
+ </div>
55
+
56
+ {% endblock %}
templates/privacy.html ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block content %}
3
+
4
+ <div class="card">
5
+ <h2>Privacy Policy</h2>
6
+ <p class="subtitle">Last updated: {{ datetime.utcnow().date() if datetime else "2025" }}</p>
7
+
8
+ <p>
9
+ This application analyzes text to classify the tone of messages
10
+ (e.g., Aggressive, Neutral, Polite, Friendly). To provide this service,
11
+ we process the following personal data:
12
+ </p>
13
+
14
+ <ul>
15
+ <li>Your email address (for login, verification, and account management)</li>
16
+ <li>A hashed version of your password</li>
17
+ <li>Text you submit for tone analysis</li>
18
+ <li>Metadata such as timestamps of your usage</li>
19
+ </ul>
20
+
21
+ <h3>Legal Basis (GDPR)</h3>
22
+ <p>
23
+ Our legal basis for processing personal data is your consent and
24
+ our legitimate interest in providing and improving the service.
25
+ You can withdraw your consent at any time by deleting your account.
26
+ </p>
27
+
28
+ <h3>Data Storage & Retention</h3>
29
+ <p>
30
+ Your data is stored on infrastructure operated by Hugging Face
31
+ and associated hosting providers. We retain account data for up to
32
+ 12 months of inactivity, after which your account and data may be
33
+ automatically deleted. You may also delete your account at any time
34
+ using the "Delete Account" option in the application.
35
+ </p>
36
+
37
+ <h3>Right of Access & Portability</h3>
38
+ <p>
39
+ You can export your analysis history via the CSV and PDF export
40
+ features on the History page. This satisfies your right of access
41
+ and data portability.
42
+ </p>
43
+
44
+ <h3>Right to Deletion ("Right to be Forgotten")</h3>
45
+ <p>
46
+ You can permanently delete your account and all associated analysis
47
+ data at any time from the "Delete Account" page. This action cannot
48
+ be undone.
49
+ </p>
50
+
51
+ <h3>Cookies</h3>
52
+ <p>
53
+ This service uses only essential cookies required for authentication,
54
+ session management, and security (CSRF protection). No advertising
55
+ or tracking cookies are used.
56
+ </p>
57
+
58
+ <h3>Data Sharing & "Do Not Sell My Personal Information"</h3>
59
+ <p>
60
+ We do not sell your personal data. We do not share your data with
61
+ third parties for advertising purposes. For more details, see the
62
+ "Do Not Sell My Info" page.
63
+ </p>
64
+
65
+ <h3>Contact</h3>
66
+ <p>
67
+ If you have questions about how your data is processed or wish
68
+ to exercise your rights under GDPR or CCPA, you can contact the
69
+ project owner directly.
70
+ </p>
71
+ </div>
72
+
73
+ {% endblock %}
templates/reset_password.html ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block content %}
3
+
4
+ <div class="card">
5
+ <h2>Reset Password</h2>
6
+ <p class="subtitle">
7
+ Check your email for the 6-digit reset code, then set a new password.
8
+ </p>
9
+
10
+ <form method="POST">
11
+ <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
12
+
13
+ <label>Email</label>
14
+ <input type="email" name="email" required
15
+ style="width: 100%; padding: 8px; margin-bottom: 12px;">
16
+
17
+ <label>Reset Code</label>
18
+ <input type="text" name="code" maxlength="6" required
19
+ style="width: 100%; padding: 8px; margin-bottom: 12px;">
20
+
21
+ <label>New Password</label>
22
+ <input type="password" name="password" required
23
+ style="width: 100%; padding: 8px; margin-bottom: 12px;">
24
+
25
+ <label>Confirm New Password</label>
26
+ <input type="password" name="confirm_password" required
27
+ style="width: 100%; padding: 8px; margin-bottom: 16px;">
28
+
29
+ <button type="submit">Reset Password</button>
30
+ </form>
31
+
32
+ <p class="subtitle" style="margin-top: 12px;">
33
+ Already reset it?
34
+ <a href="{{ url_for('login') }}">Back to login</a>.
35
+ </p>
36
+ </div>
37
+
38
+ {% endblock %}
templates/verify.html ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block content %}
3
+
4
+ <div class="card">
5
+ <h2>Verify Your Email</h2>
6
+ <p class="subtitle">
7
+ We sent a 6-digit verification code to <strong>{{ email }}</strong>.
8
+ Enter it below to activate your account.
9
+ </p>
10
+
11
+ <form method="POST" style="margin-bottom: 12px;">
12
+ <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
13
+ <input type="hidden" name="action" value="verify">
14
+
15
+ <label>Verification Code</label>
16
+ <input type="text" name="code" maxlength="6" required
17
+ style="width: 100%; padding: 8px; margin-bottom: 16px;">
18
+
19
+ <button type="submit">Verify Email</button>
20
+ </form>
21
+
22
+ <form method="POST">
23
+ <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
24
+ <input type="hidden" name="action" value="resend">
25
+ <button type="submit" class="btn-secondary">Resend Code</button>
26
+ </form>
27
+ </div>
28
+
29
+ {% endblock %}