public-ready-init
This commit is contained in:
commit
565cc5ace7
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@ -0,0 +1,9 @@
|
||||
target/
|
||||
.git/
|
||||
.gitignore
|
||||
README.md
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
11
.env.example
Normal file
11
.env.example
Normal file
@ -0,0 +1,11 @@
|
||||
# Server configuration
|
||||
HOST=127.0.0.1
|
||||
PORT=4000
|
||||
|
||||
# API Key for authentication (generate a secure random string)
|
||||
API_KEY=your-secret-api-key-here
|
||||
|
||||
# Google Service Account Key (base64 encoded)
|
||||
# The service account key should be base64 encoded JSON content
|
||||
# Generate with: cat service-account.json | base64 -w 0
|
||||
GOOGLE_SERVICE_ACCOUNT_KEY=your-base64-encoded-service-account-key-here
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
/target
|
||||
/.env
|
||||
/service_account_key.json
|
||||
Cargo.lock
|
||||
*.log
|
||||
.DS_Store
|
||||
.idea
|
||||
36
Cargo.toml
Normal file
36
Cargo.toml
Normal file
@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "emailer-microservice"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "A Rust microservice for Gmail API integration with domain-wide delegation support"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
# Web framework
|
||||
actix-web = "4.4"
|
||||
actix-cors = "0.7"
|
||||
|
||||
# HTTP client for Gmail API
|
||||
reqwest = { version = "0.12.22", features = ["json"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Environment variables
|
||||
dotenv = "0.15"
|
||||
|
||||
# Logging
|
||||
env_logger = "0.11.8"
|
||||
log = "0.4"
|
||||
|
||||
# Error handling
|
||||
thiserror = "2.0.12"
|
||||
|
||||
# Base64 for service account credentials
|
||||
base64 = "0.22.1"
|
||||
|
||||
# JWT for Google service account auth
|
||||
jsonwebtoken = "9.0"
|
||||
futures-util = "0.3.31"
|
||||
urlencoding = "2.1.3"
|
||||
48
Dockerfile
Normal file
48
Dockerfile
Normal file
@ -0,0 +1,48 @@
|
||||
# Multi-stage build for optimized production image
|
||||
FROM rust:1.83 as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy manifests for dependency caching
|
||||
COPY Cargo.toml Cargo.lock* ./
|
||||
|
||||
# Copy source code
|
||||
COPY src ./src
|
||||
|
||||
# Build for release
|
||||
RUN cargo build --release
|
||||
|
||||
# Runtime stage
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
# Install CA certificates and curl for HTTPS requests and health checks
|
||||
RUN apt-get update && \
|
||||
apt-get install -y ca-certificates curl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create app user
|
||||
RUN useradd -r -s /bin/false appuser
|
||||
|
||||
# Copy the binary from builder stage
|
||||
COPY --from=builder /app/target/release/emailer-microservice /usr/local/bin/emailer-microservice
|
||||
|
||||
# Set ownership
|
||||
RUN chown appuser:appuser /usr/local/bin/emailer-microservice
|
||||
|
||||
# Switch to non-root user
|
||||
USER appuser
|
||||
|
||||
# Environment defaults
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=4000
|
||||
ENV RUST_LOG=info
|
||||
|
||||
# Expose port
|
||||
EXPOSE 4000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:4000/health || exit 1
|
||||
|
||||
# Run the binary
|
||||
CMD ["emailer-microservice"]
|
||||
358
README.md
Normal file
358
README.md
Normal file
@ -0,0 +1,358 @@
|
||||
# Emailer Microservice
|
||||
|
||||
A Rust-based REST API microservice for Gmail integration using service account authentication with domain-wide delegation support. Send emails on behalf of users in your Google Workspace domain.
|
||||
|
||||
## Features
|
||||
|
||||
- **Gmail API Integration**: Full Gmail API support with service account authentication
|
||||
- **Domain-Wide Delegation**: Impersonate users within your Google Workspace domain
|
||||
- **Email Templates**: Pre-built templates for common notifications
|
||||
- **RESTful API**: Clean REST endpoints for all operations
|
||||
- **API Key Authentication**: Secure access via `X-API-Key` header
|
||||
- **Token Caching**: Efficient OAuth token management
|
||||
- **Docker Support**: Ready for containerized deployment
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Rust 1.75+ (for development)
|
||||
- Docker & Docker Compose (for deployment)
|
||||
- A Google Cloud project with Gmail API enabled
|
||||
- A Google service account with domain-wide delegation configured
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Environment Setup
|
||||
|
||||
Copy the example environment file:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Configure your environment variables:
|
||||
|
||||
```env
|
||||
HOST=127.0.0.1
|
||||
PORT=4000
|
||||
API_KEY=your-secret-api-key-here
|
||||
GOOGLE_SERVICE_ACCOUNT_KEY=your-base64-encoded-service-account-key-here
|
||||
```
|
||||
|
||||
### 2. Google Cloud Setup
|
||||
|
||||
1. Create a service account in Google Cloud Console
|
||||
2. Enable the Gmail API for your project
|
||||
3. Download the service account key JSON file
|
||||
4. Base64 encode the JSON file: `cat service-account.json | base64 -w 0`
|
||||
5. Set the encoded value as `GOOGLE_SERVICE_ACCOUNT_KEY` in your `.env` file
|
||||
6. Configure domain-wide delegation in Google Workspace Admin Console with scope:
|
||||
- `https://www.googleapis.com/auth/gmail.send`
|
||||
|
||||
### 3. Running the Service
|
||||
|
||||
#### Development
|
||||
|
||||
```bash
|
||||
cargo run
|
||||
```
|
||||
|
||||
#### Production (Docker)
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
The service will be available at `http://localhost:4000` (or port 4500 if using Docker).
|
||||
|
||||
## API Documentation
|
||||
|
||||
### Authentication
|
||||
|
||||
All API endpoints (except `/health`) require authentication via the `X-API-Key` header:
|
||||
|
||||
```
|
||||
X-API-Key: your-secret-api-key-here
|
||||
```
|
||||
|
||||
### User Impersonation
|
||||
|
||||
To send emails on behalf of a user, include the `X-Impersonate-User` header:
|
||||
|
||||
```
|
||||
X-Impersonate-User: user@yourdomain.com
|
||||
```
|
||||
|
||||
### Endpoints
|
||||
|
||||
#### Health Check
|
||||
|
||||
```
|
||||
GET /health
|
||||
```
|
||||
|
||||
Returns service health status. No authentication required.
|
||||
|
||||
#### Send Email
|
||||
|
||||
```
|
||||
POST /api/v1/emails
|
||||
Content-Type: application/json
|
||||
X-API-Key: your-api-key
|
||||
X-Impersonate-User: sender@yourdomain.com
|
||||
|
||||
{
|
||||
"to": ["recipient@example.com"],
|
||||
"cc": ["cc@example.com"],
|
||||
"bcc": ["bcc@example.com"],
|
||||
"subject": "Email Subject",
|
||||
"body": "Email body content",
|
||||
"contentType": "text/html"
|
||||
}
|
||||
```
|
||||
|
||||
#### List Emails
|
||||
|
||||
```
|
||||
GET /api/v1/emails?q=search_query&maxResults=10&pageToken=token
|
||||
```
|
||||
|
||||
Query parameters:
|
||||
- `q`: Gmail search query
|
||||
- `maxResults`: Maximum number of results (default: 10)
|
||||
- `pageToken`: Pagination token
|
||||
- `labelIds`: Filter by label IDs
|
||||
- `includeSpamTrash`: Include spam and trash
|
||||
|
||||
#### Get Email
|
||||
|
||||
```
|
||||
GET /api/v1/emails/{email_id}
|
||||
```
|
||||
|
||||
#### Delete Email
|
||||
|
||||
```
|
||||
DELETE /api/v1/emails/{email_id}
|
||||
```
|
||||
|
||||
#### Mark as Read/Unread
|
||||
|
||||
```
|
||||
POST /api/v1/emails/{email_id}/read
|
||||
POST /api/v1/emails/{email_id}/unread
|
||||
```
|
||||
|
||||
### Template Endpoints
|
||||
|
||||
#### List Templates
|
||||
|
||||
```
|
||||
GET /api/v1/templates
|
||||
```
|
||||
|
||||
#### Get Template
|
||||
|
||||
```
|
||||
GET /api/v1/templates/{template_id}
|
||||
```
|
||||
|
||||
#### Send Template Email
|
||||
|
||||
```
|
||||
POST /api/v1/templates/send
|
||||
Content-Type: application/json
|
||||
X-API-Key: your-api-key
|
||||
X-Impersonate-User: sender@yourdomain.com
|
||||
|
||||
{
|
||||
"to": ["recipient@example.com"],
|
||||
"template_id": "general_notification",
|
||||
"variables": {
|
||||
"recipient_name": "John Doe",
|
||||
"subject": "Important Update",
|
||||
"message": "This is an important notification.",
|
||||
"sender_name": "Admin Team"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Built-in Templates
|
||||
|
||||
### 1. General Notification (`general_notification`)
|
||||
|
||||
General purpose notification template.
|
||||
|
||||
**Variables:**
|
||||
- `subject`: Email subject
|
||||
- `recipient_name`: Recipient name
|
||||
- `message`: Notification message
|
||||
- `sender_name`: Sender name/team
|
||||
|
||||
### 2. Service Scheduled (`service_scheduled`)
|
||||
|
||||
Template for service scheduling notifications.
|
||||
|
||||
**Variables:**
|
||||
- `recipient_name`: Recipient name
|
||||
- `customer_name`: Customer name
|
||||
- `service_date`: Scheduled date
|
||||
- `service_address`: Service address
|
||||
|
||||
### 3. Project Update (`project_update`)
|
||||
|
||||
Template for project status updates.
|
||||
|
||||
**Variables:**
|
||||
- `recipient_name`: Recipient name
|
||||
- `project_name`: Project name
|
||||
- `project_status`: Current status
|
||||
- `message`: Additional details
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Sending a Simple Email
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:4000/api/v1/emails \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-H "X-Impersonate-User: sender@yourdomain.com" \
|
||||
-d '{
|
||||
"to": ["recipient@example.com"],
|
||||
"subject": "Hello from Emailer Service",
|
||||
"body": "This is a test email from the emailer service."
|
||||
}'
|
||||
```
|
||||
|
||||
### Sending a Template Email
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:4000/api/v1/templates/send \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-API-Key: your-api-key" \
|
||||
-H "X-Impersonate-User: sender@yourdomain.com" \
|
||||
-d '{
|
||||
"to": ["team@yourdomain.com"],
|
||||
"template_id": "general_notification",
|
||||
"variables": {
|
||||
"recipient_name": "Team",
|
||||
"subject": "System Maintenance",
|
||||
"message": "We will be performing maintenance tonight.",
|
||||
"sender_name": "IT Team"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HOST` | Server bind address | `127.0.0.1` |
|
||||
| `PORT` | Server port | `4000` |
|
||||
| `API_KEY` | Secret key for API authentication | Required |
|
||||
| `GOOGLE_SERVICE_ACCOUNT_KEY` | Base64 encoded service account key JSON | Required |
|
||||
|
||||
### Service Account Key Formats
|
||||
|
||||
The `GOOGLE_SERVICE_ACCOUNT_KEY` can be provided in three formats:
|
||||
|
||||
1. **File path**: `/path/to/service_account_key.json`
|
||||
2. **Base64 encoded**: `cat service-account.json | base64 -w 0`
|
||||
3. **Raw JSON**: `{"type": "service_account", ...}` (not recommended)
|
||||
|
||||
### CORS Configuration
|
||||
|
||||
CORS is configured in `src/main.rs`. Modify the allowed origins for your deployment:
|
||||
|
||||
```rust
|
||||
Cors::default()
|
||||
.allowed_origin("https://your-app.com")
|
||||
// ...
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
├── Cargo.toml
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── .env.example
|
||||
└── src/
|
||||
├── main.rs # Application entry point
|
||||
├── config.rs # Configuration management
|
||||
├── handlers/ # HTTP request handlers
|
||||
│ ├── emails.rs # Email operations
|
||||
│ ├── templates.rs # Template operations
|
||||
│ └── health.rs # Health check
|
||||
├── middleware/ # HTTP middleware
|
||||
│ └── auth.rs # API key authentication
|
||||
├── models/ # Data models
|
||||
│ ├── email.rs # Email structures
|
||||
│ ├── template.rs # Template structures
|
||||
│ └── error.rs # Error handling
|
||||
└── services/ # Business logic
|
||||
└── gmail.rs # Gmail API client
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The service returns standard HTTP status codes:
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| `200 OK` | Successful operation |
|
||||
| `201 Created` | Email sent successfully |
|
||||
| `204 No Content` | Email deleted successfully |
|
||||
| `400 Bad Request` | Invalid request data |
|
||||
| `401 Unauthorized` | Invalid or missing API key |
|
||||
| `404 Not Found` | Resource not found |
|
||||
| `502 Bad Gateway` | Gmail API error |
|
||||
| `500 Internal Server Error` | Server error |
|
||||
|
||||
Error responses include a JSON body:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Error type",
|
||||
"message": "Detailed error message"
|
||||
}
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Building from Source
|
||||
|
||||
```bash
|
||||
git clone <repository>
|
||||
cd emailer-microservice
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
### Logging
|
||||
|
||||
Set the `RUST_LOG` environment variable to control log levels:
|
||||
|
||||
```bash
|
||||
RUST_LOG=info cargo run
|
||||
RUST_LOG=debug cargo run # More verbose
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **API Key Security**: Keep your API key secure and rotate it regularly
|
||||
2. **Service Account Security**: Restrict service account permissions to minimum required scope
|
||||
3. **Network Security**: Use HTTPS in production environments
|
||||
4. **Domain Verification**: Ensure domain-wide delegation is properly configured
|
||||
5. **Access Logging**: Monitor access logs for unusual activity
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
@ -0,0 +1,17 @@
|
||||
services:
|
||||
emailer:
|
||||
build: .
|
||||
ports:
|
||||
- "4500:4000"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- HOST=0.0.0.0
|
||||
- RUST_LOG=info
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:4000/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
23
src/config.rs
Normal file
23
src/config.rs
Normal file
@ -0,0 +1,23 @@
|
||||
use std::env;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Config {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub api_key: String,
|
||||
pub google_service_account_key: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Result<Self, env::VarError> {
|
||||
Ok(Config {
|
||||
host: env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string()),
|
||||
port: env::var("PORT")
|
||||
.unwrap_or_else(|_| "4000".to_string())
|
||||
.parse()
|
||||
.expect("PORT must be a valid number"),
|
||||
api_key: env::var("API_KEY")?,
|
||||
google_service_account_key: env::var("GOOGLE_SERVICE_ACCOUNT_KEY")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
130
src/handlers/emails.rs
Normal file
130
src/handlers/emails.rs
Normal file
@ -0,0 +1,130 @@
|
||||
use crate::{
|
||||
config::Config,
|
||||
models::{email::ListEmailsQuery, email::SendEmailRequest, error::ServiceError},
|
||||
services::gmail::GmailService,
|
||||
};
|
||||
use actix_web::{web, HttpRequest, HttpResponse, Result};
|
||||
|
||||
pub async fn list_emails(
|
||||
query: web::Query<ListEmailsQuery>,
|
||||
config: web::Data<Config>,
|
||||
req: HttpRequest,
|
||||
) -> Result<HttpResponse, ServiceError> {
|
||||
let mut gmail_service = GmailService::new(&config);
|
||||
|
||||
// Check for user impersonation header
|
||||
if let Some(user_email) = req
|
||||
.headers()
|
||||
.get("X-Impersonate-User")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
{
|
||||
gmail_service = gmail_service.with_user_email(user_email.to_string());
|
||||
}
|
||||
|
||||
let emails = gmail_service.list_emails(query.into_inner()).await?;
|
||||
Ok(HttpResponse::Ok().json(emails))
|
||||
}
|
||||
|
||||
pub async fn get_email(
|
||||
path: web::Path<String>,
|
||||
config: web::Data<Config>,
|
||||
req: HttpRequest,
|
||||
) -> Result<HttpResponse, ServiceError> {
|
||||
let email_id = path.into_inner();
|
||||
let mut gmail_service = GmailService::new(&config);
|
||||
|
||||
// Check for user impersonation header
|
||||
if let Some(user_email) = req
|
||||
.headers()
|
||||
.get("X-Impersonate-User")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
{
|
||||
gmail_service = gmail_service.with_user_email(user_email.to_string());
|
||||
}
|
||||
|
||||
let email = gmail_service.get_email(&email_id).await?;
|
||||
Ok(HttpResponse::Ok().json(email))
|
||||
}
|
||||
|
||||
pub async fn send_email(
|
||||
request: web::Json<SendEmailRequest>,
|
||||
config: web::Data<Config>,
|
||||
req: HttpRequest,
|
||||
) -> Result<HttpResponse, ServiceError> {
|
||||
let mut gmail_service = GmailService::new(&config);
|
||||
|
||||
// Check for user impersonation header
|
||||
if let Some(user_email) = req
|
||||
.headers()
|
||||
.get("X-Impersonate-User")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
{
|
||||
gmail_service = gmail_service.with_user_email(user_email.to_string());
|
||||
}
|
||||
|
||||
let email = gmail_service.send_email(request.into_inner()).await?;
|
||||
Ok(HttpResponse::Created().json(email))
|
||||
}
|
||||
|
||||
pub async fn delete_email(
|
||||
path: web::Path<String>,
|
||||
config: web::Data<Config>,
|
||||
req: HttpRequest,
|
||||
) -> Result<HttpResponse, ServiceError> {
|
||||
let email_id = path.into_inner();
|
||||
let mut gmail_service = GmailService::new(&config);
|
||||
|
||||
// Check for user impersonation header
|
||||
if let Some(user_email) = req
|
||||
.headers()
|
||||
.get("X-Impersonate-User")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
{
|
||||
gmail_service = gmail_service.with_user_email(user_email.to_string());
|
||||
}
|
||||
|
||||
gmail_service.delete_email(&email_id).await?;
|
||||
Ok(HttpResponse::NoContent().finish())
|
||||
}
|
||||
|
||||
pub async fn mark_as_read(
|
||||
path: web::Path<String>,
|
||||
config: web::Data<Config>,
|
||||
req: HttpRequest,
|
||||
) -> Result<HttpResponse, ServiceError> {
|
||||
let email_id = path.into_inner();
|
||||
let mut gmail_service = GmailService::new(&config);
|
||||
|
||||
// Check for user impersonation header
|
||||
if let Some(user_email) = req
|
||||
.headers()
|
||||
.get("X-Impersonate-User")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
{
|
||||
gmail_service = gmail_service.with_user_email(user_email.to_string());
|
||||
}
|
||||
|
||||
let email = gmail_service.mark_as_read(&email_id).await?;
|
||||
Ok(HttpResponse::Ok().json(email))
|
||||
}
|
||||
|
||||
pub async fn mark_as_unread(
|
||||
path: web::Path<String>,
|
||||
config: web::Data<Config>,
|
||||
req: HttpRequest,
|
||||
) -> Result<HttpResponse, ServiceError> {
|
||||
let email_id = path.into_inner();
|
||||
let mut gmail_service = GmailService::new(&config);
|
||||
|
||||
// Check for user impersonation header
|
||||
if let Some(user_email) = req
|
||||
.headers()
|
||||
.get("X-Impersonate-User")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
{
|
||||
gmail_service = gmail_service.with_user_email(user_email.to_string());
|
||||
}
|
||||
|
||||
let email = gmail_service.mark_as_unread(&email_id).await?;
|
||||
Ok(HttpResponse::Ok().json(email))
|
||||
}
|
||||
17
src/handlers/health.rs
Normal file
17
src/handlers/health.rs
Normal file
@ -0,0 +1,17 @@
|
||||
use actix_web::{HttpResponse, Result};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct HealthResponse {
|
||||
status: String,
|
||||
service: String,
|
||||
}
|
||||
|
||||
pub async fn health_check() -> Result<HttpResponse> {
|
||||
let response = HealthResponse {
|
||||
status: "ok".to_string(),
|
||||
service: "emailer-microservice".to_string(),
|
||||
};
|
||||
|
||||
Ok(HttpResponse::Ok().json(response))
|
||||
}
|
||||
3
src/handlers/mod.rs
Normal file
3
src/handlers/mod.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub mod emails;
|
||||
pub mod health;
|
||||
pub mod templates;
|
||||
71
src/handlers/templates.rs
Normal file
71
src/handlers/templates.rs
Normal file
@ -0,0 +1,71 @@
|
||||
use crate::{
|
||||
config::Config,
|
||||
models::{
|
||||
email::SendEmailRequest, error::ServiceError, template::TemplateEmailRequest,
|
||||
template::TemplateRegistry,
|
||||
},
|
||||
services::gmail::GmailService,
|
||||
};
|
||||
use actix_web::{web, HttpRequest, HttpResponse, Result};
|
||||
|
||||
pub async fn send_template_email(
|
||||
request: web::Json<TemplateEmailRequest>,
|
||||
config: web::Data<Config>,
|
||||
req: HttpRequest,
|
||||
) -> Result<HttpResponse, ServiceError> {
|
||||
let request = request.into_inner();
|
||||
let template_registry = TemplateRegistry::new();
|
||||
|
||||
// Get the template
|
||||
let template = template_registry.get_template(&request.template_id).ok_or_else(|| {
|
||||
ServiceError::InvalidEmail(format!("Template '{}' not found", request.template_id))
|
||||
})?;
|
||||
|
||||
// Render the template
|
||||
let (subject, body) = template
|
||||
.render(&request.variables)
|
||||
.map_err(|e| ServiceError::InvalidEmail(format!("Template rendering failed: {}", e)))?;
|
||||
|
||||
// Convert to regular email request
|
||||
let email_request = SendEmailRequest {
|
||||
to: request.to,
|
||||
cc: request.cc,
|
||||
bcc: request.bcc,
|
||||
subject,
|
||||
body,
|
||||
content_type: Some("text/html".to_string()),
|
||||
from_name: None,
|
||||
attachments: None,
|
||||
};
|
||||
|
||||
// Set up Gmail service with impersonation
|
||||
let mut gmail_service = GmailService::new(&config);
|
||||
if let Some(user_email) = req
|
||||
.headers()
|
||||
.get("X-Impersonate-User")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
{
|
||||
gmail_service = gmail_service.with_user_email(user_email.to_string());
|
||||
}
|
||||
|
||||
// Send the email
|
||||
let email = gmail_service.send_email(email_request).await?;
|
||||
Ok(HttpResponse::Created().json(email))
|
||||
}
|
||||
|
||||
pub async fn list_templates() -> Result<HttpResponse, ServiceError> {
|
||||
let template_registry = TemplateRegistry::new();
|
||||
let templates = template_registry.list_templates();
|
||||
Ok(HttpResponse::Ok().json(templates))
|
||||
}
|
||||
|
||||
pub async fn get_template(path: web::Path<String>) -> Result<HttpResponse, ServiceError> {
|
||||
let template_id = path.into_inner();
|
||||
let template_registry = TemplateRegistry::new();
|
||||
|
||||
let template = template_registry
|
||||
.get_template(&template_id)
|
||||
.ok_or_else(|| ServiceError::NotFound)?;
|
||||
|
||||
Ok(HttpResponse::Ok().json(template))
|
||||
}
|
||||
67
src/main.rs
Normal file
67
src/main.rs
Normal file
@ -0,0 +1,67 @@
|
||||
mod config;
|
||||
mod handlers;
|
||||
mod middleware;
|
||||
mod models;
|
||||
mod services;
|
||||
|
||||
use crate::{
|
||||
config::Config,
|
||||
handlers::{emails, health, templates},
|
||||
middleware::auth::ApiKeyAuth,
|
||||
};
|
||||
use actix_cors::Cors;
|
||||
use actix_web::{middleware::Logger, web, App, HttpServer};
|
||||
use dotenv::dotenv;
|
||||
use log::info;
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
// Load environment variables
|
||||
dotenv().ok();
|
||||
|
||||
// Initialize logger
|
||||
env_logger::init();
|
||||
|
||||
// Load configuration
|
||||
let config = Config::from_env().expect("Failed to load configuration");
|
||||
let bind_address = format!("{}:{}", config.host, config.port);
|
||||
|
||||
info!("Starting Gmail API server on {}", bind_address);
|
||||
|
||||
HttpServer::new(move || {
|
||||
App::new()
|
||||
.app_data(web::Data::new(config.clone()))
|
||||
.wrap(
|
||||
// Configure CORS - modify these origins for your deployment
|
||||
Cors::default()
|
||||
.allowed_origin("http://localhost:5173")
|
||||
.allowed_origin("http://127.0.0.1:5173")
|
||||
.allowed_origin("http://localhost:3000")
|
||||
.allowed_origin("http://127.0.0.1:3000")
|
||||
.allowed_methods(vec!["GET", "POST", "PUT", "DELETE"])
|
||||
.allowed_headers(vec!["X-API-Key", "X-Impersonate-User", "Content-Type"])
|
||||
.supports_credentials(),
|
||||
)
|
||||
.wrap(Logger::default())
|
||||
.wrap(ApiKeyAuth)
|
||||
.service(
|
||||
web::scope("/api/v1")
|
||||
.route("/emails", web::get().to(emails::list_emails))
|
||||
.route("/emails", web::post().to(emails::send_email))
|
||||
.route("/emails/{id}", web::get().to(emails::get_email))
|
||||
.route("/emails/{id}", web::delete().to(emails::delete_email))
|
||||
.route("/emails/{id}/read", web::post().to(emails::mark_as_read))
|
||||
.route("/emails/{id}/unread", web::post().to(emails::mark_as_unread))
|
||||
.route("/templates", web::get().to(templates::list_templates))
|
||||
.route("/templates/{id}", web::get().to(templates::get_template))
|
||||
.route(
|
||||
"/templates/send",
|
||||
web::post().to(templates::send_template_email),
|
||||
),
|
||||
)
|
||||
.route("/health", web::get().to(health::health_check))
|
||||
})
|
||||
.bind(&bind_address)?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
74
src/middleware/auth.rs
Normal file
74
src/middleware/auth.rs
Normal file
@ -0,0 +1,74 @@
|
||||
use crate::{config::Config, models::error::ServiceError};
|
||||
use actix_web::{
|
||||
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
|
||||
Error,
|
||||
};
|
||||
use futures_util::future::LocalBoxFuture;
|
||||
use std::{
|
||||
future::{ready, Ready},
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
pub struct ApiKeyAuth;
|
||||
|
||||
impl<S, B> Transform<S, ServiceRequest> for ApiKeyAuth
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
|
||||
S::Future: 'static,
|
||||
B: 'static,
|
||||
{
|
||||
type Response = ServiceResponse<B>;
|
||||
type Error = Error;
|
||||
type InitError = ();
|
||||
type Transform = ApiKeyAuthMiddleware<S>;
|
||||
type Future = Ready<Result<Self::Transform, Self::InitError>>;
|
||||
|
||||
fn new_transform(&self, service: S) -> Self::Future {
|
||||
ready(Ok(ApiKeyAuthMiddleware {
|
||||
service: Rc::new(service),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ApiKeyAuthMiddleware<S> {
|
||||
service: Rc<S>,
|
||||
}
|
||||
|
||||
impl<S, B> Service<ServiceRequest> for ApiKeyAuthMiddleware<S>
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
|
||||
S::Future: 'static,
|
||||
B: 'static,
|
||||
{
|
||||
type Response = ServiceResponse<B>;
|
||||
type Error = Error;
|
||||
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
|
||||
|
||||
forward_ready!(service);
|
||||
|
||||
fn call(&self, req: ServiceRequest) -> Self::Future {
|
||||
let service = Rc::clone(&self.service);
|
||||
|
||||
Box::pin(async move {
|
||||
// Skip auth for health check
|
||||
if req.path() == "/health" {
|
||||
return service.call(req).await;
|
||||
}
|
||||
|
||||
// Check for the API key in the header
|
||||
let api_key = req.headers().get("X-API-Key").and_then(|h| h.to_str().ok());
|
||||
|
||||
if let Some(provided_key) = api_key {
|
||||
// Get the expected API key from app data
|
||||
if let Some(config) = req.app_data::<actix_web::web::Data<Config>>() {
|
||||
if provided_key == config.api_key {
|
||||
return service.call(req).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return unauthorized error
|
||||
Err(ServiceError::Unauthorized.into())
|
||||
})
|
||||
}
|
||||
}
|
||||
1
src/middleware/mod.rs
Normal file
1
src/middleware/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod auth;
|
||||
96
src/models/email.rs
Normal file
96
src/models/email.rs
Normal file
@ -0,0 +1,96 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Email {
|
||||
pub id: String,
|
||||
pub thread_id: String,
|
||||
pub label_ids: Vec<String>,
|
||||
pub snippet: String,
|
||||
pub payload: EmailPayload,
|
||||
pub size_estimate: u64,
|
||||
pub history_id: String,
|
||||
pub internal_date: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct EmailPayload {
|
||||
pub part_id: Option<String>,
|
||||
pub mime_type: String,
|
||||
pub filename: Option<String>,
|
||||
pub headers: Vec<EmailHeader>,
|
||||
pub body: EmailBody,
|
||||
pub parts: Option<Vec<EmailPayload>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct EmailHeader {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct EmailBody {
|
||||
#[serde(rename = "attachmentId")]
|
||||
pub attachment_id: Option<String>,
|
||||
pub size: u64,
|
||||
pub data: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListEmailsQuery {
|
||||
pub q: Option<String>,
|
||||
#[serde(rename = "maxResults")]
|
||||
pub max_results: Option<u32>,
|
||||
#[serde(rename = "pageToken")]
|
||||
pub page_token: Option<String>,
|
||||
#[serde(rename = "labelIds")]
|
||||
pub label_ids: Option<Vec<String>>,
|
||||
#[serde(rename = "includeSpamTrash")]
|
||||
pub include_spam_trash: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SendEmailRequest {
|
||||
pub to: Vec<String>,
|
||||
pub cc: Option<Vec<String>>,
|
||||
pub bcc: Option<Vec<String>>,
|
||||
pub subject: String,
|
||||
pub body: String,
|
||||
#[serde(rename = "contentType")]
|
||||
pub content_type: Option<String>,
|
||||
#[serde(rename = "fromName")]
|
||||
pub from_name: Option<String>,
|
||||
pub attachments: Option<Vec<EmailAttachment>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct EmailAttachment {
|
||||
pub filename: String,
|
||||
pub content: String, // base64 encoded
|
||||
pub content_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct EmailListResponse {
|
||||
pub messages: Vec<EmailMessage>,
|
||||
#[serde(rename = "nextPageToken")]
|
||||
pub next_page_token: Option<String>,
|
||||
#[serde(rename = "resultSizeEstimate")]
|
||||
pub result_size_estimate: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct EmailMessage {
|
||||
pub id: String,
|
||||
#[serde(rename = "threadId")]
|
||||
pub thread_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SendEmailResponse {
|
||||
pub id: String,
|
||||
#[serde(rename = "threadId")]
|
||||
pub thread_id: String,
|
||||
#[serde(rename = "labelIds")]
|
||||
pub label_ids: Option<Vec<String>>,
|
||||
}
|
||||
51
src/models/error.rs
Normal file
51
src/models/error.rs
Normal file
@ -0,0 +1,51 @@
|
||||
use actix_web::{HttpResponse, ResponseError};
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ServiceError {
|
||||
#[error("Email not found")]
|
||||
NotFound,
|
||||
|
||||
#[error("Unauthorized")]
|
||||
Unauthorized,
|
||||
|
||||
#[error("Gmail API error: {0}")]
|
||||
GmailApiError(String),
|
||||
|
||||
#[error("Internal server error: {0}")]
|
||||
InternalError(String),
|
||||
|
||||
#[error("Invalid email format: {0}")]
|
||||
InvalidEmail(String),
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ErrorResponse {
|
||||
error: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl ResponseError for ServiceError {
|
||||
fn error_response(&self) -> HttpResponse {
|
||||
let error_response = ErrorResponse {
|
||||
error: self.to_string(),
|
||||
message: match self {
|
||||
ServiceError::NotFound => "Email not found".to_string(),
|
||||
ServiceError::Unauthorized => "Invalid API key".to_string(),
|
||||
ServiceError::GmailApiError(msg) => format!("Gmail API error: {}", msg),
|
||||
ServiceError::InternalError(msg) => format!("Internal error: {}", msg),
|
||||
ServiceError::InvalidEmail(msg) => format!("Invalid email: {}", msg),
|
||||
},
|
||||
};
|
||||
|
||||
match self {
|
||||
ServiceError::InvalidEmail(_) => HttpResponse::BadRequest().json(error_response),
|
||||
ServiceError::NotFound => HttpResponse::NotFound().json(error_response),
|
||||
ServiceError::Unauthorized => HttpResponse::Unauthorized().json(error_response),
|
||||
ServiceError::GmailApiError(_) => HttpResponse::BadGateway().json(error_response),
|
||||
ServiceError::InternalError(_) => {
|
||||
HttpResponse::InternalServerError().json(error_response)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
src/models/mod.rs
Normal file
3
src/models/mod.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub mod email;
|
||||
pub mod error;
|
||||
pub mod template;
|
||||
168
src/models/template.rs
Normal file
168
src/models/template.rs
Normal file
@ -0,0 +1,168 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct EmailTemplate {
|
||||
pub template_id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub subject_template: String,
|
||||
pub body_template: String,
|
||||
pub variables: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct TemplateEmailRequest {
|
||||
pub to: Vec<String>,
|
||||
pub cc: Option<Vec<String>>,
|
||||
pub bcc: Option<Vec<String>>,
|
||||
pub template_id: String,
|
||||
pub variables: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl EmailTemplate {
|
||||
/// Create a general notification template
|
||||
pub fn general_notification() -> Self {
|
||||
Self {
|
||||
template_id: "general_notification".to_string(),
|
||||
name: "General Notification".to_string(),
|
||||
description: "General purpose notification template".to_string(),
|
||||
subject_template: "{{subject}}".to_string(),
|
||||
body_template: r#"Hi {{recipient_name}},
|
||||
|
||||
{{message}}
|
||||
|
||||
Thanks,
|
||||
{{sender_name}}"#
|
||||
.to_string(),
|
||||
variables: HashMap::from([
|
||||
("subject".to_string(), "Notification".to_string()),
|
||||
("recipient_name".to_string(), "Recipient".to_string()),
|
||||
("message".to_string(), "This is a notification message.".to_string()),
|
||||
("sender_name".to_string(), "System".to_string()),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a service scheduling template
|
||||
pub fn service_scheduled() -> Self {
|
||||
Self {
|
||||
template_id: "service_scheduled".to_string(),
|
||||
name: "Service Scheduled".to_string(),
|
||||
description: "Template for service scheduling notifications".to_string(),
|
||||
subject_template: "Service Scheduled - {{customer_name}}".to_string(),
|
||||
body_template: r#"Hi {{recipient_name}},
|
||||
|
||||
A new service has been scheduled:
|
||||
|
||||
Customer: {{customer_name}}
|
||||
Date: {{service_date}}
|
||||
Address: {{service_address}}
|
||||
|
||||
Please check the system for full details.
|
||||
|
||||
Thanks,
|
||||
Dispatch Team"#
|
||||
.to_string(),
|
||||
variables: HashMap::from([
|
||||
("recipient_name".to_string(), "Team Member".to_string()),
|
||||
("customer_name".to_string(), "Customer Name".to_string()),
|
||||
("service_date".to_string(), "Service Date".to_string()),
|
||||
("service_address".to_string(), "Service Address".to_string()),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a project update template
|
||||
pub fn project_update() -> Self {
|
||||
Self {
|
||||
template_id: "project_update".to_string(),
|
||||
name: "Project Update".to_string(),
|
||||
description: "Template for project status updates".to_string(),
|
||||
subject_template: "Project Update - {{project_name}}".to_string(),
|
||||
body_template: r#"Hi {{recipient_name}},
|
||||
|
||||
Project Status Update:
|
||||
|
||||
Project: {{project_name}}
|
||||
Status: {{project_status}}
|
||||
{{message}}
|
||||
|
||||
View details in the system.
|
||||
|
||||
Thanks,
|
||||
Project Team"#
|
||||
.to_string(),
|
||||
variables: HashMap::from([
|
||||
("recipient_name".to_string(), "Team Member".to_string()),
|
||||
("project_name".to_string(), "Project Name".to_string()),
|
||||
("project_status".to_string(), "Project Status".to_string()),
|
||||
("message".to_string(), "Additional project details.".to_string()),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the template with provided variables
|
||||
pub fn render(&self, variables: &HashMap<String, String>) -> Result<(String, String), String> {
|
||||
let subject = self.substitute_variables(&self.subject_template, variables);
|
||||
let body = self.substitute_variables(&self.body_template, variables);
|
||||
Ok((subject, body))
|
||||
}
|
||||
|
||||
/// Simple template variable substitution
|
||||
fn substitute_variables(&self, template: &str, variables: &HashMap<String, String>) -> String {
|
||||
let mut result = template.to_string();
|
||||
|
||||
// Substitute provided variables
|
||||
for (key, value) in variables {
|
||||
let placeholder = format!("{{{{{}}}}}", key);
|
||||
result = result.replace(&placeholder, value);
|
||||
}
|
||||
|
||||
// Substitute default template variables for any remaining placeholders
|
||||
for (key, default_value) in &self.variables {
|
||||
let placeholder = format!("{{{{{}}}}}", key);
|
||||
if result.contains(&placeholder) {
|
||||
result = result.replace(&placeholder, default_value);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TemplateRegistry {
|
||||
templates: HashMap<String, EmailTemplate>,
|
||||
}
|
||||
|
||||
impl Default for TemplateRegistry {
|
||||
fn default() -> Self {
|
||||
let mut templates = HashMap::new();
|
||||
|
||||
// Register built-in templates
|
||||
let notification = EmailTemplate::general_notification();
|
||||
templates.insert(notification.template_id.clone(), notification);
|
||||
|
||||
let service = EmailTemplate::service_scheduled();
|
||||
templates.insert(service.template_id.clone(), service);
|
||||
|
||||
let project = EmailTemplate::project_update();
|
||||
templates.insert(project.template_id.clone(), project);
|
||||
|
||||
Self { templates }
|
||||
}
|
||||
}
|
||||
|
||||
impl TemplateRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn get_template(&self, template_id: &str) -> Option<&EmailTemplate> {
|
||||
self.templates.get(template_id)
|
||||
}
|
||||
|
||||
pub fn list_templates(&self) -> Vec<&EmailTemplate> {
|
||||
self.templates.values().collect()
|
||||
}
|
||||
}
|
||||
475
src/services/gmail.rs
Normal file
475
src/services/gmail.rs
Normal file
@ -0,0 +1,475 @@
|
||||
use crate::{
|
||||
config::Config,
|
||||
models::{
|
||||
email::{Email, EmailListResponse, ListEmailsQuery, SendEmailRequest, SendEmailResponse},
|
||||
error::ServiceError,
|
||||
},
|
||||
};
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
|
||||
use reqwest::{Client, Response};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const GOOGLE_TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
|
||||
const GMAIL_SCOPE: &str = "https://www.googleapis.com/auth/gmail.send";
|
||||
const GMAIL_API_BASE: &str = "https://gmail.googleapis.com/gmail/v1";
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ServiceAccountKey {
|
||||
#[serde(rename = "type")]
|
||||
key_type: String,
|
||||
project_id: String,
|
||||
private_key_id: String,
|
||||
private_key: String,
|
||||
client_email: String,
|
||||
client_id: String,
|
||||
auth_uri: String,
|
||||
token_uri: String,
|
||||
auth_provider_x509_cert_url: String,
|
||||
client_x509_cert_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct JwtClaims {
|
||||
iss: String,
|
||||
scope: String,
|
||||
aud: String,
|
||||
exp: usize,
|
||||
iat: usize,
|
||||
sub: Option<String>, // For domain-wide delegation
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
#[allow(dead_code)]
|
||||
token_type: String,
|
||||
expires_in: u32,
|
||||
}
|
||||
|
||||
pub struct GmailService {
|
||||
client: Client,
|
||||
config: Config,
|
||||
cached_token: Arc<Mutex<Option<CachedToken>>>,
|
||||
user_email: Option<String>, // For impersonation in domain-wide delegation
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CachedToken {
|
||||
access_token: String,
|
||||
expires_at: u64,
|
||||
}
|
||||
|
||||
impl GmailService {
|
||||
pub fn new(config: &Config) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
config: config.clone(),
|
||||
cached_token: Arc::new(Mutex::new(None)),
|
||||
user_email: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_user_email(mut self, user_email: String) -> Self {
|
||||
self.user_email = Some(user_email);
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn list_emails(
|
||||
&self,
|
||||
query: ListEmailsQuery,
|
||||
) -> Result<EmailListResponse, ServiceError> {
|
||||
let url = self.build_list_emails_url(query);
|
||||
|
||||
let response = self
|
||||
.make_authenticated_request(|client, token| {
|
||||
client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
})
|
||||
.await?;
|
||||
|
||||
self.handle_list_emails_response(response).await
|
||||
}
|
||||
|
||||
pub async fn get_email(&self, email_id: &str) -> Result<Email, ServiceError> {
|
||||
let url = format!("{}/users/me/messages/{}", GMAIL_API_BASE, email_id);
|
||||
|
||||
let response = self
|
||||
.make_authenticated_request(|client, token| {
|
||||
client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
})
|
||||
.await?;
|
||||
|
||||
self.handle_email_response(response).await
|
||||
}
|
||||
|
||||
pub async fn send_email(
|
||||
&self,
|
||||
request: SendEmailRequest,
|
||||
) -> Result<SendEmailResponse, ServiceError> {
|
||||
let url = format!("{}/users/me/messages/send", GMAIL_API_BASE);
|
||||
let raw_email = self.build_raw_email(request)?;
|
||||
|
||||
let send_body = json!({
|
||||
"raw": raw_email
|
||||
});
|
||||
|
||||
let response = self
|
||||
.make_authenticated_request(|client, token| {
|
||||
client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&send_body)
|
||||
})
|
||||
.await?;
|
||||
|
||||
self.handle_send_email_response(response).await
|
||||
}
|
||||
|
||||
pub async fn delete_email(&self, email_id: &str) -> Result<(), ServiceError> {
|
||||
let url = format!("{}/users/me/messages/{}", GMAIL_API_BASE, email_id);
|
||||
|
||||
let response = self
|
||||
.make_authenticated_request(|client, token| {
|
||||
client
|
||||
.delete(&url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
})
|
||||
.await?;
|
||||
|
||||
self.handle_delete_response(response).await
|
||||
}
|
||||
|
||||
pub async fn mark_as_read(&self, email_id: &str) -> Result<Email, ServiceError> {
|
||||
let url = format!("{}/users/me/messages/{}/modify", GMAIL_API_BASE, email_id);
|
||||
|
||||
let modify_body = json!({
|
||||
"removeLabelIds": ["UNREAD"]
|
||||
});
|
||||
|
||||
let response = self
|
||||
.make_authenticated_request(|client, token| {
|
||||
client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&modify_body)
|
||||
})
|
||||
.await?;
|
||||
|
||||
self.handle_email_response(response).await
|
||||
}
|
||||
|
||||
pub async fn mark_as_unread(&self, email_id: &str) -> Result<Email, ServiceError> {
|
||||
let url = format!("{}/users/me/messages/{}/modify", GMAIL_API_BASE, email_id);
|
||||
|
||||
let modify_body = json!({
|
||||
"addLabelIds": ["UNREAD"]
|
||||
});
|
||||
|
||||
let response = self
|
||||
.make_authenticated_request(|client, token| {
|
||||
client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&modify_body)
|
||||
})
|
||||
.await?;
|
||||
|
||||
self.handle_email_response(response).await
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
fn build_list_emails_url(&self, query: ListEmailsQuery) -> String {
|
||||
let mut url = format!("{}/users/me/messages", GMAIL_API_BASE);
|
||||
let mut params = Vec::new();
|
||||
|
||||
if let Some(q) = query.q {
|
||||
params.push(("q", q));
|
||||
}
|
||||
if let Some(max_results) = query.max_results {
|
||||
params.push(("maxResults", max_results.to_string()));
|
||||
}
|
||||
if let Some(page_token) = query.page_token {
|
||||
params.push(("pageToken", page_token));
|
||||
}
|
||||
if let Some(label_ids) = query.label_ids {
|
||||
for label_id in label_ids {
|
||||
params.push(("labelIds", label_id));
|
||||
}
|
||||
}
|
||||
if let Some(include_spam_trash) = query.include_spam_trash {
|
||||
params.push(("includeSpamTrash", include_spam_trash.to_string()));
|
||||
}
|
||||
|
||||
if !params.is_empty() {
|
||||
url.push('?');
|
||||
url.push_str(
|
||||
¶ms
|
||||
.into_iter()
|
||||
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v)))
|
||||
.collect::<Vec<_>>()
|
||||
.join("&"),
|
||||
);
|
||||
}
|
||||
|
||||
url
|
||||
}
|
||||
|
||||
fn build_raw_email(&self, request: SendEmailRequest) -> Result<String, ServiceError> {
|
||||
let mut email_lines = Vec::new();
|
||||
|
||||
// Headers
|
||||
let to_header = format!("To: {}", request.to.join(", "));
|
||||
email_lines.push(to_header);
|
||||
|
||||
// From header with optional display name
|
||||
if let Some(ref user_email) = self.user_email {
|
||||
if let Some(ref from_name) = request.from_name {
|
||||
email_lines.push(format!("From: \"{}\" <{}>", from_name, user_email));
|
||||
} else {
|
||||
email_lines.push(format!("From: {}", user_email));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(cc) = request.cc {
|
||||
if !cc.is_empty() {
|
||||
email_lines.push(format!("Cc: {}", cc.join(", ")));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(bcc) = request.bcc {
|
||||
if !bcc.is_empty() {
|
||||
email_lines.push(format!("Bcc: {}", bcc.join(", ")));
|
||||
}
|
||||
}
|
||||
|
||||
email_lines.push(format!("Subject: {}", request.subject));
|
||||
let content_type = request.content_type.as_deref().unwrap_or("text/html");
|
||||
email_lines.push(format!("Content-Type: {}; charset=utf-8", content_type));
|
||||
email_lines.push(String::new()); // Empty line to separate headers from body
|
||||
email_lines.push(request.body);
|
||||
|
||||
let raw_email = email_lines.join("\r\n");
|
||||
|
||||
// Base64 encode the entire email
|
||||
Ok(general_purpose::URL_SAFE_NO_PAD.encode(raw_email.as_bytes()))
|
||||
}
|
||||
|
||||
async fn make_authenticated_request<F>(
|
||||
&self,
|
||||
request_builder: F,
|
||||
) -> Result<Response, ServiceError>
|
||||
where
|
||||
F: FnOnce(&Client, &str) -> reqwest::RequestBuilder,
|
||||
{
|
||||
let access_token = self.get_access_token().await?;
|
||||
let response = request_builder(&self.client, &access_token)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServiceError::InternalError(format!("Request failed: {}", e)))?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn handle_email_response(&self, response: Response) -> Result<Email, ServiceError> {
|
||||
if response.status().is_success() {
|
||||
response.json().await.map_err(|e| {
|
||||
ServiceError::InternalError(format!("Failed to parse response: {}", e))
|
||||
})
|
||||
} else {
|
||||
Err(self.handle_error_response(response).await)
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_send_email_response(
|
||||
&self,
|
||||
response: Response,
|
||||
) -> Result<SendEmailResponse, ServiceError> {
|
||||
if response.status().is_success() {
|
||||
response.json().await.map_err(|e| {
|
||||
ServiceError::InternalError(format!("Failed to parse response: {}", e))
|
||||
})
|
||||
} else {
|
||||
Err(self.handle_error_response(response).await)
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_list_emails_response(
|
||||
&self,
|
||||
response: Response,
|
||||
) -> Result<EmailListResponse, ServiceError> {
|
||||
if response.status().is_success() {
|
||||
response.json().await.map_err(|e| {
|
||||
ServiceError::InternalError(format!("Failed to parse response: {}", e))
|
||||
})
|
||||
} else {
|
||||
Err(self.handle_error_response(response).await)
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_delete_response(&self, response: Response) -> Result<(), ServiceError> {
|
||||
match response.status().as_u16() {
|
||||
204 => Ok(()),
|
||||
404 => Err(ServiceError::NotFound),
|
||||
_ => Err(self.handle_error_response(response).await),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_error_response(&self, response: Response) -> ServiceError {
|
||||
let status = response.status();
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
|
||||
match status.as_u16() {
|
||||
401 => ServiceError::Unauthorized,
|
||||
404 => ServiceError::NotFound,
|
||||
_ => ServiceError::GmailApiError(format!("HTTP {}: {}", status, error_text)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_access_token(&self) -> Result<String, ServiceError> {
|
||||
// Check if we have a cached token that's still valid
|
||||
{
|
||||
let cached_token = self.cached_token.lock().map_err(|e| {
|
||||
ServiceError::InternalError(format!("Failed to lock token cache: {}", e))
|
||||
})?;
|
||||
|
||||
if let Some(cached) = cached_token.as_ref() {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
if cached.expires_at > now + 60 {
|
||||
// 60-second buffer
|
||||
return Ok(cached.access_token.clone());
|
||||
}
|
||||
}
|
||||
} // Lock is released here
|
||||
|
||||
// Get a new token
|
||||
let service_account = self.parse_service_account_key()?;
|
||||
let jwt = self.create_jwt(&service_account)?;
|
||||
let token_response = self.exchange_jwt_for_token(&jwt).await?;
|
||||
|
||||
// Cache the new token
|
||||
let expires_at = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
+ token_response.expires_in as u64;
|
||||
|
||||
let new_token = CachedToken {
|
||||
access_token: token_response.access_token.clone(),
|
||||
expires_at,
|
||||
};
|
||||
|
||||
// Update the cache
|
||||
{
|
||||
let mut cached_token = self.cached_token.lock().map_err(|e| {
|
||||
ServiceError::InternalError(format!("Failed to lock token cache: {}", e))
|
||||
})?;
|
||||
*cached_token = Some(new_token);
|
||||
}
|
||||
|
||||
Ok(token_response.access_token)
|
||||
}
|
||||
|
||||
fn parse_service_account_key(&self) -> Result<ServiceAccountKey, ServiceError> {
|
||||
// Read from the file if the value is a file path
|
||||
let key_data = if self.config.google_service_account_key.starts_with('/')
|
||||
|| self.config.google_service_account_key.starts_with("./")
|
||||
{
|
||||
// Read from the file
|
||||
std::fs::read_to_string(&self.config.google_service_account_key).map_err(|e| {
|
||||
ServiceError::InternalError(format!(
|
||||
"Failed to read service account key file: {}",
|
||||
e
|
||||
))
|
||||
})?
|
||||
} else if self.config.google_service_account_key.starts_with('{') {
|
||||
// Direct JSON
|
||||
self.config.google_service_account_key.clone()
|
||||
} else {
|
||||
// Assume it's base64 encoded
|
||||
let decoded = general_purpose::STANDARD
|
||||
.decode(&self.config.google_service_account_key)
|
||||
.map_err(|e| {
|
||||
ServiceError::InternalError(format!(
|
||||
"Invalid base64 service account key: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
String::from_utf8(decoded).map_err(|e| {
|
||||
ServiceError::InternalError(format!("Invalid UTF-8 in service account key: {}", e))
|
||||
})?
|
||||
};
|
||||
|
||||
serde_json::from_str(&key_data).map_err(|e| {
|
||||
ServiceError::InternalError(format!("Invalid service account key format: {}", e))
|
||||
})
|
||||
}
|
||||
|
||||
fn create_jwt(&self, service_account: &ServiceAccountKey) -> Result<String, ServiceError> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as usize;
|
||||
|
||||
let claims = JwtClaims {
|
||||
iss: service_account.client_email.clone(),
|
||||
scope: GMAIL_SCOPE.to_string(),
|
||||
aud: GOOGLE_TOKEN_URL.to_string(),
|
||||
exp: now + 3600, // 1 hour
|
||||
iat: now,
|
||||
sub: self.user_email.clone(), // For domain-wide delegation
|
||||
};
|
||||
|
||||
let encoding_key = EncodingKey::from_rsa_pem(service_account.private_key.as_bytes())
|
||||
.map_err(|e| ServiceError::InternalError(format!("Invalid private key: {}", e)))?;
|
||||
|
||||
encode(&Header::new(Algorithm::RS256), &claims, &encoding_key)
|
||||
.map_err(|e| ServiceError::InternalError(format!("Failed to create JWT: {}", e)))
|
||||
}
|
||||
|
||||
async fn exchange_jwt_for_token(&self, jwt: &str) -> Result<TokenResponse, ServiceError> {
|
||||
let response = self
|
||||
.client
|
||||
.post(GOOGLE_TOKEN_URL)
|
||||
.form(&[
|
||||
("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"),
|
||||
("assertion", jwt),
|
||||
])
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ServiceError::InternalError(format!("Token exchange failed: {}", e)))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
response.json().await.map_err(|e| {
|
||||
ServiceError::InternalError(format!("Failed to parse token response: {}", e))
|
||||
})
|
||||
} else {
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
Err(ServiceError::InternalError(format!(
|
||||
"Token exchange failed: {}",
|
||||
error_text
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/services/mod.rs
Normal file
1
src/services/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod gmail;
|
||||
Loading…
x
Reference in New Issue
Block a user