carrief0908 commited on
Commit
e59832f
Β·
verified Β·
1 Parent(s): c2bc9c9

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +214 -219
src/streamlit_app.py CHANGED
@@ -1,10 +1,14 @@
1
- import streamlit as st
2
- import msal
 
 
 
3
  import requests
 
4
  import pandas as pd
5
- import re
6
  from bs4 import BeautifulSoup
7
  from io import BytesIO, StringIO
 
8
  from datetime import datetime, timedelta
9
 
10
  # ── Page config ────────────────────────────────────────────────────────────────
@@ -14,20 +18,12 @@ st.set_page_config(
14
  layout="wide",
15
  )
16
 
17
- # ── Styling ────────────────────────────────────────────────────────────────────
18
  st.markdown("""
19
  <style>
20
  @import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:wght@400;500;600&display=swap');
21
-
22
- html, body, [class*="css"] {
23
- font-family: 'DM Sans', sans-serif;
24
- }
25
- h1, h2, h3 {
26
- font-family: 'DM Serif Display', serif;
27
- }
28
- .main { background: #f7f5f0; }
29
  .block-container { padding-top: 2rem; max-width: 960px; }
30
-
31
  .status-card {
32
  background: white;
33
  border: 1px solid #e5e0d8;
@@ -37,18 +33,11 @@ st.markdown("""
37
  box-shadow: 0 1px 4px rgba(0,0,0,0.05);
38
  }
39
  .step-label {
40
- font-size: 0.72rem;
41
- font-weight: 600;
42
- letter-spacing: 0.1em;
43
- text-transform: uppercase;
44
- color: #9a8f82;
45
- margin-bottom: 0.25rem;
46
- }
47
- .step-title {
48
- font-size: 1.1rem;
49
- font-weight: 600;
50
- color: #1a1612;
51
  }
 
52
  .badge-success {
53
  background: #d4edda; color: #155724;
54
  padding: 2px 10px; border-radius: 20px;
@@ -60,67 +49,102 @@ st.markdown("""
60
  font-size: 0.78rem; font-weight: 600;
61
  }
62
  .stButton > button {
63
- background: #1a1612 !important;
64
- color: white !important;
65
- border: none !important;
66
- border-radius: 8px !important;
67
  font-family: 'DM Sans', sans-serif !important;
68
  font-weight: 600 !important;
69
  padding: 0.55rem 1.4rem !important;
70
  font-size: 0.9rem !important;
71
  }
72
- .stButton > button:hover {
73
- background: #3d3530 !important;
74
- transform: translateY(-1px);
75
- transition: all 0.15s ease;
76
- }
77
- div[data-testid="stNumberInput"] label,
78
- div[data-testid="stTextInput"] label {
 
79
  font-weight: 600;
80
- font-size: 0.85rem;
81
- color: #3d3530;
82
- }
83
- .result-header {
84
- font-size: 1rem;
85
- font-weight: 700;
86
- color: #1a1612;
87
- margin-bottom: 0.4rem;
88
- padding-bottom: 0.4rem;
89
- border-bottom: 2px solid #e5e0d8;
90
  }
 
91
  </style>
92
  """, unsafe_allow_html=True)
93
 
94
- # ── Constants ──────────────────────────────────────────────────────────────────
95
- CLIENT_ID = "bfcbb298-4cc1-496e-9d9b-ff8c2d967a3a"
96
- TENANT_ID = "5dac2bf2-8842-4788-ae07-33fb103b55d6"
97
- AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}"
98
- SCOPES = ["Mail.Read"]
99
 
100
- # ── Session state defaults ─────────────────────────────────────────────────────
101
- for key, default in {
 
 
 
 
 
 
 
 
102
  "token": None,
103
- "messages": [],
104
  "rj_emails": [],
105
  "cash_df": None,
106
  "withdrawals_df": None,
107
  "deposits_df": None,
108
  "notice_df": None,
109
  }.items():
110
- if key not in st.session_state:
111
- st.session_state[key] = default
112
 
113
- # ── Helper functions ───────────────────────────────────────────────────────────
114
 
115
- def get_access_token():
116
- app = msal.PublicClientApplication(CLIENT_ID, authority=AUTHORITY)
117
- result = app.acquire_token_interactive(scopes=SCOPES)
118
- if "access_token" not in result:
119
- raise RuntimeError(result.get("error_description", str(result)))
120
- return result["access_token"]
 
 
 
 
 
 
121
 
122
 
123
- def get_emails_from_sender(token, sender_email, top=50):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  url = "https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messages"
125
  headers = {
126
  "Authorization": f"Bearer {token}",
@@ -128,37 +152,32 @@ def get_emails_from_sender(token, sender_email, top=50):
128
  }
129
  params = {
130
  "$top": top,
131
- "$filter": f"from/emailAddress/address eq '{sender_email}'",
132
- "$select": "subject,from,toRecipients,ccRecipients,receivedDateTime,body,id",
133
  }
134
- resp = requests.get(url, headers=headers, params=params, timeout=60)
135
- resp.raise_for_status()
136
- return resp.json().get("value", [])
137
 
138
 
139
- def extract_email_info(msg):
140
- def get_addresses(recipients):
141
- return [
142
- (r.get("emailAddress") or {}).get("address")
143
- for r in (recipients or [])
144
- if (r.get("emailAddress") or {}).get("address")
145
- ]
146
  body_html = (msg.get("body") or {}).get("content", "")
147
  return {
148
  "id": msg.get("id"),
149
  "subject": msg.get("subject"),
150
  "from": ((msg.get("from") or {}).get("emailAddress") or {}).get("address"),
151
- "to": get_addresses(msg.get("toRecipients")),
152
- "cc": get_addresses(msg.get("ccRecipients")),
153
  "received_time": msg.get("receivedDateTime"),
154
  "body_html": body_html,
155
  }
156
 
157
 
158
  def html_to_text(html):
159
- if not html:
160
- return ""
161
- return BeautifulSoup(html, "html.parser").get_text("\n", strip=True)
162
 
163
 
164
  def reply_marker_html(body_html):
@@ -171,63 +190,57 @@ def reply_marker_html(body_html):
171
  return "".join(str(n) for n in marker.next_siblings).strip()
172
 
173
 
174
- def extract_forwarded_headers(body_html):
175
  text = html_to_text(body_html)
176
  def grab(label):
177
  m = re.search(rf"{label}:\s*(.*)", text)
178
  return m.group(1).strip() if m else ""
179
- return {
180
- "forwarded_from": grab("From"),
181
- "forwarded_sent_time": grab("Sent"),
182
- "forwarded_to": grab("To"),
183
- "forwarded_cc": grab("Cc"),
184
- "forwarded_subject": grab("Subject"),
185
- }
186
-
187
-
188
- def is_rj_forward(info):
189
- subject = (info.get("subject") or "").lower()
190
- body_html = info.get("body_html") or ""
191
- body_text = html_to_text(body_html).lower()
192
- if "rj emails--overview" in subject:
193
  return False
194
- has_forward = 'id="divRplyFwdMsg"' in body_html or ("from:" in body_text and "sent:" in body_text)
195
- if not has_forward:
196
  return False
197
  return any([
198
- "raymond james" in subject,
199
- "raymond james" in body_text,
200
- "amstradingadmin@raymondjames.com" in body_text,
201
- "ams managed operations" in subject,
202
- "ams managed operations" in body_text,
203
- "direct asset transfers" in subject,
204
- "distribution & cash balance check" in subject,
205
  ])
206
 
207
 
208
- def build_rj_emails(messages, target_filter):
209
  rows = []
210
- for idx, msg in enumerate(messages, 1):
211
- info = extract_email_info(msg)
212
- recipients = [a.lower() for a in info.get("to", []) + info.get("cc", [])]
213
- if target_filter.lower() not in recipients:
214
  continue
215
- if not is_rj_forward(info):
216
  continue
217
- fwd = extract_forwarded_headers(info.get("body_html", ""))
218
  rows.append({
219
- "email_id": info.get("id") or f"msg_{idx}",
220
  "fw_subject": info.get("subject"),
221
  "fw_from": info.get("from"),
222
  "fw_to": ", ".join(info.get("to", [])),
223
  "fw_cc": ", ".join(info.get("cc", [])),
224
  "fw_received_time": info.get("received_time"),
225
  "fw_body_html": info.get("body_html", ""),
226
- "original_from": fwd.get("forwarded_from", ""),
227
- "original_sent_time": fwd.get("forwarded_sent_time", ""),
228
- "original_to": fwd.get("forwarded_to", ""),
229
- "original_cc": fwd.get("forwarded_cc", ""),
230
- "original_subject": fwd.get("forwarded_subject", ""),
231
  "original_body_html": reply_marker_html(info.get("body_html", "")),
232
  })
233
  return rows
@@ -256,12 +269,12 @@ def extract_tables(body_html):
256
  return out
257
 
258
 
259
- def parse_date(value):
260
- if not value:
261
  return None
262
  for fmt in ("%m/%d/%Y", "%m/%d/%y", "%A, %B %d, %Y %I:%M %p"):
263
  try:
264
- return datetime.strptime(value.strip(), fmt)
265
  except ValueError:
266
  pass
267
  return None
@@ -270,21 +283,19 @@ def parse_date(value):
270
  def build_notice(row):
271
  section = row.get("source_section")
272
  account = row.get("Account") or row.get("customer_account")
273
- amount = row.get("Cash Amount") or row.get("Amount")
274
- row_type = (row.get("Type") or "").strip()
275
-
276
  if section == "Withdrawals":
277
  input_date = row.get("Input date")
278
- deadline = parse_date(input_date)
279
  response_time = (
280
- (deadline + timedelta(days=8)).strftime("%Y-%m-%d") if deadline
281
- else f"8 calendar days after {input_date}"
282
  )
283
  action = (
284
  f"Enter withdrawal in portal for account {account} for {amount}. "
285
  f"Schedule 8 calendar days after input date {input_date}."
286
  )
287
- elif section == "Deposits" or row_type.lower() == "deposit":
288
  response_time = "As soon as practical after email receipt"
289
  action = (
290
  f"Message dutytrader in Teams: contribution/deposit for account {account} "
@@ -294,36 +305,37 @@ def build_notice(row):
294
  response_time = "ASAP"
295
  action = (
296
  f"Halt account and advise of pending trades on {account}. "
297
- f"Immediate cash movement noted: {amount}."
298
  )
299
  return {
300
- "email_id": row.get("email_id"),
301
- "sent_time": row.get("fw_received_time"),
302
  "response_time_needed": response_time,
303
- "account": account,
304
- "action": action,
305
- "source_section": section,
306
  }
307
 
308
 
309
- def to_excel(dfs_dict):
310
  buf = BytesIO()
311
- with pd.ExcelWriter(buf, engine="openpyxl") as writer:
312
- for sheet_name, df in dfs_dict.items():
313
  if df is not None and not df.empty:
314
- df.to_excel(writer, sheet_name=sheet_name, index=False)
315
  return buf.getvalue()
316
 
317
 
318
  # ── UI ─────────────────────────────────────────────────────────────────────────
319
 
320
  st.markdown("## πŸ“¬ Raymond James Email Processor")
321
- st.markdown("Pulls forwarded RJ emails from your inbox, extracts cash movement tables, and generates action notices.")
322
  st.divider()
323
 
324
- # Step 1 β€” Login
 
325
  st.markdown('<div class="step-label">Step 1</div>', unsafe_allow_html=True)
326
- col1, col2 = st.columns([4, 1])
327
  with col1:
328
  st.markdown('<div class="step-title">Sign in to Microsoft</div>', unsafe_allow_html=True)
329
  with col2:
@@ -333,77 +345,77 @@ with col2:
333
  st.markdown('<span class="badge-pending">Not signed in</span>', unsafe_allow_html=True)
334
 
335
  if not st.session_state.token:
336
- st.markdown("Click the button below. A Microsoft login window will open in your browser.")
337
- if st.button("πŸ” Sign in with Microsoft"):
338
- with st.spinner("Opening Microsoft login window..."):
339
- try:
340
- st.session_state.token = get_access_token()
341
- st.success("Signed in successfully!")
342
- st.rerun()
343
- except Exception as e:
344
- st.error(f"Login failed: {e}")
345
  else:
346
  st.markdown("You are signed in. βœ“")
 
 
 
347
 
348
- # Step 2 β€” Configuration
 
 
 
349
  st.markdown('<div class="step-label">Step 2</div>', unsafe_allow_html=True)
350
  st.markdown('<div class="step-title">Configure</div>', unsafe_allow_html=True)
351
-
352
  col_a, col_b, col_c = st.columns(3)
353
  with col_a:
354
- sender_email = st.text_input(
355
- "Sender email (who forwards the RJ emails)",
356
- value="agabrielse@newfrontieradvisors.com",
357
- )
358
  with col_b:
359
- target_email = st.text_input(
360
- "Filter: recipient email to look for",
361
- value="cfeng@newfrontieradvisors.com",
362
- )
363
  with col_c:
364
  top_n = st.number_input("Max emails to fetch", min_value=5, max_value=200, value=50, step=5)
 
365
 
366
  # Step 3 β€” Run
 
367
  st.markdown('<div class="step-label">Step 3</div>', unsafe_allow_html=True)
368
- st.markdown('<div class="step-title">Fetch & Process Emails</div>', unsafe_allow_html=True)
 
369
 
370
  if st.button("β–Ά Run", disabled=not st.session_state.token):
371
  with st.spinner("Fetching emails from Outlook..."):
372
  try:
373
- raw = get_emails_from_sender(st.session_state.token, sender_email, top=int(top_n))
374
- st.session_state.messages = raw
375
- except Exception as e:
376
- st.error(f"Failed to fetch emails: {e}")
 
 
 
377
  st.stop()
378
 
379
- with st.spinner("Filtering Raymond James forwarded emails..."):
380
- st.session_state.rj_emails = build_rj_emails(raw, target_email)
381
 
382
- with st.spinner("Extracting tables from email bodies..."):
383
  cash_rows, wd_rows, dep_rows = [], [], []
384
  for email in st.session_state.rj_emails:
385
  for section, df in extract_tables(email.get("original_body_html", "")):
386
  rows = df.to_dict("records")
387
- for row in rows:
388
- row.update({
389
  "email_id": email.get("email_id"),
390
  "fw_subject": email.get("fw_subject"),
391
  "fw_received_time": email.get("fw_received_time"),
392
- "original_subject": email.get("original_subject"),
393
- "original_from": email.get("original_from"),
394
- "original_sent_time": email.get("original_sent_time"),
395
  "source_section": section,
396
  })
397
- if section == "Cash Only Transactions":
398
- cash_rows.extend(rows)
399
- elif section == "Withdrawals":
400
- wd_rows.extend(rows)
401
- elif section == "Deposits":
402
- dep_rows.extend(rows)
403
 
404
- st.session_state.cash_df = pd.DataFrame(cash_rows) if cash_rows else pd.DataFrame()
405
- st.session_state.withdrawals_df = pd.DataFrame(wd_rows) if wd_rows else pd.DataFrame()
406
- st.session_state.deposits_df = pd.DataFrame(dep_rows) if dep_rows else pd.DataFrame()
407
 
408
  with st.spinner("Building action notices..."):
409
  all_rows = cash_rows + wd_rows + dep_rows
@@ -412,55 +424,38 @@ if st.button("β–Ά Run", disabled=not st.session_state.token):
412
  if all_rows else pd.DataFrame()
413
  )
414
 
415
- st.success(f"Done! Found {len(st.session_state.rj_emails)} RJ forwarded emails.")
 
 
416
 
417
  # ── Results ────────────────────────────────────────────────────────────────────
418
  if st.session_state.rj_emails:
419
  st.divider()
420
  st.markdown("### Results")
421
-
422
  tab1, tab2, tab3, tab4 = st.tabs([
423
- "πŸ“‹ Action Notices",
424
- "πŸ’΅ Cash Transactions",
425
- "⬆ Withdrawals",
426
- "⬇ Deposits",
427
  ])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
 
429
- with tab1:
430
- df = st.session_state.notice_df
431
- if df is not None and not df.empty:
432
- st.dataframe(df, width="stretch", hide_index=True)
433
- else:
434
- st.info("No action notices generated.")
435
-
436
- with tab2:
437
- df = st.session_state.cash_df
438
- if df is not None and not df.empty:
439
- st.dataframe(df, width="stretch", hide_index=True)
440
- else:
441
- st.info("No cash transactions found.")
442
-
443
- with tab3:
444
- df = st.session_state.withdrawals_df
445
- if df is not None and not df.empty:
446
- st.dataframe(df, width="stretch", hide_index=True)
447
- else:
448
- st.info("No withdrawals found.")
449
-
450
- with tab4:
451
- df = st.session_state.deposits_df
452
- if df is not None and not df.empty:
453
- st.dataframe(df, width="stretch", hide_index=True)
454
- else:
455
- st.info("No deposits found.")
456
-
457
- # Download
458
  st.divider()
459
  excel_bytes = to_excel({
460
- "Action Notices": st.session_state.notice_df,
461
  "Cash Transactions": st.session_state.cash_df,
462
- "Withdrawals": st.session_state.withdrawals_df,
463
- "Deposits": st.session_state.deposits_df,
464
  })
465
  st.download_button(
466
  label="⬇ Download Excel Report",
 
1
+ import os
2
+ import json
3
+ import urllib.parse
4
+ import secrets
5
+
6
  import requests
7
+ import streamlit as st
8
  import pandas as pd
 
9
  from bs4 import BeautifulSoup
10
  from io import BytesIO, StringIO
11
+ import re
12
  from datetime import datetime, timedelta
13
 
14
  # ── Page config ────────────────────────────────────────────────────────────────
 
18
  layout="wide",
19
  )
20
 
 
21
  st.markdown("""
22
  <style>
23
  @import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:wght@400;500;600&display=swap');
24
+ html, body, [class*="css"] { font-family: 'DM Sans', sans-serif; }
25
+ h1, h2, h3 { font-family: 'DM Serif Display', serif; }
 
 
 
 
 
 
26
  .block-container { padding-top: 2rem; max-width: 960px; }
 
27
  .status-card {
28
  background: white;
29
  border: 1px solid #e5e0d8;
 
33
  box-shadow: 0 1px 4px rgba(0,0,0,0.05);
34
  }
35
  .step-label {
36
+ font-size: 0.72rem; font-weight: 600;
37
+ letter-spacing: 0.1em; text-transform: uppercase;
38
+ color: #9a8f82; margin-bottom: 0.25rem;
 
 
 
 
 
 
 
 
39
  }
40
+ .step-title { font-size: 1.1rem; font-weight: 600; color: #1a1612; }
41
  .badge-success {
42
  background: #d4edda; color: #155724;
43
  padding: 2px 10px; border-radius: 20px;
 
49
  font-size: 0.78rem; font-weight: 600;
50
  }
51
  .stButton > button {
52
+ background: #1a1612 !important; color: white !important;
53
+ border: none !important; border-radius: 8px !important;
 
 
54
  font-family: 'DM Sans', sans-serif !important;
55
  font-weight: 600 !important;
56
  padding: 0.55rem 1.4rem !important;
57
  font-size: 0.9rem !important;
58
  }
59
+ .stButton > button:hover { background: #3d3530 !important; }
60
+ .login-btn a {
61
+ display: inline-block;
62
+ background: #0078d4;
63
+ color: white !important;
64
+ text-decoration: none;
65
+ padding: 0.55rem 1.6rem;
66
+ border-radius: 8px;
67
  font-weight: 600;
68
+ font-size: 0.95rem;
69
+ font-family: 'DM Sans', sans-serif;
 
 
 
 
 
 
 
 
70
  }
71
+ .login-btn a:hover { background: #106ebe; }
72
  </style>
73
  """, unsafe_allow_html=True)
74
 
75
+ # ── Config (from HF Secrets) ───────────────────────────────────────────────────
76
+ CLIENT_ID = os.environ.get("AZURE_CLIENT_ID", "bfcbb298-4cc1-496e-9d9b-ff8c2d967a3a")
77
+ CLIENT_SECRET = os.environ.get("AZURE_CLIENT_SECRET", "") # set in HF Secrets
78
+ TENANT_ID = os.environ.get("AZURE_TENANT_ID", "5dac2bf2-8842-4788-ae07-33fb103b55d6")
79
+ REDIRECT_URI = os.environ.get("REDIRECT_URI", "") # e.g. https://yourspace.hf.space/
80
 
81
+ AUTHORITY = f"https://login.microsoftonline.com/{TENANT_ID}"
82
+ AUTH_ENDPOINT = f"{AUTHORITY}/oauth2/v2.0/authorize"
83
+ TOKEN_ENDPOINT= f"{AUTHORITY}/oauth2/v2.0/token"
84
+ SCOPES = "Mail.Read offline_access"
85
+
86
+ DEFAULT_SENDER = "agabrielse@newfrontieradvisors.com"
87
+ DEFAULT_TARGET = "cfeng@newfrontieradvisors.com"
88
+
89
+ # ── Session defaults ───────────────────────────────────────────────────────────
90
+ for k, v in {
91
  "token": None,
92
+ "oauth_state": None,
93
  "rj_emails": [],
94
  "cash_df": None,
95
  "withdrawals_df": None,
96
  "deposits_df": None,
97
  "notice_df": None,
98
  }.items():
99
+ if k not in st.session_state:
100
+ st.session_state[k] = v
101
 
102
+ # ── OAuth helpers ──────────────────────────────────────────────────────────────
103
 
104
+ def build_auth_url():
105
+ state = secrets.token_urlsafe(16)
106
+ st.session_state.oauth_state = state
107
+ params = {
108
+ "client_id": CLIENT_ID,
109
+ "response_type": "code",
110
+ "redirect_uri": REDIRECT_URI,
111
+ "response_mode": "query",
112
+ "scope": SCOPES,
113
+ "state": state,
114
+ }
115
+ return AUTH_ENDPOINT + "?" + urllib.parse.urlencode(params)
116
 
117
 
118
+ def exchange_code_for_token(code: str) -> str:
119
+ resp = requests.post(TOKEN_ENDPOINT, data={
120
+ "client_id": CLIENT_ID,
121
+ "client_secret": CLIENT_SECRET,
122
+ "code": code,
123
+ "redirect_uri": REDIRECT_URI,
124
+ "grant_type": "authorization_code",
125
+ }, timeout=30)
126
+ resp.raise_for_status()
127
+ return resp.json()["access_token"]
128
+
129
+
130
+ # ── Check for OAuth callback (code in URL query params) ───────────────────────
131
+ query_params = st.query_params
132
+ if not st.session_state.token and "code" in query_params:
133
+ code = query_params["code"]
134
+ state = query_params.get("state", "")
135
+ if state == st.session_state.oauth_state or not st.session_state.oauth_state:
136
+ try:
137
+ with st.spinner("Completing sign-in..."):
138
+ st.session_state.token = exchange_code_for_token(code)
139
+ # Clear the code from the URL
140
+ st.query_params.clear()
141
+ st.rerun()
142
+ except Exception as e:
143
+ st.error(f"Sign-in failed: {e}")
144
+
145
+ # ── Email helpers ──────────────────────────────────────────────────────────────
146
+
147
+ def get_emails(token, sender, top=50):
148
  url = "https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messages"
149
  headers = {
150
  "Authorization": f"Bearer {token}",
 
152
  }
153
  params = {
154
  "$top": top,
155
+ "$filter": f"from/emailAddress/address eq '{sender}'",
156
+ "$select": "id,subject,from,toRecipients,ccRecipients,receivedDateTime,body",
157
  }
158
+ r = requests.get(url, headers=headers, params=params, timeout=60)
159
+ r.raise_for_status()
160
+ return r.json().get("value", [])
161
 
162
 
163
+ def extract_info(msg):
164
+ def addrs(lst):
165
+ return [(r.get("emailAddress") or {}).get("address") for r in (lst or [])
166
+ if (r.get("emailAddress") or {}).get("address")]
 
 
 
167
  body_html = (msg.get("body") or {}).get("content", "")
168
  return {
169
  "id": msg.get("id"),
170
  "subject": msg.get("subject"),
171
  "from": ((msg.get("from") or {}).get("emailAddress") or {}).get("address"),
172
+ "to": addrs(msg.get("toRecipients")),
173
+ "cc": addrs(msg.get("ccRecipients")),
174
  "received_time": msg.get("receivedDateTime"),
175
  "body_html": body_html,
176
  }
177
 
178
 
179
  def html_to_text(html):
180
+ return BeautifulSoup(html, "html.parser").get_text("\n", strip=True) if html else ""
 
 
181
 
182
 
183
  def reply_marker_html(body_html):
 
190
  return "".join(str(n) for n in marker.next_siblings).strip()
191
 
192
 
193
+ def fwd_headers(body_html):
194
  text = html_to_text(body_html)
195
  def grab(label):
196
  m = re.search(rf"{label}:\s*(.*)", text)
197
  return m.group(1).strip() if m else ""
198
+ return {k: grab(v) for k, v in {
199
+ "forwarded_from": "From",
200
+ "forwarded_sent_time": "Sent",
201
+ "forwarded_to": "To",
202
+ "forwarded_cc": "Cc",
203
+ "forwarded_subject": "Subject",
204
+ }.items()}
205
+
206
+
207
+ def is_rj(info):
208
+ subj = (info.get("subject") or "").lower()
209
+ html = info.get("body_html") or ""
210
+ text = html_to_text(html).lower()
211
+ if "rj emails--overview" in subj:
212
  return False
213
+ has_fwd = 'id="divRplyFwdMsg"' in html or ("from:" in text and "sent:" in text)
214
+ if not has_fwd:
215
  return False
216
  return any([
217
+ "raymond james" in subj, "raymond james" in text,
218
+ "amstradingadmin@raymondjames.com" in text,
219
+ "ams managed operations" in subj, "ams managed operations" in text,
220
+ "direct asset transfers" in subj,
221
+ "distribution & cash balance check" in subj,
 
 
222
  ])
223
 
224
 
225
+ def build_rj(messages, target):
226
  rows = []
227
+ for i, msg in enumerate(messages, 1):
228
+ info = extract_info(msg)
229
+ recips = [a.lower() for a in info.get("to", []) + info.get("cc", [])]
230
+ if target.lower() not in recips:
231
  continue
232
+ if not is_rj(info):
233
  continue
234
+ fwd = fwd_headers(info.get("body_html", ""))
235
  rows.append({
236
+ "email_id": info.get("id") or f"msg_{i}",
237
  "fw_subject": info.get("subject"),
238
  "fw_from": info.get("from"),
239
  "fw_to": ", ".join(info.get("to", [])),
240
  "fw_cc": ", ".join(info.get("cc", [])),
241
  "fw_received_time": info.get("received_time"),
242
  "fw_body_html": info.get("body_html", ""),
243
+ **{k: fwd.get(k, "") for k in fwd},
 
 
 
 
244
  "original_body_html": reply_marker_html(info.get("body_html", "")),
245
  })
246
  return rows
 
269
  return out
270
 
271
 
272
+ def parse_date(v):
273
+ if not v:
274
  return None
275
  for fmt in ("%m/%d/%Y", "%m/%d/%y", "%A, %B %d, %Y %I:%M %p"):
276
  try:
277
+ return datetime.strptime(v.strip(), fmt)
278
  except ValueError:
279
  pass
280
  return None
 
283
  def build_notice(row):
284
  section = row.get("source_section")
285
  account = row.get("Account") or row.get("customer_account")
286
+ amount = row.get("Cash Amount") or row.get("Amount")
 
 
287
  if section == "Withdrawals":
288
  input_date = row.get("Input date")
289
+ deadline = parse_date(input_date)
290
  response_time = (
291
+ (deadline + timedelta(days=8)).strftime("%Y-%m-%d")
292
+ if deadline else f"8 calendar days after {input_date}"
293
  )
294
  action = (
295
  f"Enter withdrawal in portal for account {account} for {amount}. "
296
  f"Schedule 8 calendar days after input date {input_date}."
297
  )
298
+ elif section == "Deposits" or (row.get("Type") or "").lower() == "deposit":
299
  response_time = "As soon as practical after email receipt"
300
  action = (
301
  f"Message dutytrader in Teams: contribution/deposit for account {account} "
 
305
  response_time = "ASAP"
306
  action = (
307
  f"Halt account and advise of pending trades on {account}. "
308
+ f"Immediate cash movement: {amount}."
309
  )
310
  return {
311
+ "email_id": row.get("email_id"),
312
+ "sent_time": row.get("fw_received_time"),
313
  "response_time_needed": response_time,
314
+ "account": account,
315
+ "action": action,
316
+ "source_section": section,
317
  }
318
 
319
 
320
+ def to_excel(dfs):
321
  buf = BytesIO()
322
+ with pd.ExcelWriter(buf, engine="openpyxl") as w:
323
+ for name, df in dfs.items():
324
  if df is not None and not df.empty:
325
+ df.to_excel(w, sheet_name=name, index=False)
326
  return buf.getvalue()
327
 
328
 
329
  # ── UI ─────────────────────────────────────────────────────────────────────────
330
 
331
  st.markdown("## πŸ“¬ Raymond James Email Processor")
332
+ st.markdown("Pulls forwarded RJ emails, extracts cash movement tables, and generates action notices.")
333
  st.divider()
334
 
335
+ # Step 1 β€” Sign in
336
+ st.markdown('<div class="status-card">', unsafe_allow_html=True)
337
  st.markdown('<div class="step-label">Step 1</div>', unsafe_allow_html=True)
338
+ col1, col2 = st.columns([5, 1])
339
  with col1:
340
  st.markdown('<div class="step-title">Sign in to Microsoft</div>', unsafe_allow_html=True)
341
  with col2:
 
345
  st.markdown('<span class="badge-pending">Not signed in</span>', unsafe_allow_html=True)
346
 
347
  if not st.session_state.token:
348
+ auth_url = build_auth_url()
349
+ st.markdown(
350
+ f'<div class="login-btn"><a href="{auth_url}" target="_self">πŸ” Sign in with Microsoft</a></div>',
351
+ unsafe_allow_html=True,
352
+ )
353
+ st.caption("You'll be redirected to Microsoft's login page and back automatically.")
 
 
 
354
  else:
355
  st.markdown("You are signed in. βœ“")
356
+ if st.button("Sign out"):
357
+ st.session_state.token = None
358
+ st.rerun()
359
 
360
+ st.markdown('</div>', unsafe_allow_html=True)
361
+
362
+ # Step 2 β€” Config
363
+ st.markdown('<div class="status-card">', unsafe_allow_html=True)
364
  st.markdown('<div class="step-label">Step 2</div>', unsafe_allow_html=True)
365
  st.markdown('<div class="step-title">Configure</div>', unsafe_allow_html=True)
366
+ st.markdown("")
367
  col_a, col_b, col_c = st.columns(3)
368
  with col_a:
369
+ sender_email = st.text_input("Sender email (who forwards RJ emails)", value=DEFAULT_SENDER)
 
 
 
370
  with col_b:
371
+ target_email = st.text_input("Recipient email to filter for", value=DEFAULT_TARGET)
 
 
 
372
  with col_c:
373
  top_n = st.number_input("Max emails to fetch", min_value=5, max_value=200, value=50, step=5)
374
+ st.markdown('</div>', unsafe_allow_html=True)
375
 
376
  # Step 3 β€” Run
377
+ st.markdown('<div class="status-card">', unsafe_allow_html=True)
378
  st.markdown('<div class="step-label">Step 3</div>', unsafe_allow_html=True)
379
+ st.markdown('<div class="step-title">Fetch & Process</div>', unsafe_allow_html=True)
380
+ st.markdown("")
381
 
382
  if st.button("β–Ά Run", disabled=not st.session_state.token):
383
  with st.spinner("Fetching emails from Outlook..."):
384
  try:
385
+ raw = get_emails(st.session_state.token, sender_email, int(top_n))
386
+ except requests.HTTPError as e:
387
+ if e.response.status_code == 401:
388
+ st.error("Session expired. Please sign in again.")
389
+ st.session_state.token = None
390
+ else:
391
+ st.error(f"API error: {e}")
392
  st.stop()
393
 
394
+ with st.spinner("Filtering Raymond James emails..."):
395
+ st.session_state.rj_emails = build_rj(raw, target_email)
396
 
397
+ with st.spinner("Extracting cash tables..."):
398
  cash_rows, wd_rows, dep_rows = [], [], []
399
  for email in st.session_state.rj_emails:
400
  for section, df in extract_tables(email.get("original_body_html", "")):
401
  rows = df.to_dict("records")
402
+ for r in rows:
403
+ r.update({
404
  "email_id": email.get("email_id"),
405
  "fw_subject": email.get("fw_subject"),
406
  "fw_received_time": email.get("fw_received_time"),
407
+ "original_subject": email.get("forwarded_subject", ""),
408
+ "original_from": email.get("forwarded_from", ""),
409
+ "original_sent_time": email.get("forwarded_sent_time", ""),
410
  "source_section": section,
411
  })
412
+ if section == "Cash Only Transactions": cash_rows.extend(rows)
413
+ elif section == "Withdrawals": wd_rows.extend(rows)
414
+ elif section == "Deposits": dep_rows.extend(rows)
 
 
 
415
 
416
+ st.session_state.cash_df = pd.DataFrame(cash_rows) if cash_rows else pd.DataFrame()
417
+ st.session_state.withdrawals_df = pd.DataFrame(wd_rows) if wd_rows else pd.DataFrame()
418
+ st.session_state.deposits_df = pd.DataFrame(dep_rows) if dep_rows else pd.DataFrame()
419
 
420
  with st.spinner("Building action notices..."):
421
  all_rows = cash_rows + wd_rows + dep_rows
 
424
  if all_rows else pd.DataFrame()
425
  )
426
 
427
+ st.success(f"Done! Found **{len(st.session_state.rj_emails)}** RJ forwarded emails.")
428
+
429
+ st.markdown('</div>', unsafe_allow_html=True)
430
 
431
  # ── Results ────────────────────────────────────────────────────────────────────
432
  if st.session_state.rj_emails:
433
  st.divider()
434
  st.markdown("### Results")
 
435
  tab1, tab2, tab3, tab4 = st.tabs([
436
+ "πŸ“‹ Action Notices", "πŸ’΅ Cash Transactions", "⬆ Withdrawals", "⬇ Deposits"
 
 
 
437
  ])
438
+ for tab, key, label in [
439
+ (tab1, "notice_df", "No action notices generated."),
440
+ (tab2, "cash_df", "No cash transactions found."),
441
+ (tab3, "withdrawals_df", "No withdrawals found."),
442
+ (tab4, "deposits_df", "No deposits found."),
443
+ ]:
444
+ with tab:
445
+ df = st.session_state[key]
446
+ if df is not None and not df.empty:
447
+ # Hide HTML columns
448
+ display_df = df.drop(columns=[c for c in df.columns if "html" in c.lower()], errors="ignore")
449
+ st.dataframe(display_df, use_container_width=True, hide_index=True)
450
+ else:
451
+ st.info(label)
452
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
  st.divider()
454
  excel_bytes = to_excel({
455
+ "Action Notices": st.session_state.notice_df,
456
  "Cash Transactions": st.session_state.cash_df,
457
+ "Withdrawals": st.session_state.withdrawals_df,
458
+ "Deposits": st.session_state.deposits_df,
459
  })
460
  st.download_button(
461
  label="⬇ Download Excel Report",