FluxFinance commited on
Commit
f46538c
Β·
verified Β·
1 Parent(s): e282bd2

Upload Advisernote.py

Browse files
Files changed (1) hide show
  1. Advisernote.py +413 -293
Advisernote.py CHANGED
@@ -3,14 +3,14 @@ import pandas as pd
3
  import io
4
  import os
5
  import json
6
- import sys
7
- import warnings
8
- import platform
9
- import imaplib
10
- import email
11
- import html
12
- import socket
13
- from imaplib import IMAP4_SSL
14
  from email.header import decode_header, make_header
15
  from datetime import datetime
16
  from docx import Document
@@ -18,49 +18,49 @@ from docx.shared import Pt, RGBColor, Inches
18
  from docx.enum.text import WD_ALIGN_PARAGRAPH
19
  from docx.oxml.ns import qn
20
  from docx.oxml import OxmlElement
21
- import pdfplumber
22
- import vertexai
23
- import requests
24
  import copy
25
  import re
26
- import sqlite3
27
-
28
- def get_config_value(key, default=""):
29
- value = os.environ.get(key)
30
- if value is not None:
31
- return value
32
- try:
33
- return st.secrets.get(key, default)
34
- except Exception:
35
- return default
36
-
37
- def parse_allowed_users(raw_users):
38
- if hasattr(raw_users, "items"):
39
- return {str(email).strip().lower(): str(password) for email, password in raw_users.items()}
40
-
41
- users = {}
42
- for item in str(raw_users or "").split(","):
43
- email_addr, separator, password = item.partition("=")
44
- email_addr = email_addr.strip().strip('"').strip("'").lower()
45
- if separator and email_addr:
46
- users[email_addr] = password.strip().strip('"').strip("'")
47
- return users
48
-
49
- # 1. 인증 및 Secrets 톡합 λ‘œλ“œ
50
- os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = "credentials.json"
51
-
52
- # 2. λͺ¨λ“  ν•„μˆ˜ λ³€μˆ˜ ν•œ λ²ˆμ— μ„ μ–Έ (λ³€μˆ˜λͺ… 일치 확인)
53
- COMMON_PW = get_config_value("EMAIL_PASSWORD")
54
- MASTER_PW = get_config_value("MASTER_PASSWORD")
55
- ADMIN_EMAIL = get_config_value("ADMIN_EMAIL").strip().lower()
56
- PROJECT_ID = get_config_value("PROJECT_ID")
57
- NOTION_TOKEN = get_config_value("NOTION_API_TOKEN")
58
- NOTION_DB_ID = get_config_value("NOTION_DATABASE_ID")
59
-
60
- # 3. 리슀트/λ”•μ…”λ„ˆλ¦¬ λ³€ν™˜ 둜직
61
- ALL_MAILBOXES = [item.strip() for item in get_config_value("ALL_MAILBOXES").split(",") if item.strip()]
62
- allowed_users = parse_allowed_users(get_config_value("ALLOWED_USERS"))
63
- MAX_EMAILS_PER_FOLDER = 250
64
 
65
  def save_rules_to_db(df):
66
  conn = sqlite3.connect("data.db")
@@ -125,78 +125,143 @@ if 'client_names' not in st.session_state: st.session_state['client_names'] = ""
125
  if 'support_name' not in st.session_state: st.session_state['support_name'] = ""
126
  if 'refs' not in st.session_state: st.session_state['refs'] = ""
127
  if 'folder_path' not in st.session_state: st.session_state['folder_path'] = ""
128
- if 'doc_context' not in st.session_state: st.session_state['doc_context'] = ""
129
- if 'results' not in st.session_state: st.session_state['results'] = None
130
- if 'notion_client_name' not in st.session_state: st.session_state['notion_client_name'] = ""
131
- if 'case_comments' not in st.session_state: st.session_state['case_comments'] = ""
132
 
133
  # =========================================================================
134
- # [λ³΄μ•ˆ λνŒμ™•] 졜초 둜그인 λΉ„λ²ˆ μ„ΈνŒ… & κ΄€λ¦¬μž μ‹€μ‹œκ°„ 동기화/μ‚­μ œ ν†΅μ œ μ‹œμŠ€ν…œ
135
- # =========================================================================
136
- USER_DB_FILE = "user_passwords.db"
137
-
138
- def init_user_db(seed_users):
139
- conn = sqlite3.connect(USER_DB_FILE)
140
- conn.execute("""
141
- CREATE TABLE IF NOT EXISTS users (
142
- email TEXT PRIMARY KEY,
143
- password TEXT NOT NULL DEFAULT '',
144
- active INTEGER NOT NULL DEFAULT 1
145
- )
146
- """)
147
- for email_addr, password in seed_users.items():
148
- conn.execute(
149
- "INSERT OR IGNORE INTO users (email, password, active) VALUES (?, ?, 1)",
150
- (email_addr, password)
151
- )
152
- conn.commit()
153
- conn.close()
154
-
155
- def load_active_users():
156
- conn = sqlite3.connect(USER_DB_FILE)
157
- rows = conn.execute("SELECT email, password FROM users WHERE active = 1 ORDER BY email").fetchall()
158
- conn.close()
159
- return {email_addr: password for email_addr, password in rows}
160
-
161
- def update_user_password(email_addr, new_password):
162
- conn = sqlite3.connect(USER_DB_FILE)
163
- conn.execute(
164
- "UPDATE users SET password = ?, active = 1 WHERE email = ?",
165
- (new_password, email_addr)
166
- )
167
- conn.commit()
168
- updated = conn.total_changes > 0
169
- conn.close()
170
- return updated
171
-
172
- def add_or_restore_user(email_addr, initial_password=""):
173
- email_addr = email_addr.strip().lower()
174
- conn = sqlite3.connect(USER_DB_FILE)
175
- existing = conn.execute("SELECT email FROM users WHERE email = ?", (email_addr,)).fetchone()
176
- if existing:
177
- conn.execute(
178
- "UPDATE users SET password = ?, active = 1 WHERE email = ?",
179
- (initial_password, email_addr)
180
- )
181
- else:
182
- conn.execute(
183
- "INSERT INTO users (email, password, active) VALUES (?, ?, 1)",
184
- (email_addr, initial_password)
185
- )
186
- conn.commit()
187
- updated = conn.total_changes > 0
188
- conn.close()
189
- return updated
190
-
191
- def revoke_user_access(email_addr):
192
- conn = sqlite3.connect(USER_DB_FILE)
193
- conn.execute("UPDATE users SET active = 0 WHERE email = ?", (email_addr,))
194
- conn.commit()
195
- updated = conn.total_changes > 0
196
- conn.close()
197
- return updated
198
-
199
- init_user_db(allowed_users)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
 
201
  def check_password():
202
  """둜그인 검증, 졜초 μ ‘μ†μž λΉ„λ²ˆ μ„ΈνŒ…, κ΄€λ¦¬μž μ „μš© λŒ€μ‹œλ³΄λ“œ μŠ€μœ„μΉ­ λ§ˆμŠ€ν„° ν•¨μˆ˜"""
@@ -223,22 +288,22 @@ def check_password():
223
  </div>
224
  """, unsafe_allow_html=True)
225
 
226
- allowed_users = load_active_users()
227
-
228
- input_email = st.text_input("πŸ“§ Login Email Address", key="login_email", placeholder="username@fluxfinance.co.nz").strip().lower()
229
-
230
- if input_email and (input_email != ADMIN_EMAIL and input_email not in allowed_users):
231
- st.error("❌ Access Denied: This account is unregistered or has been deactivated. Please contact the administrator.")
232
- return False
233
-
234
- # Case A: 졜고 κ΄€λ¦¬μž(λ§€λ‹ˆμ €λ‹˜) 접속 μ‹œ (κΈ°λŠ₯ 100% 동일)
235
- if input_email == ADMIN_EMAIL:
236
- input_password = st.text_input("πŸ‘‘ Admin Password", type="password", key="login_password", placeholder="Enter admin password")
237
- if st.button("Sign In as Manager", use_container_width=True):
238
- if input_password == MASTER_PW:
239
- st.session_state["password_correct"] = True
240
- st.session_state["is_admin_mode"] = True
241
- st.rerun()
242
  else:
243
  st.error("❌ Incorrect Administrator password.")
244
  return False
@@ -251,17 +316,17 @@ def check_password():
251
  if current_db_pwd == "":
252
  st.warning("πŸ†• Welcome! Please set up your personalized password for this account.")
253
  new_pwd = st.text_input("πŸ”‘ Create New Password", type="password", key="setup_pwd")
254
- confirm_pwd = st.text_input("πŸ”„ Confirm New Password", type="password", key="confirm_pwd")
255
-
256
- if st.button("Activate My Account", use_container_width=True):
257
- if new_pwd and new_pwd == confirm_pwd:
258
- if update_user_password(input_email, new_pwd):
259
- st.success("βœ… Password configured successfully! Please sign in again with your new password.")
260
- st.rerun()
261
- else:
262
- st.error("⚠️ System Error: Failed to update your password. Please contact the administrator.")
263
- else:
264
- st.error("❌ Passwords do not match or fields are left blank.")
265
  return False
266
 
267
  # 상황 β‘‘: 이미 λΉ„λ²ˆ μ„ΈνŒ…μ΄ λλ‚˜μ„œ 정상 λ‘œκ·ΈμΈμ„ μ‹œλ„ν•  λ•Œ -> 둜그인 둜직 μ™„λ²½ 보쑴
@@ -284,16 +349,16 @@ def check_password():
284
  st.write("Verify your current password to update your credentials.")
285
  verify_old = st.text_input("Current Password", type="password", key="v_old")
286
  update_new = st.text_input("New Password", type="password", key="u_new")
287
-
288
- if st.button("Update Password", use_container_width=True):
289
- if verify_old == current_db_pwd and update_new:
290
- if update_user_password(input_email, update_new):
291
- st.toast("βœ… Password updated successfully!", icon="πŸš€")
292
- st.rerun()
293
- else:
294
- st.error("⚠️ System Error: Failed to update your password. Please contact the administrator.")
295
- else:
296
- st.error("❌ Current password verification failed or input is missing.")
297
  return False
298
  # =========================================================================
299
  # [NOTION API CONNECTOR] λ…Έμ…˜ νŽ˜μ΄μ§€ λ³Έλ¬Έ 및 λͺ¨λ“  μ‹€μ‹œκ°„ λŒ“κΈ€ 병합 μˆ˜μ§‘κΈ°
@@ -301,9 +366,9 @@ def check_password():
301
  # =========================
302
  # 1. λŒ“κΈ€ κ°€μ Έμ˜€κΈ°
303
  # =========================
304
- def fetch_notion_comments(page_id):
305
- """λ…Έμ…˜ νŠΉμ • νŽ˜μ΄μ§€μ˜ λŒ“κΈ€κ³Ό μž‘μ„± λ‚ μ§œλ₯Ό ν•¨κ»˜ μΆ”μΆœν•˜λŠ” ν•¨μˆ˜"""
306
- notion_token = NOTION_TOKEN
307
  headers = {
308
  "Authorization": f"Bearer {notion_token}",
309
  "Notion-Version": "2025-09-03"
@@ -318,12 +383,12 @@ def fetch_notion_comments(page_id):
318
  # 🟒 [μˆ˜μ •] μž‘μ„± λ‚ μ§œ(created_time)λ₯Ό κ°€μ Έμ˜΅λ‹ˆλ‹€.
319
  created_at = c.get("created_time", "").split("T")[0] # YYYY-MM-DD ν˜•μ‹λ§Œ μΆ”μΆœ
320
 
321
- c_text_list = c.get("rich_text", [])
322
- c_text = "".join(
323
- item.get("plain_text", item.get("text", {}).get("content", ""))
324
- for item in c_text_list
325
- if isinstance(item, dict)
326
- ).strip()
327
 
328
  if c_text:
329
  # 🟒 [μˆ˜μ •] [λ‚ μ§œ] μ½”λ©˜νŠΈ λ‚΄μš© ν˜•μ‹μœΌλ‘œ μ‘°ν•©
@@ -333,10 +398,10 @@ def fetch_notion_comments(page_id):
333
  pass
334
  return ""
335
 
336
- def fetch_client_tasks_from_notion(client_name):
337
- # .streamlit/secrets.toml νŒŒμΌμ—μ„œ λ³΄μ•ˆ 정보λ₯Ό μ•ˆμ „ν•˜κ²Œ λ‘œλ“œν•©λ‹ˆλ‹€.
338
- notion_token = NOTION_TOKEN
339
- target_data_source_id = NOTION_DB_ID
340
 
341
  if not notion_token:
342
  return [], "Missing NOTION_API_TOKEN in secrets.toml"
@@ -495,8 +560,8 @@ if check_password():
495
  st.markdown("### πŸ‘‘ Admin Control")
496
  st.caption("Monitor real-time password setups for active team members and revoke access for offboarded employees.")
497
 
498
- current_users = load_active_users()
499
- admin_data = []
500
 
501
  for u_email, u_pwd in current_users.items():
502
  status_tag = "πŸ”΄ Pending Setup" if u_pwd == "" else "🟒 Active (Password Set)"
@@ -515,42 +580,78 @@ if check_password():
515
  "Status": status_tag
516
  })
517
 
518
- st.table(pd.DataFrame(admin_data))
519
-
520
- st.markdown("**Add or Restore User Access**")
521
- add_col_email, add_col_password = st.columns([2, 1])
522
- new_user_email = add_col_email.text_input(
523
- "Employee email address",
524
- key="admin_add_user_email",
525
- placeholder="new.user@fluxfinance.co.nz"
526
- ).strip().lower()
527
- new_user_password = add_col_password.text_input(
528
- "Initial password",
529
- type="password",
530
- key="admin_add_user_password",
531
- help="Leave blank to let the user set their password on first login."
532
- )
533
-
534
- if st.button("βž• Add / Restore User", use_container_width=True):
535
- if not new_user_email:
536
- st.error("Please enter an email address.")
537
- elif not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", new_user_email):
538
- st.error("Please enter a valid email address.")
539
- elif add_or_restore_user(new_user_email, new_user_password):
540
- st.success(f"βœ… Access granted for {new_user_email}.")
541
- st.rerun()
542
- else:
543
- st.error("⚠️ System Error: Failed to update user access.")
544
-
545
- st.markdown("**Deactivate & Revoke User Permissions**")
546
- target_del = st.selectbox("Select email address to revoke access", ["-"] + list(current_users.keys()))
547
-
548
- if target_del != "-" and st.button("🚨 Revoke Access"):
549
- if target_del in current_users and revoke_user_access(target_del):
550
- st.success(f"⚠️ Access permanently revoked for {target_del}. This user can no longer sign in.")
551
- st.rerun()
552
-
553
- # (2) κ·œμΉ™ νŽΈμ§‘ 관리 (Rules Configuration - Admin Control λ°”λ‘œ λ°‘μœΌλ‘œ 배치)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
554
  with st.expander("βš™οΈ Rules Configuration", expanded=False):
555
  st.caption("πŸ“ μ›Ή ν™”λ©΄μ—μ„œ μˆ˜μ •Β·μΆ”κ°€ν•˜λ©΄ μ‹œμŠ€ν…œ νŒŒμΌμ— 영ꡬ μ €μž₯λ˜μ–΄ μ—°λ™λ©λ‹ˆλ‹€.")
556
  edited_rules = st.data_editor(st.session_state['rules_df'], num_rows="dynamic", use_container_width=True, hide_index=True, key="main_rules_editor")
@@ -1116,19 +1217,19 @@ if check_password():
1116
  status.update(label="βœ… Notion Tasks Synced.", state="complete")
1117
  st.rerun()
1118
 
1119
- elif tasks_data is not None and not tasks_data:
1120
- status.update(label="⚠️ λ§€μΉ­ 데이터 μ—†μŒ", state="error")
1121
- else:
1122
- status.update(label=f"❌ Failed: {msg}", state="error")
1123
-
1124
- case_comments = st.session_state.get('case_comments', '').strip()
1125
- if case_comments:
1126
- html_case_comments = html.escape(case_comments).replace('\n', '<br>')
1127
- st.markdown(f"""
1128
- <div style='background-color: #F0FDF4; padding: 12px 15px; border-radius: 8px; border: 1px solid #BBF7D0; font-size: 0.85rem; margin-top: 12px; color: #166534; line-height: 1.6;'>
1129
- <b>πŸ’¬ Case Comments:</b><br><br>{html_case_comments}
1130
- </div>
1131
- """, unsafe_allow_html=True)
1132
  # TAB 2: 이메일 검색창
1133
  with tab_email:
1134
  with st.container(border=True):
@@ -1136,16 +1237,15 @@ if check_password():
1136
  st.session_state['folder_path'] = st.text_input("πŸ“‚ G: Drive Folder Path", value=st.session_state['folder_path'], key="fixed_mail_path")
1137
 
1138
  # 🎯 [μΆ”κ°€] secretsμ—μ„œ μ£Όμ†Œλ§Œ 쏙 λ°œλΌλ‚΄μ–΄ λ©€ν‹° μ…€λ ‰νŠΈ λ°•μŠ€ 생성
1139
- raw_mailbox_list = ALL_MAILBOXES
1140
- mailbox_options = []
1141
- mailbox_map = {}
1142
- for entry in raw_mailbox_list:
1143
- entry_clean = entry.strip()
1144
- if not entry_clean: continue
1145
- cred_part = entry_clean.split("|", 1)[0].strip()
1146
- clean_email = cred_part.split(":", 1)[0].strip()
1147
- mailbox_options.append(clean_email)
1148
- mailbox_map[clean_email] = entry_clean
1149
 
1150
  selected_boxes = st.multiselect("πŸ“¬ Select Mailboxes to Scan", options=mailbox_options, default=[], key="active_mailboxes_select")
1151
 
@@ -1178,16 +1278,16 @@ if check_password():
1178
  </div>
1179
  </div>
1180
  </div>
1181
- """, unsafe_allow_html=True)
1182
-
1183
- if email_sync_btn:
1184
- st.session_state['active_mode'] = "EMAIL"
1185
- if not selected_boxes:
1186
- st.warning("Please select at least one mailbox to scan.")
1187
- st.stop()
1188
- # 🟒 [여기에 μΆ”κ°€] 이메일 이름을 μ „μ—­ λ³€μˆ˜λ‘œ ν™•μ • 박아버림! (μ—¬λŸ¬ λͺ…이면 &둜 μΉ˜ν™˜)
1189
- raw_names = st.session_state.get('client_names', '').strip()
1190
- st.session_state['global_client_name'] = raw_names.replace(';', ' & ') if raw_names else ''
1191
 
1192
  with st.status("πŸ” Syncing...", expanded=True) as status:
1193
  progress_text = st.empty()
@@ -1206,12 +1306,12 @@ if check_password():
1206
  # 🎯 [ꡐ체 μ™„λ£Œ] 전체λ₯Ό λ‹€ λ„λŠ” λŒ€μ‹ , λ©€ν‹° μ…€λ ‰νŠΈμ—μ„œ μ„ νƒλœ μ‚¬μ„œν•¨λ“€λ§Œ μΆ”μΆœ
1207
  target_mailbox_entries = [mailbox_map[m] for m in selected_boxes if m in mailbox_map]
1208
 
1209
- if target_mailbox_entries:
1210
- try:
1211
- import email.utils
1212
- socket.setdefaulttimeout(20)
1213
-
1214
- for entry in target_mailbox_entries:
1215
  entry_clean = entry.strip()
1216
  if not entry_clean: continue
1217
 
@@ -1230,11 +1330,31 @@ if check_password():
1230
  my_mail_addr = credential_part.strip()
1231
  target_pw = COMMON_PW
1232
 
1233
- status.write(f"πŸ” Scanning Mailbox: '{my_mail_addr}'...")
1234
-
1235
- try:
1236
- mail = IMAP4_SSL(imap_host, 993)
1237
- mail.login(my_mail_addr, target_pw)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1238
 
1239
  res_list, folder_list = mail.list()
1240
  all_target_folders = []
@@ -1251,32 +1371,32 @@ if check_password():
1251
  else:
1252
  raw_folder_name = f_str.split()[-1].strip().strip('"')
1253
 
1254
- if any(k in raw_folder_name.lower() for k in ["junk", "trash", "deleted", "sync", "spam"]):
1255
- continue
1256
- if raw_folder_name:
1257
- all_target_folders.append(raw_folder_name)
1258
-
1259
- if not all_target_folders:
1260
- all_target_folders = ["INBOX", "Sent"]
1261
- status.write(f"πŸ“‚ Found {len(all_target_folders)} folder(s). Scanning latest {MAX_EMAILS_PER_FOLDER} messages per folder.")
1262
-
1263
- for folder in all_target_folders:
1264
- try:
1265
- mail.select(f'"{folder}"', readonly=True)
1266
- res, msg_ids = mail.search(None, "ALL")
1267
- matched_ids = msg_ids[0].split()[-MAX_EMAILS_PER_FOLDER:] if res == "OK" and msg_ids and msg_ids[0] else []
1268
-
1269
- if not matched_ids:
1270
- continue
1271
- status.write(f"πŸ“ {folder}: checking latest {len(matched_ids)} message(s)")
1272
-
1273
- # νšλ“ν•œ 메일 번호 기반 λ³Έλ¬Έ μΆ”μΆœ μ—”μ§„ μ •μœ„μΉ˜ μ•ˆμ°©
1274
- for m_id in reversed(matched_ids):
1275
- stats["count"] += 1
1276
- res_body, body_data = mail.fetch(m_id, "(RFC822)")
1277
- if res_body != "OK" or not body_data or not body_data[0]:
1278
- continue
1279
- msg_raw = email.message_from_bytes(body_data[0][1])
1280
 
1281
  subj = str(make_header(decode_header(msg_raw.get("Subject", ""))))
1282
  snd = str(make_header(decode_header(msg_raw.get("From", ""))))
@@ -1311,10 +1431,10 @@ if check_password():
1311
  decoded_filename = str(make_header(decode_header(filename)))
1312
  attachments.append(decoded_filename)
1313
 
1314
- try:
1315
- dt = email.utils.parsedate_to_datetime(msg_raw.get("Date"))
1316
- except Exception:
1317
- dt = datetime.now()
1318
 
1319
  # 🎯 [νƒ€μž„μ‘΄ λ³€ν™˜ 버그 ꡐ정] μ„œλ²„ μ‹œκ°„(UTC)을 λ‰΄μ§ˆλžœλ“œ(Auckland) ν˜„μ§€ μ‹œκ°„μœΌλ‘œ μ •ν™•ν•˜κ²Œ κ°•μ œ λ³€ν™˜ν•©λ‹ˆλ‹€.
1320
  if dt and dt.tzinfo:
@@ -1358,14 +1478,14 @@ if check_password():
1358
  "Attach": attachments,
1359
  "RawDate": dt.replace(tzinfo=None)
1360
  })
1361
- except Exception as folder_error:
1362
- status.write(f"⚠️ Skipped folder '{folder}': {folder_error}")
1363
- continue
1364
- mail.logout()
1365
- except Exception as mailbox_error:
1366
- status.write(f"❌ Mailbox failed '{my_mail_addr}': {mailbox_error}")
1367
- except Exception as e:
1368
- st.error(f"IMAP Engine Error: {e}")
1369
 
1370
  if all_mails:
1371
  all_mails.sort(key=lambda x: x.get("RawDate", datetime.min))
@@ -1477,10 +1597,10 @@ if st.session_state['results'] is not None:
1477
  st.markdown(f"<div class='content-box'>{row.get('Body', '')}</div>", unsafe_allow_html=True)
1478
 
1479
  # 🟒 [κΈ°μ‘΄ κΈ°λŠ₯ μœ μ§€] μ½”λ©˜νŠΈ(Comments)κ°€ μžˆμ„ 경우, λ³Έλ¬Έ μ•„λž˜μ— λ³„λ„μ˜ 말풍선 λ°•μŠ€λ‘œ 예쁘게 λ Œλ”λ§
1480
- comments_data = row.get('Comments', '').strip()
1481
- if comments_data:
1482
- html_comments = html.escape(comments_data).replace('\n', '<br>')
1483
- st.markdown(f"""
1484
  <div style='background-color: #FEF3C7; padding: 12px 15px; border-radius: 8px; border: 1px solid #FCD34D; font-size: 0.85rem; margin-top: 12px; color: #92400E; line-height: 1.6;'>
1485
  <b>πŸ’¬ Notion Comments:</b><br><br>{html_comments}
1486
  </div>
 
3
  import io
4
  import os
5
  import json
6
+ import sys
7
+ import warnings
8
+ import platform
9
+ import imaplib
10
+ import email
11
+ import html
12
+ import socket
13
+ from imaplib import IMAP4_SSL
14
  from email.header import decode_header, make_header
15
  from datetime import datetime
16
  from docx import Document
 
18
  from docx.enum.text import WD_ALIGN_PARAGRAPH
19
  from docx.oxml.ns import qn
20
  from docx.oxml import OxmlElement
21
+ import pdfplumber
22
+ import vertexai
23
+ import requests
24
  import copy
25
  import re
26
+ import sqlite3
27
+
28
+ def get_config_value(key, default=""):
29
+ value = os.environ.get(key)
30
+ if value is not None:
31
+ return value
32
+ try:
33
+ return st.secrets.get(key, default)
34
+ except Exception:
35
+ return default
36
+
37
+ def parse_allowed_users(raw_users):
38
+ if hasattr(raw_users, "items"):
39
+ return {str(email).strip().lower(): str(password) for email, password in raw_users.items()}
40
+
41
+ users = {}
42
+ for item in str(raw_users or "").split(","):
43
+ email_addr, separator, password = item.partition("=")
44
+ email_addr = email_addr.strip().strip('"').strip("'").lower()
45
+ if separator and email_addr:
46
+ users[email_addr] = password.strip().strip('"').strip("'")
47
+ return users
48
+
49
+ # 1. 인증 및 Secrets 톡합 λ‘œλ“œ
50
+ os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = "credentials.json"
51
+
52
+ # 2. λͺ¨λ“  ν•„μˆ˜ λ³€μˆ˜ ν•œ λ²ˆμ— μ„ μ–Έ (λ³€μˆ˜λͺ… 일치 확인)
53
+ COMMON_PW = get_config_value("EMAIL_PASSWORD")
54
+ MASTER_PW = get_config_value("MASTER_PASSWORD")
55
+ ADMIN_EMAIL = get_config_value("ADMIN_EMAIL").strip().lower()
56
+ PROJECT_ID = get_config_value("PROJECT_ID")
57
+ NOTION_TOKEN = get_config_value("NOTION_API_TOKEN")
58
+ NOTION_DB_ID = get_config_value("NOTION_DATABASE_ID")
59
+
60
+ # 3. 리슀트/λ”•μ…”λ„ˆλ¦¬ λ³€ν™˜ 둜직
61
+ ALL_MAILBOXES = [item.strip() for item in get_config_value("ALL_MAILBOXES").split(",") if item.strip()]
62
+ allowed_users = parse_allowed_users(get_config_value("ALLOWED_USERS"))
63
+ MAX_EMAILS_PER_FOLDER = 250
64
 
65
  def save_rules_to_db(df):
66
  conn = sqlite3.connect("data.db")
 
125
  if 'support_name' not in st.session_state: st.session_state['support_name'] = ""
126
  if 'refs' not in st.session_state: st.session_state['refs'] = ""
127
  if 'folder_path' not in st.session_state: st.session_state['folder_path'] = ""
128
+ if 'doc_context' not in st.session_state: st.session_state['doc_context'] = ""
129
+ if 'results' not in st.session_state: st.session_state['results'] = None
130
+ if 'notion_client_name' not in st.session_state: st.session_state['notion_client_name'] = ""
131
+ if 'case_comments' not in st.session_state: st.session_state['case_comments'] = ""
132
 
133
  # =========================================================================
134
+ # [λ³΄μ•ˆ λνŒμ™•] 졜초 둜그인 λΉ„λ²ˆ μ„ΈνŒ… & κ΄€λ¦¬μž μ‹€μ‹œκ°„ 동기화/μ‚­μ œ ν†΅μ œ μ‹œμŠ€ν…œ
135
+ # =========================================================================
136
+ USER_DB_FILE = "user_passwords.db"
137
+
138
+ def init_user_db(seed_users):
139
+ conn = sqlite3.connect(USER_DB_FILE)
140
+ conn.execute("""
141
+ CREATE TABLE IF NOT EXISTS users (
142
+ email TEXT PRIMARY KEY,
143
+ password TEXT NOT NULL DEFAULT '',
144
+ active INTEGER NOT NULL DEFAULT 1
145
+ )
146
+ """)
147
+ conn.execute("""
148
+ CREATE TABLE IF NOT EXISTS mailboxes (
149
+ email TEXT PRIMARY KEY,
150
+ entry TEXT NOT NULL,
151
+ active INTEGER NOT NULL DEFAULT 1
152
+ )
153
+ """)
154
+ for email_addr, password in seed_users.items():
155
+ conn.execute(
156
+ "INSERT OR IGNORE INTO users (email, password, active) VALUES (?, ?, 1)",
157
+ (email_addr, password)
158
+ )
159
+ for mailbox_entry in ALL_MAILBOXES:
160
+ mailbox_email = parse_mailbox_email(mailbox_entry)
161
+ if mailbox_email:
162
+ conn.execute(
163
+ "INSERT OR IGNORE INTO mailboxes (email, entry, active) VALUES (?, ?, 1)",
164
+ (mailbox_email, mailbox_entry)
165
+ )
166
+ conn.commit()
167
+ conn.close()
168
+
169
+ def parse_mailbox_email(mailbox_entry):
170
+ credential_part = str(mailbox_entry or "").split("|", 1)[0].strip()
171
+ return credential_part.split(":", 1)[0].strip().lower()
172
+
173
+ def load_active_users():
174
+ conn = sqlite3.connect(USER_DB_FILE)
175
+ rows = conn.execute("SELECT email, password FROM users WHERE active = 1 ORDER BY email").fetchall()
176
+ conn.close()
177
+ return {email_addr: password for email_addr, password in rows}
178
+
179
+ def update_user_password(email_addr, new_password):
180
+ conn = sqlite3.connect(USER_DB_FILE)
181
+ conn.execute(
182
+ "UPDATE users SET password = ?, active = 1 WHERE email = ?",
183
+ (new_password, email_addr)
184
+ )
185
+ conn.commit()
186
+ updated = conn.total_changes > 0
187
+ conn.close()
188
+ return updated
189
+
190
+ def add_or_restore_user(email_addr, initial_password=""):
191
+ email_addr = email_addr.strip().lower()
192
+ conn = sqlite3.connect(USER_DB_FILE)
193
+ existing = conn.execute("SELECT email FROM users WHERE email = ?", (email_addr,)).fetchone()
194
+ if existing:
195
+ conn.execute(
196
+ "UPDATE users SET password = ?, active = 1 WHERE email = ?",
197
+ (initial_password, email_addr)
198
+ )
199
+ else:
200
+ conn.execute(
201
+ "INSERT INTO users (email, password, active) VALUES (?, ?, 1)",
202
+ (email_addr, initial_password)
203
+ )
204
+ conn.commit()
205
+ updated = conn.total_changes > 0
206
+ conn.close()
207
+ return updated
208
+
209
+ def revoke_user_access(email_addr):
210
+ conn = sqlite3.connect(USER_DB_FILE)
211
+ conn.execute("UPDATE users SET active = 0 WHERE email = ?", (email_addr,))
212
+ conn.commit()
213
+ updated = conn.total_changes > 0
214
+ conn.close()
215
+ return updated
216
+
217
+ def load_active_mailboxes():
218
+ conn = sqlite3.connect(USER_DB_FILE)
219
+ rows = conn.execute("SELECT email, entry FROM mailboxes WHERE active = 1 ORDER BY email").fetchall()
220
+ conn.close()
221
+ return {email_addr: entry for email_addr, entry in rows}
222
+
223
+ def add_or_restore_mailbox(mailbox_entry):
224
+ mailbox_entry = mailbox_entry.strip()
225
+ mailbox_email = parse_mailbox_email(mailbox_entry)
226
+ if not mailbox_email:
227
+ return False
228
+ conn = sqlite3.connect(USER_DB_FILE)
229
+ existing = conn.execute("SELECT email FROM mailboxes WHERE email = ?", (mailbox_email,)).fetchone()
230
+ if existing:
231
+ conn.execute(
232
+ "UPDATE mailboxes SET entry = ?, active = 1 WHERE email = ?",
233
+ (mailbox_entry, mailbox_email)
234
+ )
235
+ else:
236
+ conn.execute(
237
+ "INSERT INTO mailboxes (email, entry, active) VALUES (?, ?, 1)",
238
+ (mailbox_email, mailbox_entry)
239
+ )
240
+ conn.commit()
241
+ updated = conn.total_changes > 0
242
+ conn.close()
243
+ return updated
244
+
245
+ def revoke_mailbox_access(mailbox_email):
246
+ conn = sqlite3.connect(USER_DB_FILE)
247
+ conn.execute("UPDATE mailboxes SET active = 0 WHERE email = ?", (mailbox_email,))
248
+ conn.commit()
249
+ updated = conn.total_changes > 0
250
+ conn.close()
251
+ return updated
252
+
253
+ def get_imap_host_candidates(primary_host, mailbox_email):
254
+ candidates = []
255
+ primary_host = str(primary_host or "").strip()
256
+ if primary_host:
257
+ candidates.append(primary_host)
258
+ if "@" in mailbox_email:
259
+ fallback_host = "mail." + mailbox_email.split("@", 1)[1].strip()
260
+ if fallback_host not in candidates:
261
+ candidates.append(fallback_host)
262
+ return candidates
263
+
264
+ init_user_db(allowed_users)
265
 
266
  def check_password():
267
  """둜그인 검증, 졜초 μ ‘μ†μž λΉ„λ²ˆ μ„ΈνŒ…, κ΄€λ¦¬μž μ „μš© λŒ€μ‹œλ³΄λ“œ μŠ€μœ„μΉ­ λ§ˆμŠ€ν„° ν•¨μˆ˜"""
 
288
  </div>
289
  """, unsafe_allow_html=True)
290
 
291
+ allowed_users = load_active_users()
292
+
293
+ input_email = st.text_input("πŸ“§ Login Email Address", key="login_email", placeholder="username@fluxfinance.co.nz").strip().lower()
294
+
295
+ if input_email and (input_email != ADMIN_EMAIL and input_email not in allowed_users):
296
+ st.error("❌ Access Denied: This account is unregistered or has been deactivated. Please contact the administrator.")
297
+ return False
298
+
299
+ # Case A: 졜고 κ΄€λ¦¬μž(λ§€λ‹ˆμ €λ‹˜) 접속 μ‹œ (κΈ°λŠ₯ 100% 동일)
300
+ if input_email == ADMIN_EMAIL:
301
+ input_password = st.text_input("πŸ‘‘ Admin Password", type="password", key="login_password", placeholder="Enter admin password")
302
+ if st.button("Sign In as Manager", use_container_width=True):
303
+ if input_password == MASTER_PW:
304
+ st.session_state["password_correct"] = True
305
+ st.session_state["is_admin_mode"] = True
306
+ st.rerun()
307
  else:
308
  st.error("❌ Incorrect Administrator password.")
309
  return False
 
316
  if current_db_pwd == "":
317
  st.warning("πŸ†• Welcome! Please set up your personalized password for this account.")
318
  new_pwd = st.text_input("πŸ”‘ Create New Password", type="password", key="setup_pwd")
319
+ confirm_pwd = st.text_input("πŸ”„ Confirm New Password", type="password", key="confirm_pwd")
320
+
321
+ if st.button("Activate My Account", use_container_width=True):
322
+ if new_pwd and new_pwd == confirm_pwd:
323
+ if update_user_password(input_email, new_pwd):
324
+ st.success("βœ… Password configured successfully! Please sign in again with your new password.")
325
+ st.rerun()
326
+ else:
327
+ st.error("⚠️ System Error: Failed to update your password. Please contact the administrator.")
328
+ else:
329
+ st.error("❌ Passwords do not match or fields are left blank.")
330
  return False
331
 
332
  # 상황 β‘‘: 이미 λΉ„λ²ˆ μ„ΈνŒ…μ΄ λλ‚˜μ„œ 정상 λ‘œκ·ΈμΈμ„ μ‹œλ„ν•  λ•Œ -> 둜그인 둜직 μ™„λ²½ 보쑴
 
349
  st.write("Verify your current password to update your credentials.")
350
  verify_old = st.text_input("Current Password", type="password", key="v_old")
351
  update_new = st.text_input("New Password", type="password", key="u_new")
352
+
353
+ if st.button("Update Password", use_container_width=True):
354
+ if verify_old == current_db_pwd and update_new:
355
+ if update_user_password(input_email, update_new):
356
+ st.toast("βœ… Password updated successfully!", icon="πŸš€")
357
+ st.rerun()
358
+ else:
359
+ st.error("⚠️ System Error: Failed to update your password. Please contact the administrator.")
360
+ else:
361
+ st.error("❌ Current password verification failed or input is missing.")
362
  return False
363
  # =========================================================================
364
  # [NOTION API CONNECTOR] λ…Έμ…˜ νŽ˜μ΄μ§€ λ³Έλ¬Έ 및 λͺ¨λ“  μ‹€μ‹œκ°„ λŒ“κΈ€ 병합 μˆ˜μ§‘κΈ°
 
366
  # =========================
367
  # 1. λŒ“κΈ€ κ°€μ Έμ˜€κΈ°
368
  # =========================
369
+ def fetch_notion_comments(page_id):
370
+ """λ…Έμ…˜ νŠΉμ • νŽ˜μ΄μ§€μ˜ λŒ“κΈ€κ³Ό μž‘μ„± λ‚ μ§œλ₯Ό ν•¨κ»˜ μΆ”μΆœν•˜λŠ” ν•¨μˆ˜"""
371
+ notion_token = NOTION_TOKEN
372
  headers = {
373
  "Authorization": f"Bearer {notion_token}",
374
  "Notion-Version": "2025-09-03"
 
383
  # 🟒 [μˆ˜μ •] μž‘μ„± λ‚ μ§œ(created_time)λ₯Ό κ°€μ Έμ˜΅λ‹ˆλ‹€.
384
  created_at = c.get("created_time", "").split("T")[0] # YYYY-MM-DD ν˜•μ‹λ§Œ μΆ”μΆœ
385
 
386
+ c_text_list = c.get("rich_text", [])
387
+ c_text = "".join(
388
+ item.get("plain_text", item.get("text", {}).get("content", ""))
389
+ for item in c_text_list
390
+ if isinstance(item, dict)
391
+ ).strip()
392
 
393
  if c_text:
394
  # 🟒 [μˆ˜μ •] [λ‚ μ§œ] μ½”λ©˜νŠΈ λ‚΄μš© ν˜•μ‹μœΌλ‘œ μ‘°ν•©
 
398
  pass
399
  return ""
400
 
401
+ def fetch_client_tasks_from_notion(client_name):
402
+ # .streamlit/secrets.toml νŒŒμΌμ—μ„œ λ³΄μ•ˆ 정보λ₯Ό μ•ˆμ „ν•˜κ²Œ λ‘œλ“œν•©λ‹ˆλ‹€.
403
+ notion_token = NOTION_TOKEN
404
+ target_data_source_id = NOTION_DB_ID
405
 
406
  if not notion_token:
407
  return [], "Missing NOTION_API_TOKEN in secrets.toml"
 
560
  st.markdown("### πŸ‘‘ Admin Control")
561
  st.caption("Monitor real-time password setups for active team members and revoke access for offboarded employees.")
562
 
563
+ current_users = load_active_users()
564
+ admin_data = []
565
 
566
  for u_email, u_pwd in current_users.items():
567
  status_tag = "πŸ”΄ Pending Setup" if u_pwd == "" else "🟒 Active (Password Set)"
 
580
  "Status": status_tag
581
  })
582
 
583
+ st.table(pd.DataFrame(admin_data))
584
+
585
+ st.markdown("**Add or Restore User Access**")
586
+ add_col_email, add_col_password = st.columns([2, 1])
587
+ new_user_email = add_col_email.text_input(
588
+ "Employee email address",
589
+ key="admin_add_user_email",
590
+ placeholder="new.user@fluxfinance.co.nz"
591
+ ).strip().lower()
592
+ new_user_password = add_col_password.text_input(
593
+ "Initial password",
594
+ type="password",
595
+ key="admin_add_user_password",
596
+ help="Leave blank to let the user set their password on first login."
597
+ )
598
+
599
+ if st.button("βž• Add / Restore User", use_container_width=True):
600
+ if not new_user_email:
601
+ st.error("Please enter an email address.")
602
+ elif not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", new_user_email):
603
+ st.error("Please enter a valid email address.")
604
+ elif add_or_restore_user(new_user_email, new_user_password):
605
+ st.success(f"βœ… Access granted for {new_user_email}.")
606
+ st.rerun()
607
+ else:
608
+ st.error("⚠️ System Error: Failed to update user access.")
609
+
610
+ st.markdown("**Deactivate & Revoke User Permissions**")
611
+ target_del = st.selectbox("Select email address to revoke access", ["-"] + list(current_users.keys()))
612
+
613
+ if target_del != "-" and st.button("🚨 Revoke Access"):
614
+ if target_del in current_users and revoke_user_access(target_del):
615
+ st.success(f"⚠️ Access permanently revoked for {target_del}. This user can no longer sign in.")
616
+ st.rerun()
617
+
618
+ st.markdown("**Mailbox Scan List**")
619
+ current_mailboxes = load_active_mailboxes()
620
+ mailbox_rows = [{"Mailbox": email_addr, "IMAP Entry": entry} for email_addr, entry in current_mailboxes.items()]
621
+ st.table(pd.DataFrame(mailbox_rows))
622
+
623
+ mailbox_entry = st.text_input(
624
+ "Mailbox entry",
625
+ key="admin_add_mailbox_entry",
626
+ placeholder="email@domain.com:optional-password||imap.server.com"
627
+ ).strip()
628
+
629
+ if st.button("βž• Add / Restore Mailbox", use_container_width=True):
630
+ mailbox_email = parse_mailbox_email(mailbox_entry)
631
+ if not mailbox_entry:
632
+ st.error("Please enter a mailbox entry.")
633
+ elif not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", mailbox_email):
634
+ st.error("Please enter a valid mailbox email address.")
635
+ elif "||" not in mailbox_entry:
636
+ st.error("Please include the IMAP host using the format email@domain.com||imap.server.com.")
637
+ elif add_or_restore_mailbox(mailbox_entry):
638
+ st.success(f"βœ… Mailbox added for scanning: {mailbox_email}.")
639
+ st.rerun()
640
+ else:
641
+ st.error("⚠️ System Error: Failed to update mailbox list.")
642
+
643
+ target_mailbox_del = st.selectbox(
644
+ "Select mailbox to remove from scan list",
645
+ ["-"] + list(current_mailboxes.keys()),
646
+ key="admin_revoke_mailbox_select"
647
+ )
648
+
649
+ if target_mailbox_del != "-" and st.button("🚫 Remove Mailbox From Scan List"):
650
+ if target_mailbox_del in current_mailboxes and revoke_mailbox_access(target_mailbox_del):
651
+ st.success(f"⚠️ Mailbox removed from scan list: {target_mailbox_del}.")
652
+ st.rerun()
653
+
654
+ # (2) κ·œμΉ™ νŽΈμ§‘ 관리 (Rules Configuration - Admin Control λ°”λ‘œ λ°‘μœΌλ‘œ 배치)
655
  with st.expander("βš™οΈ Rules Configuration", expanded=False):
656
  st.caption("πŸ“ μ›Ή ν™”λ©΄μ—μ„œ μˆ˜μ •Β·μΆ”κ°€ν•˜λ©΄ μ‹œμŠ€ν…œ νŒŒμΌμ— 영ꡬ μ €μž₯λ˜μ–΄ μ—°λ™λ©λ‹ˆλ‹€.")
657
  edited_rules = st.data_editor(st.session_state['rules_df'], num_rows="dynamic", use_container_width=True, hide_index=True, key="main_rules_editor")
 
1217
  status.update(label="βœ… Notion Tasks Synced.", state="complete")
1218
  st.rerun()
1219
 
1220
+ elif tasks_data is not None and not tasks_data:
1221
+ status.update(label="⚠️ λ§€μΉ­ 데이터 μ—†μŒ", state="error")
1222
+ else:
1223
+ status.update(label=f"❌ Failed: {msg}", state="error")
1224
+
1225
+ case_comments = st.session_state.get('case_comments', '').strip()
1226
+ if case_comments:
1227
+ html_case_comments = html.escape(case_comments).replace('\n', '<br>')
1228
+ st.markdown(f"""
1229
+ <div style='background-color: #F0FDF4; padding: 12px 15px; border-radius: 8px; border: 1px solid #BBF7D0; font-size: 0.85rem; margin-top: 12px; color: #166534; line-height: 1.6;'>
1230
+ <b>πŸ’¬ Case Comments:</b><br><br>{html_case_comments}
1231
+ </div>
1232
+ """, unsafe_allow_html=True)
1233
  # TAB 2: 이메일 검색창
1234
  with tab_email:
1235
  with st.container(border=True):
 
1237
  st.session_state['folder_path'] = st.text_input("πŸ“‚ G: Drive Folder Path", value=st.session_state['folder_path'], key="fixed_mail_path")
1238
 
1239
  # 🎯 [μΆ”κ°€] secretsμ—μ„œ μ£Όμ†Œλ§Œ 쏙 λ°œλΌλ‚΄μ–΄ λ©€ν‹° μ…€λ ‰νŠΈ λ°•μŠ€ 생성
1240
+ raw_mailbox_list = list(load_active_mailboxes().values())
1241
+ mailbox_options = []
1242
+ mailbox_map = {}
1243
+ for entry in raw_mailbox_list:
1244
+ entry_clean = entry.strip()
1245
+ if not entry_clean: continue
1246
+ clean_email = parse_mailbox_email(entry_clean)
1247
+ mailbox_options.append(clean_email)
1248
+ mailbox_map[clean_email] = entry_clean
 
1249
 
1250
  selected_boxes = st.multiselect("πŸ“¬ Select Mailboxes to Scan", options=mailbox_options, default=[], key="active_mailboxes_select")
1251
 
 
1278
  </div>
1279
  </div>
1280
  </div>
1281
+ """, unsafe_allow_html=True)
1282
+
1283
+ if email_sync_btn:
1284
+ st.session_state['active_mode'] = "EMAIL"
1285
+ if not selected_boxes:
1286
+ st.warning("Please select at least one mailbox to scan.")
1287
+ st.stop()
1288
+ # 🟒 [여기에 μΆ”κ°€] 이메일 이름을 μ „μ—­ λ³€μˆ˜λ‘œ ν™•μ • 박아버림! (μ—¬λŸ¬ λͺ…이면 &둜 μΉ˜ν™˜)
1289
+ raw_names = st.session_state.get('client_names', '').strip()
1290
+ st.session_state['global_client_name'] = raw_names.replace(';', ' & ') if raw_names else ''
1291
 
1292
  with st.status("πŸ” Syncing...", expanded=True) as status:
1293
  progress_text = st.empty()
 
1306
  # 🎯 [ꡐ체 μ™„λ£Œ] 전체λ₯Ό λ‹€ λ„λŠ” λŒ€μ‹ , λ©€ν‹° μ…€λ ‰νŠΈμ—μ„œ μ„ νƒλœ μ‚¬μ„œν•¨λ“€λ§Œ μΆ”μΆœ
1307
  target_mailbox_entries = [mailbox_map[m] for m in selected_boxes if m in mailbox_map]
1308
 
1309
+ if target_mailbox_entries:
1310
+ try:
1311
+ import email.utils
1312
+ socket.setdefaulttimeout(20)
1313
+
1314
+ for entry in target_mailbox_entries:
1315
  entry_clean = entry.strip()
1316
  if not entry_clean: continue
1317
 
 
1330
  my_mail_addr = credential_part.strip()
1331
  target_pw = COMMON_PW
1332
 
1333
+ status.write(f"πŸ” Scanning Mailbox: '{my_mail_addr}' via {imap_host}:993...")
1334
+
1335
+ mail = None
1336
+ last_connect_error = None
1337
+ for host_candidate in get_imap_host_candidates(imap_host, my_mail_addr):
1338
+ try:
1339
+ status.write(f"🌐 Connecting to {host_candidate}:993...")
1340
+ mail = IMAP4_SSL(host_candidate, 993, timeout=45)
1341
+ mail.login(my_mail_addr, target_pw)
1342
+ imap_host = host_candidate
1343
+ break
1344
+ except Exception as connect_error:
1345
+ last_connect_error = connect_error
1346
+ try:
1347
+ if mail:
1348
+ mail.logout()
1349
+ except Exception:
1350
+ pass
1351
+ mail = None
1352
+
1353
+ if mail is None:
1354
+ status.write(f"❌ Mailbox failed '{my_mail_addr}' via {imap_host}: {last_connect_error}")
1355
+ continue
1356
+
1357
+ try:
1358
 
1359
  res_list, folder_list = mail.list()
1360
  all_target_folders = []
 
1371
  else:
1372
  raw_folder_name = f_str.split()[-1].strip().strip('"')
1373
 
1374
+ if any(k in raw_folder_name.lower() for k in ["junk", "trash", "deleted", "sync", "spam"]):
1375
+ continue
1376
+ if raw_folder_name:
1377
+ all_target_folders.append(raw_folder_name)
1378
+
1379
+ if not all_target_folders:
1380
+ all_target_folders = ["INBOX", "Sent"]
1381
+ status.write(f"πŸ“‚ Found {len(all_target_folders)} folder(s). Scanning latest {MAX_EMAILS_PER_FOLDER} messages per folder.")
1382
+
1383
+ for folder in all_target_folders:
1384
+ try:
1385
+ mail.select(f'"{folder}"', readonly=True)
1386
+ res, msg_ids = mail.search(None, "ALL")
1387
+ matched_ids = msg_ids[0].split()[-MAX_EMAILS_PER_FOLDER:] if res == "OK" and msg_ids and msg_ids[0] else []
1388
+
1389
+ if not matched_ids:
1390
+ continue
1391
+ status.write(f"πŸ“ {folder}: checking latest {len(matched_ids)} message(s)")
1392
+
1393
+ # νšλ“ν•œ 메일 번호 기반 λ³Έλ¬Έ μΆ”μΆœ μ—”μ§„ μ •μœ„μΉ˜ μ•ˆμ°©
1394
+ for m_id in reversed(matched_ids):
1395
+ stats["count"] += 1
1396
+ res_body, body_data = mail.fetch(m_id, "(RFC822)")
1397
+ if res_body != "OK" or not body_data or not body_data[0]:
1398
+ continue
1399
+ msg_raw = email.message_from_bytes(body_data[0][1])
1400
 
1401
  subj = str(make_header(decode_header(msg_raw.get("Subject", ""))))
1402
  snd = str(make_header(decode_header(msg_raw.get("From", ""))))
 
1431
  decoded_filename = str(make_header(decode_header(filename)))
1432
  attachments.append(decoded_filename)
1433
 
1434
+ try:
1435
+ dt = email.utils.parsedate_to_datetime(msg_raw.get("Date"))
1436
+ except Exception:
1437
+ dt = datetime.now()
1438
 
1439
  # 🎯 [νƒ€μž„μ‘΄ λ³€ν™˜ 버그 ꡐ정] μ„œλ²„ μ‹œκ°„(UTC)을 λ‰΄μ§ˆλžœλ“œ(Auckland) ν˜„μ§€ μ‹œκ°„μœΌλ‘œ μ •ν™•ν•˜κ²Œ κ°•μ œ λ³€ν™˜ν•©λ‹ˆλ‹€.
1440
  if dt and dt.tzinfo:
 
1478
  "Attach": attachments,
1479
  "RawDate": dt.replace(tzinfo=None)
1480
  })
1481
+ except Exception as folder_error:
1482
+ status.write(f"⚠️ Skipped folder '{folder}': {folder_error}")
1483
+ continue
1484
+ mail.logout()
1485
+ except Exception as mailbox_error:
1486
+ status.write(f"❌ Mailbox failed '{my_mail_addr}': {mailbox_error}")
1487
+ except Exception as e:
1488
+ st.error(f"IMAP Engine Error: {e}")
1489
 
1490
  if all_mails:
1491
  all_mails.sort(key=lambda x: x.get("RawDate", datetime.min))
 
1597
  st.markdown(f"<div class='content-box'>{row.get('Body', '')}</div>", unsafe_allow_html=True)
1598
 
1599
  # 🟒 [κΈ°μ‘΄ κΈ°λŠ₯ μœ μ§€] μ½”λ©˜νŠΈ(Comments)κ°€ μžˆμ„ 경우, λ³Έλ¬Έ μ•„λž˜μ— λ³„λ„μ˜ 말풍선 λ°•μŠ€λ‘œ 예쁘게 λ Œλ”λ§
1600
+ comments_data = row.get('Comments', '').strip()
1601
+ if comments_data:
1602
+ html_comments = html.escape(comments_data).replace('\n', '<br>')
1603
+ st.markdown(f"""
1604
  <div style='background-color: #FEF3C7; padding: 12px 15px; border-radius: 8px; border: 1px solid #FCD34D; font-size: 0.85rem; margin-top: 12px; color: #92400E; line-height: 1.6;'>
1605
  <b>πŸ’¬ Notion Comments:</b><br><br>{html_comments}
1606
  </div>