Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions .github/workflows/daily-pa.yml
Original file line number Diff line number Diff line change
@@ -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
Binary file not shown.
Binary file not shown.
25 changes: 25 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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 .
53 changes: 47 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
19 changes: 19 additions & 0 deletions reports/2025-10-06.md
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions src/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__all__ = []
Binary file added src/__pycache__/__init__.cpython-313.pyc
Binary file not shown.
Binary file added src/__pycache__/calendar_analyzer.cpython-313.pyc
Binary file not shown.
Binary file added src/__pycache__/config.cpython-313.pyc
Binary file not shown.
Binary file added src/__pycache__/deliver.cpython-313.pyc
Binary file not shown.
Binary file added src/__pycache__/formatter.cpython-313.pyc
Binary file not shown.
Binary file added src/__pycache__/gmail_client.cpython-313.pyc
Binary file not shown.
Binary file added src/__pycache__/graph_client.cpython-313.pyc
Binary file not shown.
Binary file added src/__pycache__/main.cpython-313.pyc
Binary file not shown.
Binary file added src/__pycache__/triage.cpython-313.pyc
Binary file not shown.
62 changes: 62 additions & 0 deletions src/calendar_analyzer.py
Original file line number Diff line number Diff line change
@@ -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)
63 changes: 63 additions & 0 deletions src/config.py
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions src/deliver.py
Original file line number Diff line number Diff line change
@@ -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()
Loading