carrief0908 commited on
Commit
702e45b
Β·
verified Β·
1 Parent(s): ff74774
Files changed (2) hide show
  1. app.py +470 -0
  2. requirements.txt +7 -3
app.py ADDED
@@ -0,0 +1,470 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ────────────────────────────────────────────────────────────────
11
+ st.set_page_config(
12
+ page_title="RJ Email Processor",
13
+ page_icon="πŸ“¬",
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;
34
+ border-radius: 12px;
35
+ padding: 1.5rem 2rem;
36
+ margin-bottom: 1.2rem;
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;
55
+ font-size: 0.78rem; font-weight: 600;
56
+ }
57
+ .badge-pending {
58
+ background: #fff3cd; color: #856404;
59
+ padding: 2px 10px; border-radius: 20px;
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}",
127
+ "Prefer": 'outlook.body-content-type="html"',
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):
165
+ if not body_html:
166
+ return ""
167
+ soup = BeautifulSoup(body_html, "html.parser")
168
+ marker = soup.find(id="divRplyFwdMsg")
169
+ if not marker:
170
+ return ""
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
234
+
235
+
236
+ def extract_tables(body_html):
237
+ if not body_html:
238
+ return []
239
+ soup = BeautifulSoup(body_html, "html.parser")
240
+ out = []
241
+ for node in soup.find_all(string=True):
242
+ label = node.strip()
243
+ if label not in {"Cash Only Transactions", "Withdrawals", "Deposits"}:
244
+ continue
245
+ table = node.find_next("table")
246
+ if not table:
247
+ continue
248
+ try:
249
+ dfs = pd.read_html(StringIO(str(table)))
250
+ except ValueError:
251
+ continue
252
+ for df in dfs:
253
+ df.columns = [str(c).strip() for c in df.columns]
254
+ df = df.dropna(how="all").reset_index(drop=True)
255
+ out.append((label, df))
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
268
+
269
+
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} "
291
+ f"({amount}) approved to invest in model."
292
+ )
293
+ else:
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:
330
+ if st.session_state.token:
331
+ st.markdown('<span class="badge-success">βœ“ Signed in</span>', unsafe_allow_html=True)
332
+ else:
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
410
+ st.session_state.notice_df = (
411
+ pd.DataFrame([build_notice(r) for r in all_rows])
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",
467
+ data=excel_bytes,
468
+ file_name=f"rj_emails_{datetime.now().strftime('%Y%m%d_%H%M')}.xlsx",
469
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
470
+ )
requirements.txt CHANGED
@@ -1,3 +1,7 @@
1
- altair
2
- pandas
3
- streamlit
 
 
 
 
 
1
+ streamlit>=1.35.0
2
+ msal>=1.28.0
3
+ requests>=2.31.0
4
+ beautifulsoup4>=4.12.0
5
+ pandas>=2.0.0
6
+ openpyxl>=3.1.0
7
+ lxml>=5.0.0