Skip to content

Commit 06d19e2

Browse files
committed
Merge branch 'main' into dev
2 parents 487e7b0 + 707ee5e commit 06d19e2

9 files changed

Lines changed: 343 additions & 19 deletions

File tree

.cursorindexingignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
2+
# Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references
3+
.specstory/**
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Support Ticket: Enable Automated Email Sending for Future of Development Platform
2+
3+
**Subject:**
4+
Enable Automated Email Sending for Future Trends & Signals (Microsoft Graph Application Permissions)
5+
6+
**Description:**
7+
We are building a feature for the Future of Development platform that sends automated email digests (e.g., weekly summaries, notifications) to users. To do this securely and reliably, we need to configure Microsoft Graph **Application permissions** for our Azure AD app registration, and set up a dedicated internal email account for sending these digests.
8+
9+
## Requirements
10+
11+
1. **Azure AD App Registration:**
12+
- App Name: **Future Trends & Signals**
13+
- Client ID: `4b179bfc-6621-409a-a1ed-ad141c12eb11`
14+
- The app must be able to send emails automatically (without manual login) using Microsoft Graph.
15+
16+
2. **Permissions Needed:**
17+
- Add **Mail.Send** (Application) permission to the app registration.
18+
- Grant **admin consent** for this permission.
19+
20+
3. **Service Account:**
21+
- Please create or confirm an internal mailbox (e.g., `futureofdevelopment@undp.org`) to be used as the sender for these digests.
22+
- Ensure this mailbox is licensed and can send emails.
23+
24+
4. **Configuration Steps (for ITU):**
25+
- Go to Azure Portal > Azure Active Directory > App registrations > "Future of Development".
26+
- Under **API permissions**, click **Add a permission** > **Microsoft Graph** > **Application permissions**.
27+
- Add **Mail.Send** (Application).
28+
- Click **Grant admin consent for [Your Org]**.
29+
- Confirm that the mailbox `futureofdevelopment@undp.org` is active and can be used by the app for sending emails.
30+
31+
5. **Verification:**
32+
- After configuration, we will verify by running:
33+
```sh
34+
az ad app permission list --id 4b179bfc-6621-409a-a1ed-ad141c12eb11
35+
```
36+
- We should see a `"type": "Role"` for Mail.Send.
37+
38+
## Why This Is Needed
39+
- Delegated permissions require a user to log in interactively, which is not suitable for scheduled/automated jobs.
40+
- Application permissions allow our backend to send emails on a schedule, securely and without manual intervention.
41+
42+
## What We Need from ITU
43+
- Add and grant the required permissions as described above.
44+
- Confirm the service account is ready and provide any additional configuration details if needed.
45+
46+
Thank you for your support! If you need more technical details, please see the attached documentation or contact our team.

scripts/run_direct_test.sh

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/bin/bash
2+
# Script to run the direct email test in the correct virtual environment
3+
4+
# Change to the project directory
5+
cd "$(dirname "$0")/.."
6+
7+
# Activate the virtual environment
8+
source venv/bin/activate
9+
10+
# Run the direct test script
11+
python scripts/test_email_direct.py andrew.maguire@undp.org
12+
13+
# Deactivate the virtual environment
14+
deactivate

scripts/send_digest_smtp.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
#!/usr/bin/env python
2+
"""
3+
Script to send a weekly digest email using SMTP (e.g., Office 365, Gmail).
4+
This is for testing SMTP-based delivery to a distribution list or group.
5+
"""
6+
7+
import os
8+
import sys
9+
import asyncio
10+
import argparse
11+
import logging
12+
import smtplib
13+
from email.mime.text import MIMEText
14+
from typing import List
15+
16+
# Add the parent directory to sys.path to allow importing the app modules
17+
parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
18+
sys.path.insert(0, parent_dir)
19+
20+
from src.services.weekly_digest import WeeklyDigestService
21+
22+
logging.basicConfig(
23+
level=logging.INFO,
24+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
25+
handlers=[logging.StreamHandler()]
26+
)
27+
logger = logging.getLogger(__name__)
28+
29+
async def generate_digest_html(days=None, status=None, limit=None):
30+
digest_service = WeeklyDigestService()
31+
signals_list = await digest_service.get_recent_signals(days=days, status=status, limit=limit)
32+
logger.info(f"Fetched {len(signals_list)} signals for digest.")
33+
html_content = digest_service.generate_email_html(signals_list)
34+
return html_content
35+
36+
def send_email_smtp(smtp_server, smtp_port, username, password, to_emails, subject, html_content):
37+
msg = MIMEText(html_content, 'html')
38+
msg['Subject'] = subject
39+
msg['From'] = username
40+
msg['To'] = ', '.join(to_emails)
41+
with smtplib.SMTP(smtp_server, smtp_port) as server:
42+
server.starttls()
43+
server.login(username, password)
44+
server.sendmail(msg['From'], to_emails, msg.as_string())
45+
logger.info(f"Email sent via SMTP to {to_emails}")
46+
47+
def main():
48+
parser = argparse.ArgumentParser(description="Send weekly digest email via SMTP")
49+
parser.add_argument('--recipients', nargs='+', required=True, help="Email addresses to send the digest to (space-separated)")
50+
parser.add_argument('--days', type=int, default=None, help="Number of days to look back for signals (optional)")
51+
parser.add_argument('--status', nargs='+', default=None, help="Signal statuses to filter by (e.g. Draft Approved). Optional.")
52+
parser.add_argument('--limit', type=int, default=None, help="Maximum number of signals to include (optional)")
53+
parser.add_argument('--smtp-server', type=str, default='smtp.office365.com', help="SMTP server address")
54+
parser.add_argument('--smtp-port', type=int, default=587, help="SMTP server port")
55+
parser.add_argument('--smtp-user', type=str, required=True, help="SMTP username (your email)")
56+
parser.add_argument('--smtp-password', type=str, required=True, help="SMTP password (or app password)")
57+
parser.add_argument('--test', action='store_true', help="Run in test mode (adds [TEST] to the subject line)")
58+
args = parser.parse_args()
59+
60+
subject = "UNDP Futures Weekly Digest"
61+
if args.test:
62+
subject = f"[TEST] {subject}"
63+
64+
# Validate email addresses
65+
for email in args.recipients:
66+
if "@" not in email:
67+
logger.error(f"Invalid email address: {email}")
68+
sys.exit(1)
69+
70+
# Map status strings to Status enum if provided
71+
status_enum = None
72+
if args.status:
73+
from src.services.weekly_digest import Status
74+
status_enum = [Status(s) for s in args.status]
75+
76+
# Generate digest HTML
77+
html_content = asyncio.run(generate_digest_html(days=args.days, status=status_enum, limit=args.limit))
78+
79+
# Send email via SMTP
80+
send_email_smtp(
81+
smtp_server=args.smtp_server,
82+
smtp_port=args.smtp_port,
83+
username=args.smtp_user,
84+
password=args.smtp_password,
85+
to_emails=args.recipients,
86+
subject=subject,
87+
html_content=html_content
88+
)
89+
90+
if __name__ == "__main__":
91+
main()

scripts/test_direct_email.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
#!/usr/bin/env python
2+
"""
3+
Direct test script for sending emails using Graph API.
4+
This bypasses the normal email service for testing purposes.
5+
"""
6+
7+
import os
8+
import sys
9+
import asyncio
10+
import logging
11+
from datetime import datetime
12+
from dotenv import load_dotenv
13+
14+
# Add the parent directory to sys.path
15+
parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
16+
sys.path.insert(0, parent_dir)
17+
18+
# Load environment variables
19+
env_file = os.path.join(parent_dir, '.env.local')
20+
if os.path.exists(env_file):
21+
load_dotenv(env_file)
22+
print(f"Loaded environment from {env_file}")
23+
else:
24+
print(f"Warning: {env_file} not found")
25+
26+
# Import our direct authentication module
27+
from src.services.graph_direct_auth import GraphDirectAuth
28+
29+
# Set up logging
30+
logging.basicConfig(
31+
level=logging.DEBUG,
32+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
33+
handlers=[
34+
logging.StreamHandler()
35+
]
36+
)
37+
logger = logging.getLogger(__name__)
38+
39+
async def test_direct_email(to_email: str) -> None:
40+
"""Send a test email using direct Graph API authentication"""
41+
try:
42+
print(f"\nSending test email to {to_email}...")
43+
44+
# Get sender email from environment
45+
from_email = os.getenv('MS_FROM_EMAIL', 'exo.futures.curators@undp.org')
46+
47+
# Create the GraphDirectAuth client
48+
graph_auth = GraphDirectAuth()
49+
50+
# Create HTML content with current timestamp
51+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
52+
53+
html_content = f"""
54+
<!DOCTYPE html>
55+
<html>
56+
<head>
57+
<meta charset="utf-8">
58+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
59+
<title>UNDP Futures - Direct Test Email</title>
60+
<style>
61+
body {{
62+
font-family: Arial, sans-serif;
63+
line-height: 1.6;
64+
color: #333;
65+
max-width: 600px;
66+
margin: 0 auto;
67+
padding: 20px;
68+
}}
69+
.header {{
70+
background-color: #0768AC;
71+
color: white;
72+
padding: 20px;
73+
text-align: center;
74+
margin-bottom: 20px;
75+
}}
76+
.content {{
77+
padding: 20px;
78+
background-color: #f5f5f5;
79+
border-left: 4px solid #0768AC;
80+
}}
81+
.footer {{
82+
margin-top: 30px;
83+
padding-top: 15px;
84+
border-top: 1px solid #ddd;
85+
font-size: 0.9em;
86+
color: #666;
87+
text-align: center;
88+
}}
89+
</style>
90+
</head>
91+
<body>
92+
<div class="header">
93+
<h1>UNDP Futures - Direct Test Email</h1>
94+
</div>
95+
96+
<div class="content">
97+
<h2>Direct Graph API Email Test</h2>
98+
<p>This is a test email sent using direct Graph API authentication.</p>
99+
<p>If you're receiving this, it means the email configuration is working!</p>
100+
<p>Sent at: {timestamp}</p>
101+
<p>Configuration:</p>
102+
<ul>
103+
<li>From Email: {from_email}</li>
104+
<li>To Email: {to_email}</li>
105+
<li>Tenant ID: {os.getenv('TENANT_ID')}</li>
106+
</ul>
107+
</div>
108+
109+
<div class="footer">
110+
<p>This is a test email from the UNDP Futures platform.</p>
111+
<p>&copy; United Nations Development Programme</p>
112+
</div>
113+
</body>
114+
</html>
115+
"""
116+
117+
# Send the email
118+
success = await graph_auth.send_email(
119+
from_email=from_email,
120+
to_emails=[to_email],
121+
subject=f"[TEST] UNDP Futures - Direct Email Test ({timestamp})",
122+
content=html_content,
123+
content_type="HTML"
124+
)
125+
126+
if success:
127+
print("\n=====================================================")
128+
print(f"✅ Test email successfully sent to {to_email}!")
129+
print("=====================================================\n")
130+
else:
131+
print("\n=====================================================")
132+
print(f"❌ Failed to send test email to {to_email}")
133+
print("=====================================================\n")
134+
135+
except Exception as e:
136+
logger.error(f"Error in test_direct_email: {str(e)}", exc_info=True)
137+
print("\n=====================================================")
138+
print(f"❌ Error sending test email: {str(e)}")
139+
print("=====================================================\n")
140+
141+
def main():
142+
"""Main entry point"""
143+
if len(sys.argv) < 2:
144+
print("Usage: python test_direct_email.py <recipient_email>")
145+
sys.exit(1)
146+
147+
recipient_email = sys.argv[1]
148+
asyncio.run(test_direct_email(recipient_email))
149+
150+
if __name__ == "__main__":
151+
main()

scripts/test_email.sh

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/bin/bash
2+
# Test script to run the email digest in the correct virtual environment
3+
4+
# Change to the project directory
5+
cd "$(dirname "$0")/.."
6+
7+
# Activate the virtual environment
8+
source venv/bin/activate
9+
10+
# Run the digest script with test parameters
11+
python scripts/send_digest.py --recipients andrew.maguire@undp.org --days 14 --test
12+
13+
# Deactivate the virtual environment
14+
deactivate

src/database/users.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ async def search_users(cursor: AsyncCursor, filters: UserFilters) -> UserPage:
3636
params = filters.model_dump()
3737

3838
# Only add roles filter if present and non-empty
39-
if getattr(filters, "roles", None):
40-
where_clauses.append("role = ANY(%(roles)s)")
39+
# if getattr(filters, "roles", None):
40+
# where_clauses.append("role = ANY(%(roles)s)")
4141

4242
# Always allow searching by query
4343
where_clauses.append("(%(query)s IS NULL OR name ~* %(query)s)")

0 commit comments

Comments
 (0)