commit 565cc5ace754e814e2caa111ad02c6e87b617ced Author: Damien Coles Date: Mon Jan 26 01:36:06 2026 -0500 public-ready-init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ccaf86c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +target/ +.git/ +.gitignore +README.md +.env +.env.* +*.log +.DS_Store +Thumbs.db diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..51bbae0 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..15ab1b4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/target +/.env +/service_account_key.json +Cargo.lock +*.log +.DS_Store +.idea \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..24fa689 --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1cd9ca1 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..328f605 --- /dev/null +++ b/README.md @@ -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 +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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..81eae00 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..e842231 --- /dev/null +++ b/src/config.rs @@ -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 { + 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")?, + }) + } +} diff --git a/src/handlers/emails.rs b/src/handlers/emails.rs new file mode 100644 index 0000000..fe98eda --- /dev/null +++ b/src/handlers/emails.rs @@ -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, + config: web::Data, + req: HttpRequest, +) -> Result { + 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, + config: web::Data, + req: HttpRequest, +) -> Result { + 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, + config: web::Data, + req: HttpRequest, +) -> Result { + 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, + config: web::Data, + req: HttpRequest, +) -> Result { + 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, + config: web::Data, + req: HttpRequest, +) -> Result { + 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, + config: web::Data, + req: HttpRequest, +) -> Result { + 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)) +} diff --git a/src/handlers/health.rs b/src/handlers/health.rs new file mode 100644 index 0000000..3b01921 --- /dev/null +++ b/src/handlers/health.rs @@ -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 { + let response = HealthResponse { + status: "ok".to_string(), + service: "emailer-microservice".to_string(), + }; + + Ok(HttpResponse::Ok().json(response)) +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs new file mode 100644 index 0000000..48924f7 --- /dev/null +++ b/src/handlers/mod.rs @@ -0,0 +1,3 @@ +pub mod emails; +pub mod health; +pub mod templates; diff --git a/src/handlers/templates.rs b/src/handlers/templates.rs new file mode 100644 index 0000000..e8f6529 --- /dev/null +++ b/src/handlers/templates.rs @@ -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, + config: web::Data, + req: HttpRequest, +) -> Result { + 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 { + let template_registry = TemplateRegistry::new(); + let templates = template_registry.list_templates(); + Ok(HttpResponse::Ok().json(templates)) +} + +pub async fn get_template(path: web::Path) -> Result { + 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)) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..e2ecb19 --- /dev/null +++ b/src/main.rs @@ -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 +} diff --git a/src/middleware/auth.rs b/src/middleware/auth.rs new file mode 100644 index 0000000..56ecf70 --- /dev/null +++ b/src/middleware/auth.rs @@ -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 Transform for ApiKeyAuth +where + S: Service, Error = Error> + 'static, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type InitError = (); + type Transform = ApiKeyAuthMiddleware; + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ready(Ok(ApiKeyAuthMiddleware { + service: Rc::new(service), + })) + } +} + +pub struct ApiKeyAuthMiddleware { + service: Rc, +} + +impl Service for ApiKeyAuthMiddleware +where + S: Service, Error = Error> + 'static, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse; + type Error = Error; + type Future = LocalBoxFuture<'static, Result>; + + 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::>() { + if provided_key == config.api_key { + return service.call(req).await; + } + } + } + + // Return unauthorized error + Err(ServiceError::Unauthorized.into()) + }) + } +} diff --git a/src/middleware/mod.rs b/src/middleware/mod.rs new file mode 100644 index 0000000..0e4a05d --- /dev/null +++ b/src/middleware/mod.rs @@ -0,0 +1 @@ +pub mod auth; diff --git a/src/models/email.rs b/src/models/email.rs new file mode 100644 index 0000000..c35c50e --- /dev/null +++ b/src/models/email.rs @@ -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, + 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, + pub mime_type: String, + pub filename: Option, + pub headers: Vec, + pub body: EmailBody, + pub parts: Option>, +} + +#[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, + pub size: u64, + pub data: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ListEmailsQuery { + pub q: Option, + #[serde(rename = "maxResults")] + pub max_results: Option, + #[serde(rename = "pageToken")] + pub page_token: Option, + #[serde(rename = "labelIds")] + pub label_ids: Option>, + #[serde(rename = "includeSpamTrash")] + pub include_spam_trash: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct SendEmailRequest { + pub to: Vec, + pub cc: Option>, + pub bcc: Option>, + pub subject: String, + pub body: String, + #[serde(rename = "contentType")] + pub content_type: Option, + #[serde(rename = "fromName")] + pub from_name: Option, + pub attachments: Option>, +} + +#[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, + #[serde(rename = "nextPageToken")] + pub next_page_token: Option, + #[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>, +} diff --git a/src/models/error.rs b/src/models/error.rs new file mode 100644 index 0000000..5678116 --- /dev/null +++ b/src/models/error.rs @@ -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) + } + } + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..d8e8945 --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,3 @@ +pub mod email; +pub mod error; +pub mod template; diff --git a/src/models/template.rs b/src/models/template.rs new file mode 100644 index 0000000..232f455 --- /dev/null +++ b/src/models/template.rs @@ -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, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct TemplateEmailRequest { + pub to: Vec, + pub cc: Option>, + pub bcc: Option>, + pub template_id: String, + pub variables: HashMap, +} + +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) -> 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 { + 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, +} + +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() + } +} diff --git a/src/services/gmail.rs b/src/services/gmail.rs new file mode 100644 index 0000000..38326ff --- /dev/null +++ b/src/services/gmail.rs @@ -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, // 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>>, + user_email: Option, // 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 { + 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 { + 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 { + 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 { + 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 { + 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::>() + .join("&"), + ); + } + + url + } + + fn build_raw_email(&self, request: SendEmailRequest) -> Result { + 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( + &self, + request_builder: F, + ) -> Result + 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 { + 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 { + 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 { + 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 { + // 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 { + // 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 { + 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 { + 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 + ))) + } + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 0000000..9cc23e1 --- /dev/null +++ b/src/services/mod.rs @@ -0,0 +1 @@ +pub mod gmail;