public-ready-init

This commit is contained in:
Damien Coles 2026-01-26 01:36:06 -05:00
commit 565cc5ace7
21 changed files with 1666 additions and 0 deletions

9
.dockerignore Normal file
View File

@ -0,0 +1,9 @@
target/
.git/
.gitignore
README.md
.env
.env.*
*.log
.DS_Store
Thumbs.db

11
.env.example Normal file
View 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
View File

@ -0,0 +1,7 @@
/target
/.env
/service_account_key.json
Cargo.lock
*.log
.DS_Store
.idea

36
Cargo.toml Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,3 @@
pub mod emails;
pub mod health;
pub mod templates;

71
src/handlers/templates.rs Normal file
View 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
View 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
View 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
View File

@ -0,0 +1 @@
pub mod auth;

96
src/models/email.rs Normal file
View 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
View 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
View File

@ -0,0 +1,3 @@
pub mod email;
pub mod error;
pub mod template;

168
src/models/template.rs Normal file
View 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
View 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(
&params
.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
View File

@ -0,0 +1 @@
pub mod gmail;