From 7f984812ba1d8e639d87103f646dcf9c68a7ac44 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Oct 2025 09:48:13 +0000 Subject: [PATCH] feat: Implement Daily PA email and calendar brief Co-authored-by: basvanderkruijt --- .env.example | 37 +++++ .github/workflows/daily-pa.yml | 30 ++++ .../__pycache__/cap.cpython-313.pyc | Bin 0 -> 294 bytes .../test_cap.cpython-313-pytest-8.4.2.pyc | Bin 0 -> 1577 bytes Makefile | 25 ++++ README.md | 53 ++++++- reports/2025-10-06.md | 19 +++ requirements.txt | 13 ++ src/__init__.py | 1 + src/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 138 bytes .../calendar_analyzer.cpython-313.pyc | Bin 0 -> 3711 bytes src/__pycache__/config.cpython-313.pyc | Bin 0 -> 3184 bytes src/__pycache__/deliver.cpython-313.pyc | Bin 0 -> 2749 bytes src/__pycache__/formatter.cpython-313.pyc | Bin 0 -> 4720 bytes src/__pycache__/gmail_client.cpython-313.pyc | Bin 0 -> 8022 bytes src/__pycache__/graph_client.cpython-313.pyc | Bin 0 -> 4657 bytes src/__pycache__/main.cpython-313.pyc | Bin 0 -> 6244 bytes src/__pycache__/triage.cpython-313.pyc | Bin 0 -> 5353 bytes src/calendar_analyzer.py | 62 ++++++++ src/config.py | 63 ++++++++ src/deliver.py | 44 ++++++ src/formatter.py | 63 ++++++++ src/gcal_client.py | 109 ++++++++++++++ src/gmail_client.py | 138 +++++++++++++++++ src/graph_client.py | 77 ++++++++++ src/main.py | 139 ++++++++++++++++++ src/triage.py | 101 +++++++++++++ ...ndar_analyzer.cpython-313-pytest-8.4.2.pyc | Bin 0 -> 2776 bytes ...est_formatter.cpython-313-pytest-8.4.2.pyc | Bin 0 -> 3646 bytes .../test_triage.cpython-313-pytest-8.4.2.pyc | Bin 0 -> 3379 bytes tests/test_calendar_analyzer.py | 14 ++ tests/test_formatter.py | 17 +++ tests/test_triage.py | 26 ++++ 33 files changed, 1025 insertions(+), 6 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/daily-pa.yml create mode 100644 07-Errors and Exception Handling/__pycache__/cap.cpython-313.pyc create mode 100644 07-Errors and Exception Handling/__pycache__/test_cap.cpython-313-pytest-8.4.2.pyc create mode 100644 Makefile create mode 100644 reports/2025-10-06.md create mode 100644 requirements.txt create mode 100644 src/__init__.py create mode 100644 src/__pycache__/__init__.cpython-313.pyc create mode 100644 src/__pycache__/calendar_analyzer.cpython-313.pyc create mode 100644 src/__pycache__/config.cpython-313.pyc create mode 100644 src/__pycache__/deliver.cpython-313.pyc create mode 100644 src/__pycache__/formatter.cpython-313.pyc create mode 100644 src/__pycache__/gmail_client.cpython-313.pyc create mode 100644 src/__pycache__/graph_client.cpython-313.pyc create mode 100644 src/__pycache__/main.cpython-313.pyc create mode 100644 src/__pycache__/triage.cpython-313.pyc create mode 100644 src/calendar_analyzer.py create mode 100644 src/config.py create mode 100644 src/deliver.py create mode 100644 src/formatter.py create mode 100644 src/gcal_client.py create mode 100644 src/gmail_client.py create mode 100644 src/graph_client.py create mode 100644 src/main.py create mode 100644 src/triage.py create mode 100644 tests/__pycache__/test_calendar_analyzer.cpython-313-pytest-8.4.2.pyc create mode 100644 tests/__pycache__/test_formatter.cpython-313-pytest-8.4.2.pyc create mode 100644 tests/__pycache__/test_triage.cpython-313-pytest-8.4.2.pyc create mode 100644 tests/test_calendar_analyzer.py create mode 100644 tests/test_formatter.py create mode 100644 tests/test_triage.py diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..fa8c24a45 --- /dev/null +++ b/.env.example @@ -0,0 +1,37 @@ +# Scheduling +RUN_AT=07:15 +TZ=Europe/Amsterdam + +# Email / Gmail +GMAIL_USER= +GMAIL_CLIENT_ID= +GMAIL_CLIENT_SECRET= +GMAIL_REDIRECT_URI=http://localhost:8080/ +GMAIL_TOKEN_PATH=config/gmail_token.json + +# Microsoft Graph (Calendar) +PROVIDER_CALENDAR=graph +MS_TENANT_ID= +MS_CLIENT_ID= +MS_CLIENT_SECRET= +MS_CACHE_PATH=config/msal_cache.json + +# Google Calendar (alternative) +GCAL_TOKEN_PATH=config/gcal_token.json + +# Delivery (optional) +DELIVERY_EMAIL_ENABLED=false +DELIVERY_SMTP_ENABLED=false +DELIVERY_TEAMS_ENABLED=false +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +SMTP_FROM= +TEAMS_WEBHOOK_URL= + +# App +EMAIL_MAX_THREADS=200 +OUTPUT_DIR=reports +VIP_SENDERS_PATH=config/vip.txt +FEATURES_DRAFT_REPLIES=false diff --git a/.github/workflows/daily-pa.yml b/.github/workflows/daily-pa.yml new file mode 100644 index 000000000..70cd15b2a --- /dev/null +++ b/.github/workflows/daily-pa.yml @@ -0,0 +1,30 @@ +name: Daily PA + +on: + schedule: + - cron: '15 6 * * *' # 07:15 Europe/Amsterdam ~ 06:15 UTC (DST varies) + workflow_dispatch: {} + +env: + TZ: Europe/Amsterdam + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install deps + run: | + python -m venv .venv + . .venv/bin/activate + pip install --upgrade pip + pip install -r requirements.txt + - name: Run Daily PA once + env: + PYTHONPATH: . + run: | + . .venv/bin/activate + python -m src.main --once diff --git a/07-Errors and Exception Handling/__pycache__/cap.cpython-313.pyc b/07-Errors and Exception Handling/__pycache__/cap.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7abac996fedceff66149d9bf44a101e1461b53d2 GIT binary patch literal 294 zcmey&%ge<81ebarX9NN1#~=<2FhUuhK}x1F1T#o66fvYTYBD9mWIzg-fS4JGKPv%= zsSKeELCCT|F%U3?Pz)*znv8y$jJH@zGD~t&feeTE9+vWm`_-CL91JGKqnw1Pi>_Bc2E0DOwVUwGmQks)$SHubAg0+J@ R`+=E}k?}JVBclXZEdUW5Irsno literal 0 HcmV?d00001 diff --git a/07-Errors and Exception Handling/__pycache__/test_cap.cpython-313-pytest-8.4.2.pyc b/07-Errors and Exception Handling/__pycache__/test_cap.cpython-313-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c35017f626b417bd78db2525985d7010115e1f2d GIT binary patch literal 1577 zcmc&!OHUL*5boKTogJ11L;-m$GGf%Mgog)0qDCPEV=$m$ATg2H8J2;axH~g;_Xd`e z@wpc-CPa>2`8OgVkk-TlCvJqryVX5Ayb`(6hw7@X>guYmtEy60mqf6Be%Z~viXpTs zglNU9Or;FWDzcEJJViv2du&{#F%ykzByNTpo#;}NAcQdzHcpp?l zp47}HE$!Y5gzU=*fo;pNO~iGh!76K_MP*UR#P@_enT-s@u2hA}EnrsBtc;SxEQP2d z4x(8q(72@m)vY*5Eb5sAPfRJT>e zrfBCut5GMvz%{ATi9?HdMn#~eY4V}|C7-;eft_{w2Znmb2=NIuZExNjTh2NGbA8Wz z0%G3v7W)~bj^UNQpv3E>;Cl`R2lKqS3QOOqV;x?hH-WrGI}KOM+V94uZ|Se;wfW7) z8)bdRNNpRf8%FC|%lFnT<4#57eIbypqzb;rN@fV`N@_w%A-tppq9S!bBG5=92>go7 zv;bhbK|Vx*@{Ut5oL1Hf7ZDhp=Yer;%NQU{V16-Rq)&ozo0<#x68yfIbxF2JSJEfJ z0qx^ZJ1VvR1FKnrwdArKwgaCs;s-g0^3GFmomGn=Kt*$k>ghcPupieSZ5hK7v2`?= zF}Q)TXBQldc@pD-KVO6o!aRks=!mMO0pkUiPzIIlcs|AirU@cqn28D?6XY2y7eS#aY)2+~8+12$qL(fdI5RYg($AoKyfZ}^3hhgyR&qLg#n^<5kF hU57|W``t)Ol~*>^wjC|?dE(u~$7lO0(%NOt{{YsWY+nEX literal 0 HcmV?d00001 diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..abe550a6f --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +PYTHON ?= python3 +VENV ?= .venv +PIP := $(VENV)/bin/pip +PY := $(VENV)/bin/python +PYTEST := $(VENV)/bin/pytest +RUFF := $(VENV)/bin/ruff + +.PHONY: setup run test fmt lint + +setup: + $(PYTHON) -m venv $(VENV) + $(PIP) install --upgrade pip + $(PIP) install -r requirements.txt + +run: + $(PY) -m src.main --once + +test: + $(PYTEST) -q + +fmt: + $(RUFF) format + +lint: + $(RUFF) check . diff --git a/README.md b/README.md index a7cd5b6b4..c5226edc3 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,50 @@ -# Complete-Python-3-Bootcamp -Course Files for Complete Python 3 Bootcamp Course on Udemy +# Daily PA -Copyright(©) by Pierian Data Inc. +A small, secure service that checks your email and calendar every morning and produces a concise daily brief. -Get it now for 95% off with the link: -https://www.udemy.com/complete-python-bootcamp/?couponCode=COMPLETE_GITHUB +## Quickstart -Thanks! +1. Copy `.env.example` to `.env` and fill values. +2. Create a virtualenv and install dependencies: + +```bash +make setup +``` + +3. Run once locally: + +```bash +make run +``` + +4. Configure OAuth for Gmail (read-only) and Microsoft Graph (Calendars.Read). Tokens are cached under `config/` paths. + +5. Schedule via cron or GitHub Actions. See `.github/workflows/daily-pa.yml` for CI template. + +## Modules + +- `src/config.py`: Pydantic settings and feature flags +- `src/gmail_client.py`: Gmail API client (read-only) +- `src/graph_client.py`: Microsoft Graph Calendar client +- `src/gcal_client.py`: Google Calendar client (alternative) +- `src/triage.py`: Email triage scoring and actionable detection +- `src/calendar_analyzer.py`: Calendar conflicts and gaps analysis +- `src/formatter.py`: Markdown renderer +- `src/deliver.py`: Email/Teams/webhook delivery, file save +- `src/main.py`: Entrypoint and orchestration + +## Tests + +Run: + +```bash +make test +``` + +Tests cover triage, calendar analysis, and formatting. + +## Security & Privacy + +- Read-only scopes by default +- Secrets via `.env`/keychain only +- No secrets in logs; addresses redacted to domain where logged diff --git a/reports/2025-10-06.md b/reports/2025-10-06.md new file mode 100644 index 000000000..8bab6e431 --- /dev/null +++ b/reports/2025-10-06.md @@ -0,0 +1,19 @@ +# Daily Brief — 2025-10-06 (Europe/Amsterdam) + +Status: 🟡 Degraded (runtime 0s) + +Top 5 Priorities + +Inbox Summary +- Unread: 0 | Starred: 0 | Waiting reply: 0 +| Sender | Subject | Age | Action | +|---|---|---|---| + +Deadlines & Follow-ups + +Calendar (Today) +| Start–End | Title | Location/Join | Prep | +|---|---|---|---| + +Risks/Conflicts +- Data unavailable \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..98e508cd2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +google-api-python-client>=2.140.0 +google-auth>=2.35.0 +google-auth-oauthlib>=1.2.0 +msal>=1.31.0 +requests>=2.32.3 +python-dotenv>=1.0.1 +pydantic>=2.7.4 +pydantic-settings>=2.2.1 +rich>=13.7.1 +python-dateutil>=2.9.0.post0 +tzdata>=2024.1 +pytest>=8.3.3 +ruff>=0.6.9 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 000000000..a9a2c5b3b --- /dev/null +++ b/src/__init__.py @@ -0,0 +1 @@ +__all__ = [] diff --git a/src/__pycache__/__init__.cpython-313.pyc b/src/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..96d8c4a89f2ec96650b697a3d0011409a2e6d667 GIT binary patch literal 138 zcmey&%ge<81S|UI$j%SU QjEsyoxOf`bi&%hM01`+akN^Mx literal 0 HcmV?d00001 diff --git a/src/__pycache__/calendar_analyzer.cpython-313.pyc b/src/__pycache__/calendar_analyzer.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1a6cde23b5ff1b70b76b365f51c8685ec6035e8b GIT binary patch literal 3711 zcmbUkTTmO<_3lHf*Xn@~29wxXurXFzn@~dt7!wnR#uY^rDY6|uva{AMvOuilyNek# zq)SrPi0Oe(`tLa6Ou0CFEiP=p#rK?+j>!RcW-$Y93A znPE1_Vm4^O7IV%Hb3rS%25s0Tpvz1XYDE!CGm3ECbV+7+>4UJnOtk{l_8qDt;*8jv z(FBW~aYumPBwPW?a7wD0(j`5SQZ<;`Wl5J}NlDWJv|)pZq9>*l!%m>ABy|av*`Fpf z-LQ?O36qqJmCrXnOld-Gj{|ZaT}GISAWTOn%(THhFS8M*9VjNonwSOFxE;)DVm4T_ z@2ojY%n57!4%KC1ZfuEr0$#&42ztzB6nW@|qUxJAH~|xj8x~EMux>DlDjSTZW5c2+ z^rT|glBt+^=ZvdzjY~kjV%R5A2~|uc)Jemh!f{DWWEA|Zo!q#*LDF@QrYM@hrm>P< zLZKzju!*87O(~*iI7D$OB~K>_&WqypX(?HzctmkLfi)e3D{2bJu&~Scu+Siips${Y ziEs!`T%FbxO%(AyxXCvBka4JECWR-pv=mc1G#u-QNlDN}!XU@IKR%wG#Uuz#K+K>W z1Mo5Wv$OuzXwhDGYozFO-wGDp1oOPQ&c0go_-=)Yo~CuSspx2Y#x~-E75>_e?E>=u z=vXVb$ri91?lWb5!%8}Z^-VvB`nKM*?Mn{40&Ysl(yU=mrc#sAHARwfJ?u3sIH65y zra?luk?2WmQ_}b~g0)lt5v>tG?tXBcJy`U#uCuL0r~eu2FENn4v2qRpiVz1sh+k@Z zjzZ`|+kF(J!hlteO1`Od2T~dMu^nj2cBvfs2t^^cAp_}NTD3$i_2}$Pgj6odh1qIJ z2z{UYDBX7fAqg%XWy03)@3 z)yOr3MxjoPBta|oKWg<6LQVu#%o??IQKV{3*d|!j8nw2KyswU*|kqKW1fUT)u?24#C8lKQiV4t%I@R=NRK%Bd|7C};F0k|Yx$hbo(fwTw$X%YH_&40lN*x+H3)VN|W@En`<#jG?2C#BQyK4X&d zhD(PwD=BJ9ETzgCCgmewHCW|_t{GIqpd^F3YA|rJ!9x3`<0G&^BV}JO$6TpSd1Xac zV!8-I#*>K{@V>rxy{g{(10r%9tn<$~^kviFgJl`2ch8fa)$Wy!{DI(xcldcD@*Vj{6XLza`ozq6&{97ufR|HEdU6iVx51X zDcf|fc`jHyaumSpKb#8{8~4u*{l(v$d$Z6!Sn$6yH?qwk_ba!lqLaVdbEoHS|DFC% z267kIo$W<_-v)o6z#qs-&v>Eea?f`X=P&eU`|k}r2o~y(Z}_?jzOFA^y(`p)tGD3l zE%MD9d`p3EdC>BVKm6PdlD~0#En({UVPt8TzxX*TeCgc(z_B(|aE9}2*mOZCz~LcK z*)%!+F~LZ!!lwx!Z+U@cx^({5R)j09^n8 literal 0 HcmV?d00001 diff --git a/src/__pycache__/config.cpython-313.pyc b/src/__pycache__/config.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..de3779723254b1ae093e24d9c97e1f429a50bbfb GIT binary patch literal 3184 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 literal 0 HcmV?d00001 diff --git a/src/__pycache__/formatter.cpython-313.pyc b/src/__pycache__/formatter.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1b3e3dcf097cc5346fbad660a6a008ed10bf8fa6 GIT binary patch literal 4720 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 Oyu>iRuL$Pq#QzJ4IK7Vm literal 0 HcmV?d00001 diff --git a/src/__pycache__/graph_client.cpython-313.pyc b/src/__pycache__/graph_client.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..daf9efb3b5625e98f2add4f21de9d544ea323227 GIT binary patch literal 4657 zcmb6dOK{uP@qq;Ye@c`fS(a!;cFe{RWvi0EjpN9QWy$)45EDCc3PU6)!65|N`D*}fBNnmL6q=+k6(_K5$~I}o zb_2Fg(UUZ$Cmq;fjOi)oqzk(y-Pmo69aA3cfjN#TFZQxd)-~mu^kYBJ+^i>Q!vWTd zgO}|L>PM`v8?pWoo2h%m><|tav;fe877d4&9Es3F!jYhKA1_I=%B!L*DKPY8d6m!P zc|}RsbT@`Ef})h;%c@#bP9&2{m@h657Q_se6*;F4X5>Qh`r*N$OmhiF zXEz7b=4AkGqss_WEW$RH!uI{((#te!e-rI;aLz5rWk7DgdA4v~1M&gRZ$JU; zSPCXWdcd-L{JJ2i55piAlP%R9ippbEx5Kh@yP{&=R$asmD#Ik_P9^#w@ zpuVAdcvS^nK~QveUd|XkE(a{PHF>VeqQrq?-ux|^NjP;k$4Pua;5gmOaRoVB$`jbn zao0+GzDWsjTu#J_O57>QKnBFNT?9mo6Sz!XgcT1KS8xEBjE1aUA@2DZ`qI~SbGG4$+?;L%!Z$B8LeZNujSy3# znMRNxLp`6-J-FAJxnuvDVM%Qs0rJlMV_mGR8(oEC06}dxAP4JVX#;Yy4(wWTCcL`u z9Ep=L!@`GqKuV@56F`Jq@PinkHrD~TjV^}s1Z#W?TYM?n!nJxhI8>TSIa?SD3Zh{f zP~6tI)dSWz{4ETDw(0Tjq>xcRvW_HcJ5Hxjo>**rODJKVDfe5)Aulh9QtLR#64_5YDybr$*L|w|rXX<{KC>(&+`3z79!s4rC_vsj zCQCW7$!z)*&Td{bV^sG~v)ugn%$b>a?)+I}G~tvO=H$5P|-dIu|D&?%Pr z#k??2^o|j=I?eILB1spDkk8?Ruvmp`MLdrCpd*nkN}>wU+P-aT2Q6$5;h%u!9n@gr zzX)u!Gxhe-2koN`=ES{Y^^%@WrhVBTYAY3Wibm`qY!f6L>Kgdw7 zLIXh+iQVbC)m4uqtC8gIeD~+INK%VTR-B)QVvShWolCba)niAiv7_3HIqme420(1N z@^Zu1wc1hhJ-ZR@)%G2|=lrAh_g-!6)!MPww4)cbzDu>}>ze=dMkw~7R1NLd-22V2 z+a5$Dw;EyhPiT#>+a8oaB%hTnBzr5b1kGI4EH6wxv$eQ^!#mgEq;zu$2d&l=u44#qqS0lpe=v$(43JDKITd;t1$urQiE3y5vqG$&+@c zwnDwd$a<5=DpuOiIcnm^2ua?w_ldl|?Yyu06k8I~OdFaQKuG?>6KD8`wxmCi9%z+H zbGGRN2>DPPNq)(3!zaP*Jfvl| zeb+g$r|9mm(vK9`MQi8(EFqeXSc#N}beWD?qb!`2bJVmOaGq4C6)P6B5ZQvF7L;nY zpjO|H(hKh9d}u?;>2R!jGwe;0OB$ZKgpLviSC(l zU$P)1GtDCTRZ)2Bv{7E3g_3(7a^H{2fdf!HZVmOo8T0Nhn{}k?C4t8Rj+cYRv?M9j z$-IYISZu;pb`QunE8xYIGCiQm#fPLWOW1YSG8E$iR&-~P$9zH2T_n-UB~^D^=kp~2 z6QAjRp#UlVOg0PY7FhTufN&d7ag4C|vZ7MV^D8q@t?Ir-IlD54h3l}4@*eA|Q3X)Y zDrHC!qdVpa5|bS?pBKbtOsY&wb|c|3NG*h^kr-vD#T|snhhKnB51O~h_{`b4+4D2= zx<`Zugp3P3JWjB1tt2R_f=?07Fy=)?;K&t2>f(~3(^nN)(w$JDfRoK~mt;*nki&9e zOHQKAxOQ4L=?>#U)a@ml$7f*z9w)PC@KaHzVGnrSDUfFaXb@GQpi{iz(U#+k73ef^ z7?fq?LBVBJm?UMPSBQV(|M{5}lRqb@nPsjipV@xRZKpgBGL#2!r@$o_Zm)-*sfM4a zh4)t+8<9RO9M|Hf?>qhy`ctU(;w#$el$PSOE1CM0LiI|ac16~>q87YXu{YZKSC`g= zwb!(P5iL4eaW#4e@7b%7Qx)e%sPluj-+%k=*u9QVqSera3f*w~>+Zc(_ufz4eT`7t z2P^Nb{QM_?K5($&Y_v0XCT>mKeQkBI-kzwOZOpQNyYbf>+Qp0}EPXn=Tw}xsv&*0C z`#APd?Cww3aINb|y=%1EHM+iXzyGtY@#^ey<-Ep-4W{Q~_ebv4J!^|K=5U=EsWKz$ z(tQ}5uQStCX8IFRyR=YaE?3SsI``bUaqGsackQKGXR_WoT&VQ}%* z#k<04sunv~j~%MU4z0ajiyf=H{CV4+-;C;Ers0o%F#X>2MtJW=cYnQm=t1|;I#cUD zvBAXa%-{oNaP4}H8Ey2$Kc4t#V)fFxt=2Q#=zW&_?cERUHz9W<`q=Ib1i$G*u^&7d zMB$D~;9uW9kGif>3b~*EoOtfss1qt8QfL`<^-O{`dQ6@rq()LpSkmbkSrUM4k+6~U z2(=kGcgADZJYgi{4SKoFc((3%`M!~GV}>wvkp{|Igvq-LA0`bsfcOe&pk77B%hG)M zk=h>bvl=o%A-dm1-`QLa?{`tB>MV`PdLv{Bl^Nsxi zUIqf38Kg`@^T>^?e!Y)Ak+Kf=Ax*H6p|B1Rpt( e&G*>mqV_&Q&}`aa_=pT2w;!Q8zD3X&>i!LgRwtSO literal 0 HcmV?d00001 diff --git a/src/__pycache__/main.cpython-313.pyc b/src/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..60294ea724445d21bf43b7e83d0b031c596fe44e GIT binary patch literal 6244 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 literal 0 HcmV?d00001 diff --git a/src/__pycache__/triage.cpython-313.pyc b/src/__pycache__/triage.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0a84796ef391a6256b3d001c61c36c0b14dcb853 GIT binary patch literal 5353 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 Zp_&q^ON8owxou4Ozx}(JBaGoW{|l1Cc9{SG literal 0 HcmV?d00001 diff --git a/src/calendar_analyzer.py b/src/calendar_analyzer.py new file mode 100644 index 000000000..c4e04ce9b --- /dev/null +++ b/src/calendar_analyzer.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import List, Optional + + +@dataclass +class AnalyzedEvent: + start: datetime + end: datetime + title: str + location: str + join_link: Optional[str] + organizer: str + attendees: List[str] + prep: Optional[str] + + +@dataclass +class CalendarReport: + today: List[AnalyzedEvent] + lookahead: List[AnalyzedEvent] + risks: List[str] + + +def analyze_calendar(events: List[AnalyzedEvent], now: datetime) -> CalendarReport: + today_date = now.date() + today_list: List[AnalyzedEvent] = [] + lookahead_list: List[AnalyzedEvent] = [] + risks: List[str] = [] + + events_sorted = sorted(events, key=lambda e: (e.start, e.end)) + + # Partition today vs lookahead + for e in events_sorted: + if e.start.date() == today_date: + today_list.append(e) + elif 0 <= (e.start.date() - today_date).days <= 3: + lookahead_list.append(e) + + # Risks: overlaps and short gaps + def detect_conflicts(evts: List[AnalyzedEvent]) -> None: + for i in range(len(evts) - 1): + a = evts[i] + b = evts[i + 1] + if a.end > b.start: + risks.append( + f"{a.title} overlaps {b.title} ({a.start:%H:%M}-{a.end:%H:%M} vs {b.start:%H:%M}-{b.end:%H:%M})" + ) + gap = (b.start - a.end).total_seconds() / 60.0 + if 0 <= gap < 30: + risks.append(f"<30m gap between {a.title} and {b.title} at {a.end:%H:%M}") + if not a.join_link: + risks.append(f"No join link: {a.title} at {a.start:%H:%M}") + if evts: + last = evts[-1] + if not last.join_link: + risks.append(f"No join link: {last.title} at {last.start:%H:%M}") + + detect_conflicts(today_list) + return CalendarReport(today=today_list, lookahead=lookahead_list, risks=risks) diff --git a/src/config.py b/src/config.py new file mode 100644 index 000000000..f6affca6e --- /dev/null +++ b/src/config.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from pathlib import Path +from typing import List, Optional + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class AppSettings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", case_sensitive=False) + + # Scheduling / Locale + RUN_AT: str = Field(default="07:15") + TZ: str = Field(default="Europe/Amsterdam") + + # Email / Gmail + GMAIL_USER: Optional[str] = None + GMAIL_CLIENT_ID: Optional[str] = None + GMAIL_CLIENT_SECRET: Optional[str] = None + GMAIL_REDIRECT_URI: str = Field(default="http://localhost:8080/") + GMAIL_TOKEN_PATH: Path = Field(default=Path("config/gmail_token.json")) + + # Microsoft Graph (preferred) + PROVIDER_CALENDAR: str = Field(default="graph") # or "google" + MS_TENANT_ID: Optional[str] = None + MS_CLIENT_ID: Optional[str] = None + MS_CLIENT_SECRET: Optional[str] = None + MS_CACHE_PATH: Path = Field(default=Path("config/msal_cache.json")) + + # Google Calendar (alternative) + GCAL_TOKEN_PATH: Path = Field(default=Path("config/gcal_token.json")) + + # Delivery (optional) + DELIVERY_EMAIL_ENABLED: bool = Field(default=False) + DELIVERY_SMTP_ENABLED: bool = Field(default=False) + DELIVERY_TEAMS_ENABLED: bool = Field(default=False) + + SMTP_HOST: Optional[str] = None + SMTP_PORT: int = Field(default=587) + SMTP_USER: Optional[str] = None + SMTP_PASS: Optional[str] = None + SMTP_FROM: Optional[str] = None + + TEAMS_WEBHOOK_URL: Optional[str] = None + + # App behavior + EMAIL_MAX_THREADS: int = Field(default=200, ge=1, le=500) + OUTPUT_DIR: Path = Field(default=Path("reports")) + VIP_SENDERS_PATH: Path = Field(default=Path("config/vip.txt")) + FEATURES_DRAFT_REPLIES: bool = Field(default=False) + + # Recipients + RECIPIENTS: List[str] = Field(default_factory=list) + + +def load_config() -> AppSettings: + settings = AppSettings() # loads from env + settings.OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + settings.GMAIL_TOKEN_PATH.parent.mkdir(parents=True, exist_ok=True) + settings.MS_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) + settings.GCAL_TOKEN_PATH.parent.mkdir(parents=True, exist_ok=True) + return settings diff --git a/src/deliver.py b/src/deliver.py new file mode 100644 index 000000000..efa9af0ba --- /dev/null +++ b/src/deliver.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import os +import smtplib +from dataclasses import dataclass +from datetime import datetime +from email.message import EmailMessage +from pathlib import Path +from typing import Iterable, List + +import requests + +from .config import AppSettings + + +def save_report(markdown: str, output_dir: Path, now: datetime) -> Path: + output_dir.mkdir(parents=True, exist_ok=True) + path = output_dir / f"{now:%Y-%m-%d}.md" + path.write_text(markdown, encoding="utf-8") + return path + + +def send_smtp(settings: AppSettings, subject: str, to: List[str], md_body: str, attachment_path: Path | None = None) -> None: + if not settings.SMTP_HOST or not settings.SMTP_USER or not settings.SMTP_PASS: + raise RuntimeError("SMTP not configured") + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = settings.SMTP_FROM or settings.SMTP_USER + msg["To"] = ", ".join(to) + msg.set_content(md_body) + + if attachment_path and attachment_path.exists(): + msg.add_attachment(attachment_path.read_text(encoding="utf-8"), subtype="markdown", filename=attachment_path.name) + + with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server: + server.starttls() + server.login(settings.SMTP_USER, settings.SMTP_PASS) + server.send_message(msg) + + +def send_teams(webhook_url: str, title: str, text: str) -> None: + payload = {"title": title, "text": text} + resp = requests.post(webhook_url, json=payload, timeout=15) + resp.raise_for_status() diff --git a/src/formatter.py b/src/formatter.py new file mode 100644 index 000000000..b5e9c117f --- /dev/null +++ b/src/formatter.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import List + +from .calendar_analyzer import AnalyzedEvent, CalendarReport +from .triage import ScoredThread, TriageResult + + +def format_event_row(e: AnalyzedEvent) -> str: + time_range = f"{e.start:%H:%M}–{e.end:%H:%M}" + location = e.join_link or e.location or "" + prep = e.prep or "" + return f"| {time_range} | {e.title} | {location} | {prep} |" + + +def render_markdown( + now: datetime, + triage: TriageResult, + cal_report: CalendarReport, + priorities: List[str], + degraded: bool = False, + runtime_s: int = 0, +) -> str: + status = "🟡 Degraded" if degraded else "✅ Healthy" + lines: List[str] = [] + lines.append(f"# Daily Brief — {now:%Y-%m-%d} (Europe/Amsterdam)") + lines.append("") + lines.append(f"Status: {status} (runtime {runtime_s}s)") + lines.append("") + lines.append("Top 5 Priorities") + for p in priorities[:5]: + lines.append(f"- {p}") + lines.append("") + lines.append("Inbox Summary") + lines.append(f"- Unread: {triage.counts.unread} | Starred: {triage.counts.starred} | Waiting reply: {triage.counts.waiting_reply}") + lines.append("| Sender | Subject | Age | Action |") + lines.append("|---|---|---|---|") + for s in triage.top10: + age_h = max(0, int((now - s.email.received_at).total_seconds() // 3600)) + sender = s.email.sender.split("<")[-1].strip("<>") + lines.append(f"| {sender} | {s.email.subject[:80]} | {age_h}h | {s.action_hint} |") + lines.append("") + lines.append("Deadlines & Follow-ups") + for f in triage.followups[:10]: + lines.append(f"- {f}") + lines.append("") + lines.append("Calendar (Today)") + lines.append("| Start–End | Title | Location/Join | Prep |") + lines.append("|---|---|---|---|") + for e in cal_report.today: + lines.append(format_event_row(e)) + lines.append("") + if cal_report.lookahead: + lines.append("Look-ahead") + for e in cal_report.lookahead: + lines.append(f"- {e.start:%a %H:%M} {e.title} ({e.location or e.join_link or ''})") + lines.append("") + lines.append("Risks/Conflicts") + for r in cal_report.risks: + lines.append(f"- {r}") + return "\n".join(lines) diff --git a/src/gcal_client.py b/src/gcal_client.py new file mode 100644 index 000000000..059cebd74 --- /dev/null +++ b/src/gcal_client.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import List, Optional + +from google.oauth2.credentials import Credentials +from google_auth_oauthlib.flow import InstalledAppFlow +from googleapiclient.discovery import build + +from .config import AppSettings + +GCAL_SCOPE = "https://www.googleapis.com/auth/calendar.readonly" + + +@dataclass +class GCalEvent: + start: datetime + end: datetime + summary: str + organizer: str + attendees: List[str] + location: str + join_link: Optional[str] + + +class GoogleCalendarClient: + def __init__(self, settings: AppSettings) -> None: + self.settings = settings + self._service = None + + def _credentials(self) -> Credentials: + token_path = str(self.settings.GCAL_TOKEN_PATH) + if not self.settings.GMAIL_CLIENT_ID or not self.settings.GMAIL_CLIENT_SECRET: + raise RuntimeError("Google OAuth client not configured") + creds = None + try: + creds = Credentials.from_authorized_user_file(token_path, [GCAL_SCOPE]) + except Exception: + creds = None + if not creds or not creds.valid: + if creds and creds.expired and creds.refresh_token: + from google.auth.transport.requests import Request + + creds.refresh(Request()) + else: + # Requires client secrets via env like Gmail + flow = InstalledAppFlow.from_client_config( + { + "installed": { + "client_id": self.settings.GMAIL_CLIENT_ID, + "client_secret": self.settings.GMAIL_CLIENT_SECRET, + "redirect_uris": [self.settings.GMAIL_REDIRECT_URI], + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + } + }, + [GCAL_SCOPE], + ) + creds = flow.run_local_server(port=0) + with open(token_path, "w") as token: + token.write(creds.to_json()) + return creds + + def _service_client(self): + if self._service is None: + creds = self._credentials() + self._service = build("calendar", "v3", credentials=creds, cache_discovery=False) + return self._service + + def fetch_events(self, start: datetime, end: datetime) -> List[GCalEvent]: + service = self._service_client() + events_result = ( + service.events() + .list( + calendarId="primary", + timeMin=start.isoformat() + "Z", + timeMax=end.isoformat() + "Z", + maxResults=100, + singleEvents=True, + orderBy="startTime", + ) + .execute() + ) + events: List[GCalEvent] = [] + for e in events_result.get("items", []): + start_dt = e.get("start", {}).get("dateTime") or e.get("start", {}).get("date") + end_dt = e.get("end", {}).get("dateTime") or e.get("end", {}).get("date") + if not start_dt or not end_dt: + continue + attendees = [a.get("email", "") for a in e.get("attendees", []) if isinstance(a, dict)] + organizer = e.get("organizer", {}).get("email", "") + location = e.get("location", "") or "" + join_link = None + hangout = e.get("hangoutLink") or e.get("conferenceData", {}).get("entryPoints", [{}])[0].get("uri") + if hangout: + join_link = "Google Meet" + events.append( + GCalEvent( + start=datetime.fromisoformat(start_dt.replace("Z", "+00:00")), + end=datetime.fromisoformat(end_dt.replace("Z", "+00:00")), + summary=e.get("summary", ""), + organizer=organizer, + attendees=attendees, + location=location, + join_link=join_link, + ) + ) + return events diff --git a/src/gmail_client.py b/src/gmail_client.py new file mode 100644 index 000000000..47fa197e7 --- /dev/null +++ b/src/gmail_client.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Iterable, List, Optional + +from google.oauth2.credentials import Credentials +from google_auth_oauthlib.flow import InstalledAppFlow +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError + +from .config import AppSettings + +GMAIL_READONLY_SCOPE = "https://www.googleapis.com/auth/gmail.readonly" +GMAIL_SEND_SCOPE = "https://www.googleapis.com/auth/gmail.send" + + +@dataclass +class EmailSnippet: + id: str + threadId: str + sender: str + subject: str + received_at: datetime + labels: List[str] + snippet: str + is_unread: bool + to_me: bool + + +class GmailClient: + def __init__(self, settings: AppSettings, enable_send: bool = False) -> None: + self.settings = settings + self.scopes = [GMAIL_READONLY_SCOPE] + if enable_send: + self.scopes.append(GMAIL_SEND_SCOPE) + self._service = None + + def _credentials(self) -> Credentials: + token_path = str(self.settings.GMAIL_TOKEN_PATH) + client_config = { + "installed": { + "client_id": self.settings.GMAIL_CLIENT_ID, + "client_secret": self.settings.GMAIL_CLIENT_SECRET, + "redirect_uris": [self.settings.GMAIL_REDIRECT_URI], + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + } + } + # Non-interactive guard: do not attempt local server login if not configured + if not self.settings.GMAIL_CLIENT_ID or not self.settings.GMAIL_CLIENT_SECRET: + raise RuntimeError("Gmail OAuth client not configured") + creds: Optional[Credentials] = None + try: + creds = Credentials.from_authorized_user_file(token_path, self.scopes) + except Exception: + creds = None + if not creds or not creds.valid: + if creds and creds.expired and creds.refresh_token: + from google.auth.transport.requests import Request + + creds.refresh(Request()) + else: + flow = InstalledAppFlow.from_client_config(client_config, self.scopes) + creds = flow.run_local_server(port=0) + with open(token_path, "w") as token: + token.write(creds.to_json()) + return creds + + def _service_client(self): + if self._service is None: + creds = self._credentials() + self._service = build("gmail", "v1", credentials=creds, cache_discovery=False) + return self._service + + def fetch_threads(self, since: datetime, max_threads: int = 200) -> List[EmailSnippet]: + service = self._service_client() + after_epoch = int(since.timestamp()) + q = f"after:{after_epoch}" + results = service.users().threads().list(userId="me", q=q, maxResults=max_threads).execute() + threads = results.get("threads", []) + snippets: List[EmailSnippet] = [] + for th in threads: + thread = ( + service.users() + .threads() + .get(userId="me", id=th["id"], format="metadata", metadataHeaders=["From", "To", "Subject", "Date"]) + .execute() + ) + messages = thread.get("messages", []) + if not messages: + continue + msg = messages[-1] # last message in thread + headers = {h["name"].lower(): h["value"] for h in msg.get("payload", {}).get("headers", [])} + labels = msg.get("labelIds", []) + snippet = msg.get("snippet", "") + date_hdr = headers.get("date") + received_at = datetime.now(timezone.utc) + if date_hdr: + try: + from email.utils import parsedate_to_datetime + + received_at = parsedate_to_datetime(date_hdr) + except Exception: + pass + sender = headers.get("from", "") + subject = headers.get("subject", "") + to_me = self.settings.GMAIL_USER and self.settings.GMAIL_USER.lower() in headers.get("to", "").lower() + is_unread = "UNREAD" in labels + snippets.append( + EmailSnippet( + id=msg.get("id"), + threadId=msg.get("threadId"), + sender=sender, + subject=subject, + received_at=received_at, + labels=list(labels), + snippet=snippet, + is_unread=is_unread, + to_me=bool(to_me), + ) + ) + return snippets + + def send_email(self, to: str, subject: str, body_md: str) -> None: + service = self._service_client() + from email.mime.text import MIMEText + import base64 + + message = MIMEText(body_md, _subtype="markdown") + message["to"] = to + message["from"] = self.settings.SMTP_FROM or self.settings.GMAIL_USER or "" + message["subject"] = subject + raw = base64.urlsafe_b64encode(message.as_bytes()).decode() + try: + service.users().messages().send(userId="me", body={"raw": raw}).execute() + except HttpError: + raise diff --git a/src/graph_client.py b/src/graph_client.py new file mode 100644 index 000000000..60e3f4f48 --- /dev/null +++ b/src/graph_client.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import List, Optional + +import msal +import requests + +from .config import AppSettings + +GRAPH_SCOPE = ["Calendars.Read", "offline_access"] +GRAPH_ENDPOINT = "https://graph.microsoft.com/v1.0" + + +@dataclass +class CalendarEvent: + start: datetime + end: datetime + subject: str + organizer: str + attendees: List[str] + location: str + join_link: Optional[str] + + +class GraphClient: + def __init__(self, settings: AppSettings) -> None: + self.settings = settings + self._app = msal.ConfidentialClientApplication( + authority=f"https://login.microsoftonline.com/{settings.MS_TENANT_ID}", + client_id=settings.MS_CLIENT_ID, + client_credential=settings.MS_CLIENT_SECRET, + token_cache=msal.SerializableTokenCache() + ) + + def _acquire_token(self) -> str: + result = self._app.acquire_token_silent(GRAPH_SCOPE, account=None) + if not result: + result = self._app.acquire_token_for_client(scopes=["https://graph.microsoft.com/.default"]) + if "access_token" not in result: + raise RuntimeError(f"MS Graph auth failed: {result.get('error_description')}") + return result["access_token"] + + def fetch_events(self, start: datetime, end: datetime) -> List[CalendarEvent]: + token = self._acquire_token() + url = f"{GRAPH_ENDPOINT}/me/calendarView?startDateTime={start.isoformat()}&endDateTime={end.isoformat()}" + headers = {"Authorization": f"Bearer {token}"} + params = {"$orderby": "start/dateTime", "$top": 100} + resp = requests.get(url, headers=headers, params=params, timeout=30) + resp.raise_for_status() + data = resp.json() + events: List[CalendarEvent] = [] + for item in data.get("value", []): + attendees = [a.get("emailAddress", {}).get("address", "") for a in item.get("attendees", [])] + organizer = item.get("organizer", {}).get("emailAddress", {}).get("address", "") + location = item.get("location", {}).get("displayName", "") + join_link = None + body = item.get("bodyPreview", "") + if "https://teams.microsoft.com" in body: + join_link = "Teams" + start_dt = item.get("start", {}).get("dateTime") + end_dt = item.get("end", {}).get("dateTime") + if not start_dt or not end_dt: + continue + events.append( + CalendarEvent( + start=datetime.fromisoformat(start_dt), + end=datetime.fromisoformat(end_dt), + subject=item.get("subject", ""), + organizer=organizer, + attendees=attendees, + location=location, + join_link=join_link, + ) + ) + return events diff --git a/src/main.py b/src/main.py new file mode 100644 index 000000000..add28480f --- /dev/null +++ b/src/main.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import argparse +import json +import os +import time +from datetime import datetime, timedelta + +from dateutil import tz +from rich.console import Console + +from .config import load_config +from .deliver import save_report, send_smtp, send_teams +from .formatter import render_markdown +from .triage import TriageResult, load_vip_list, score_threads + +console = Console() + + +def tz_now(tz_name: str) -> datetime: + return datetime.now(tz.gettz(tz_name)) + + +def fetch_email_and_calendar(settings, now: datetime): + degraded = False + triage: TriageResult | None = None + cal_report = None + priorities: list[str] = [] + + # Emails + try: + from .gmail_client import GmailClient + + gmail = GmailClient(settings, enable_send=settings.DELIVERY_EMAIL_ENABLED) + since = now - timedelta(hours=24) + emails = gmail.fetch_threads(since=since, max_threads=settings.EMAIL_MAX_THREADS) + vip_list = load_vip_list(str(settings.VIP_SENDERS_PATH)) + triage = score_threads(emails, vip_list) + # derive priorities from top threads + priorities = [f"{s.email.subject[:80]}" for s in triage.top10[:5]] + except Exception as e: + degraded = True + console.log(f"Email fetch failed: {e}") + + # Calendar + try: + from .calendar_analyzer import AnalyzedEvent, analyze_calendar + if settings.PROVIDER_CALENDAR == "graph": + from .graph_client import GraphClient + + client = GraphClient(settings) + start = now.replace(hour=0, minute=0, second=0, microsecond=0) + end = start + timedelta(days=4) + raw = client.fetch_events(start, end) + events = [ + AnalyzedEvent( + start=e.start, + end=e.end, + title=e.subject, + location=e.location, + join_link=e.join_link, + organizer=e.organizer, + attendees=e.attendees, + prep=None, + ) + for e in raw + ] + else: + from .gcal_client import GoogleCalendarClient + + client = GoogleCalendarClient(settings) + start = now.replace(hour=0, minute=0, second=0, microsecond=0) + end = start + timedelta(days=4) + raw = client.fetch_events(start, end) + events = [ + AnalyzedEvent( + start=e.start, + end=e.end, + title=e.summary, + location=e.location, + join_link=e.join_link, + organizer=e.organizer, + attendees=e.attendees, + prep=None, + ) + for e in raw + ] + cal_report = analyze_calendar(events, now) + except Exception as e: + degraded = True + console.log(f"Calendar fetch failed: {e}") + + return triage, cal_report, priorities, degraded + + +def main_once() -> int: + settings = load_config() + start_ts = time.time() + now = tz_now(settings.TZ) + + triage, cal_report, priorities, degraded = fetch_email_and_calendar(settings, now) + + if triage is None or cal_report is None: + degraded = True + # create empty structures + from .triage import TriageResult, TriagedCounts + from .calendar_analyzer import CalendarReport + + triage = TriageResult(top10=[], counts=TriagedCounts(unread=0, starred=0, waiting_reply=0), followups=[]) + cal_report = CalendarReport(today=[], lookahead=[], risks=["Data unavailable"]) + + md = render_markdown(now, triage, cal_report, priorities, degraded=degraded, runtime_s=int(time.time() - start_ts)) + saved_path = save_report(md, settings.OUTPUT_DIR, now) + + subject = f"Daily Brief – {now:%Y-%m-%d}" + try: + if settings.DELIVERY_EMAIL_ENABLED and settings.RECIPIENTS: + if settings.DELIVERY_SMTP_ENABLED: + send_smtp(settings, subject, settings.RECIPIENTS, md, attachment_path=saved_path) + else: + from .gmail_client import GmailClient + + g = GmailClient(settings, enable_send=True) + for r in settings.RECIPIENTS: + g.send_email(r, subject, md) + if settings.DELIVERY_TEAMS_ENABLED and settings.TEAMS_WEBHOOK_URL: + send_teams(settings.TEAMS_WEBHOOK_URL, subject, md[:15000]) + except Exception as e: + console.log(f"Delivery failed: {e}") + + console.log(f"Report saved to {saved_path}") + return 0 + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--once", action="store_true", help="Run once and exit") + args = parser.parse_args() + raise SystemExit(main_once()) diff --git a/src/triage.py b/src/triage.py new file mode 100644 index 000000000..b891dda7e --- /dev/null +++ b/src/triage.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Dict, Iterable, List, Optional, Tuple + +from .gmail_client import EmailSnippet + +DEFAULT_KEYWORDS = [ + "urgent", + "incident", + "p1", + "deadline", + "invoice", + "ns", + "kpn", + "meeting", + "reschedule", +] + + +@dataclass +class TriagedCounts: + unread: int + starred: int + waiting_reply: int + + +@dataclass +class ScoredThread: + email: EmailSnippet + score: float + action_hint: str + + +@dataclass +class TriageResult: + top10: List[ScoredThread] + counts: TriagedCounts + followups: List[str] + + +def load_vip_list(path: str) -> List[str]: + try: + with open(path, "r", encoding="utf-8") as f: + return [line.strip().lower() for line in f if line.strip()] + except FileNotFoundError: + return [] + + +def score_threads(emails: Iterable[EmailSnippet], vip_list: List[str]) -> TriageResult: + scored: List[ScoredThread] = [] + now = datetime.utcnow() + + unread = 0 + starred = 0 # Placeholder: would need label mapping + waiting_reply = 0 + + for e in emails: + score = 0.0 + age_hours = max(0.0, (now - e.received_at.replace(tzinfo=None)).total_seconds() / 3600.0) + sender_lower = e.sender.lower() + subject_lower = e.subject.lower() + snippet_lower = e.snippet.lower() + + if e.is_unread: + score += 2.0 + unread += 1 + if e.to_me: + score += 1.5 + if any(vip in sender_lower for vip in vip_list): + score += 2.5 + if any(kw in subject_lower or kw in snippet_lower for kw in DEFAULT_KEYWORDS): + score += 1.0 + if "invoice" in subject_lower: + score += 0.5 + if age_hours > 48: + waiting_reply += 1 + score += 1.0 + # Penalize known noise labels if available + if any(lbl in {"CATEGORY_UPDATES", "CATEGORY_FORUMS", "CATEGORY_PROMOTIONS"} for lbl in e.labels): + score -= 3.0 + + action_hint = "Review" + if "?" in e.subject or "?" in e.snippet: + action_hint = "Reply" + elif "invoice" in subject_lower: + action_hint = "Pay/Forward to finance" + elif "meeting" in subject_lower or "reschedule" in subject_lower: + action_hint = "Schedule/Reschedule" + elif e.is_unread: + action_hint = "Read" + + scored.append(ScoredThread(email=e, score=score, action_hint=action_hint)) + + scored.sort(key=lambda s: s.score, reverse=True) + top10 = scored[:10] + + counts = TriagedCounts(unread=unread, starred=starred, waiting_reply=waiting_reply) + followups = [s.email.subject for s in scored if (now - s.email.received_at.replace(tzinfo=None)).days >= 1][:10] + return TriageResult(top10=top10, counts=counts, followups=followups) diff --git a/tests/__pycache__/test_calendar_analyzer.cpython-313-pytest-8.4.2.pyc b/tests/__pycache__/test_calendar_analyzer.cpython-313-pytest-8.4.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5a5c5d25d821389ea9e26eefd01ee5e0e33c3210 GIT binary patch literal 2776 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 literal 0 HcmV?d00001 diff --git a/tests/test_calendar_analyzer.py b/tests/test_calendar_analyzer.py new file mode 100644 index 000000000..9f6dd742e --- /dev/null +++ b/tests/test_calendar_analyzer.py @@ -0,0 +1,14 @@ +from datetime import datetime, timedelta + +from src.calendar_analyzer import AnalyzedEvent, analyze_calendar + + +def test_analyze_calendar_detects_overlap_and_gaps(): + now = datetime(2025, 1, 1, 9, 0) + a = AnalyzedEvent(start=now, end=now + timedelta(minutes=30), title="A", location="", join_link=None, organizer="o", attendees=[], prep=None) + b = AnalyzedEvent(start=now + timedelta(minutes=25), end=now + timedelta(minutes=60), title="B", location="", join_link="Teams", organizer="o", attendees=[], prep=None) + c = AnalyzedEvent(start=now + timedelta(minutes=75), end=now + timedelta(minutes=105), title="C", location="", join_link=None, organizer="o", attendees=[], prep=None) + + report = analyze_calendar([a, b, c], now) + assert any("overlaps" in r for r in report.risks) + assert any("<30m gap" in r for r in report.risks) diff --git a/tests/test_formatter.py b/tests/test_formatter.py new file mode 100644 index 000000000..dfb9eed61 --- /dev/null +++ b/tests/test_formatter.py @@ -0,0 +1,17 @@ +from datetime import datetime, timedelta + +from src.calendar_analyzer import AnalyzedEvent, CalendarReport +from src.formatter import render_markdown +from src.triage import ScoredThread, TriageResult, TriagedCounts + + +def test_render_markdown_has_sections(): + now = datetime(2025, 1, 1, 8, 0) + triage = TriageResult(top10=[], counts=TriagedCounts(unread=0, starred=0, waiting_reply=0), followups=[]) + cal = CalendarReport(today=[], lookahead=[], risks=["risk"]) + md = render_markdown(now, triage, cal, priorities=["p1", "p2"], degraded=False, runtime_s=1) + assert "Daily Brief" in md + assert "Top 5 Priorities" in md + assert "Inbox Summary" in md + assert "Calendar (Today)" in md + assert "Risks/Conflicts" in md diff --git a/tests/test_triage.py b/tests/test_triage.py new file mode 100644 index 000000000..80304f051 --- /dev/null +++ b/tests/test_triage.py @@ -0,0 +1,26 @@ +from datetime import datetime, timedelta + +from src.triage import ScoredThread, TriageResult, TriagedCounts, score_threads +from src.gmail_client import EmailSnippet + + +def make_email(subject: str, unread: bool = True, to_me: bool = True, labels=None): + return EmailSnippet( + id="1", + threadId="t1", + sender="vip@example.com", + subject=subject, + received_at=datetime.utcnow() - timedelta(hours=1), + labels=labels or [], + snippet="please review", + is_unread=unread, + to_me=to_me, + ) + + +def test_score_threads_orders_top10(): + emails = [make_email(f"urgent {i}") for i in range(12)] + triage = score_threads(emails, vip_list=["vip@example.com"]) + assert len(triage.top10) == 10 + assert triage.counts.unread == 12 +