This document describes a system that provides a unified Email Provider + Sender platform: a Web API to manage OAuth connections to external providers (Gmail, Outlook, etc.), store user tokens, and a separate microservice that exposes unified async APIs to send, fetch, and list email messages across configured providers.
Overview
The system enables users to connect their email provider accounts (Gmail, Outlook, etc.) via OAuth, then send and receive emails through a unified API. It decouples user authentication/authorization (OAuth onboarding and token storage) from email operations (send, get, list) which are processed asynchronously using queues and background processors.
Primary goals:
- Unified API surface for multiple providers
- Secure storage and handling of user tokens and attachments
- Asynchronous, resilient dispatch with retries and per-provider processors
- Scalability and observability
Requirements
Functional Requirements
- User onboarding: allow users to connect an external email provider via OAuth and store access/refresh tokens
- Unified Send API: accept an email send request (recipients, subject, body, attachments metadata) and enqueue for dispatch
- Attachments: attachments are uploaded via attachment CRUD APIs and stored in blob storage; metadata stored in DB; attachments encrypted at rest
- Inbox APIs: list messages, get message detail (headers/body/attachments) from provider or cached copy
- Async Delivery: sending must be asynchronous — user receives accepted response and later get status
- Per-provider operations: support Gmail and Outlook (initial), pluggable connectors for other providers
- Retry & Failure Handling: retries, exponential backoff, DLQ
- Web UI: integration button to begin OAuth flows and show connection status
- Provider callback/webhook processing (where supported)
Non-Functional Requirements
- Security: encrypt tokens and attachments; least privilege; secrets management
- Scalability: system should scale for many users and high concurrent send rates
- Durability: attachments and message metadata persisted reliably
- Observability: metrics, tracing, logs for send flows and provider interactions
- Low Latency for API responses (acceptance time); async delivery can be eventual
Constraints and Assumptions
- Max recipients per send request: configurable (example: 100)
- Max attachments per message: 10; max attachment size: 5 MB per attachment (policy)
- Providers: initial support for Gmail (via Gmail REST API) and Outlook/Graph API
- Cloud platform: Azure-first (Azure Blob Storage, Azure Service Bus / Storage Queues, Azure Functions) but design is portable
Design Considerations
Key decisions:
- Split responsibilities into two microservices:
- Auth Service (Web): OAuth onboarding, token management, basic user management, front-end UI redirect
- Email Service (Backend): Unified send/get/list APIs, validation, queuing, dispatch, inbox sync
- Use relational DB (Postgres / Azure SQL) for metadata (users, tokens, message metadata). Use blob storage for attachments and optionally whole payloads for large bodies.
- Encrypt sensitive data at rest (tokens & attachments) and encrypt in transit (TLS everywhere)
- Use queues for reliable processing and to provide backpressure and scaling
- Use per-provider connector modules that implement a common interface for send/list/get operations
- Use idempotency keys for send requests to avoid duplicate sends
High-Level Architecture
┌─────────────┐
│ Clients │
│ (UI/API/CLI)│
└──────┬──────┘
│
┌───────────▼───────────┐
│ API Gateway / LB │
└───────┬────┬──────────┘
│ │
┌───────▼┐ ┌▼────────┐
│ Auth │ │ Email │
│Service│ │ Service │
│ (Web) │ │ (API) │
└──┬────┘ └──┬───────┘
│ │
OAuth Redirects & Callbacks │ │ Send / Get / List APIs
│ │
┌─────────────────────────────▼──────────┐│
│ Token DB (encrypted) ││
│ User / Token / Conn table ││
└────────────────────────────────────────┘│
│
Attachment Uploads -> Blob Storage (encrypted)│
│
Email Service validation => Enqueue -> Dispatch Queue
│ │
┌────────────▼───────────┐ │
│ Dispatch Azure Func │ │
│ (select provider) │ │
└──────┬─────────┬───────┘ │
│ │ │
Provider Queue A Provider Queue B │
(Gmail) (Outlook) │
│ │ │
Provider Processor Provider Processor │
(send via API / SMTP) (send via API) │
└─────────┴────────────┬────┘
Sent Queue
│
Post-send processors
(status update, DLQ, webhooks)
Notes:
Auth Servicehandles OAuth flows and stores tokens inToken DB(encrypted).Email Servicevalidates send requests, persists metadata, stores attachments in blob storage, and enqueues messages intoDispatch Queue.Dispatch Azure Functionpicks messages fromDispatch Queue, chooses provider, and pushes to provider-specific queues (or directly invokes provider).- Provider processors are responsible for provider-specific transforms, retry policies, and final status reporting to
Sent Queue.
Data Model (Relational)
Below are example tables and DDL suggestions.
users
CREATE TABLE users (
id UUID PRIMARY KEY,
email VARCHAR(320) NOT NULL UNIQUE,
display_name VARCHAR(255),
created_at timestamptz default now()
);
provider_connections (stores per-user provider OAuth info — encrypted tokens)
CREATE TABLE provider_connections (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
provider_name VARCHAR(50) NOT NULL, -- e.g., 'gmail', 'outlook'
provider_account_id VARCHAR(255), -- provider's account id
access_token TEXT NOT NULL, -- encrypted
refresh_token TEXT, -- encrypted
token_expires_at timestamptz,
scopes TEXT[],
created_at timestamptz default now(),
updated_at timestamptz default now(),
is_active boolean default true
);
CREATE INDEX idx_provider_user ON provider_connections(user_id, provider_name);
messages (metadata for each send request)
CREATE TABLE messages (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
provider_connection_id UUID REFERENCES provider_connections(id), -- preferred provider
external_id VARCHAR(255), -- provider message id (nullable until sent)
status VARCHAR(50) NOT NULL, -- queued, sending, sent, failed, retrying
subject TEXT,
body_format VARCHAR(10) NOT NULL DEFAULT 'html', -- html|text
created_at timestamptz default now(),
updated_at timestamptz default now(),
scheduled_at timestamptz NULL,
idempotency_key VARCHAR(255) NULL,
recipients JSONB, -- array with to/cc/bcc
attachments JSONB, -- array of attachment metadata (blob id, size, name)
attempt_count int default 0
);
CREATE INDEX idx_messages_user ON messages(user_id, created_at DESC);
CREATE UNIQUE INDEX idx_messages_idempotency ON messages (idempotency_key) WHERE idempotency_key IS NOT NULL;
attachments (metadata only; content in blob)
CREATE TABLE attachments (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users(id),
blob_path TEXT NOT NULL,
size_bytes bigint NOT NULL,
mime_type VARCHAR(255),
name VARCHAR(512),
uploaded_at timestamptz default now(),
encrypted boolean default true
);
CREATE INDEX idx_attachments_user ON attachments(user_id, uploaded_at DESC);
Storage Choices
- Blob Storage: store attachments and optional raw message blobs. Use server-side encryption and optionally client-side encryption before upload.
- Relational DB: Postgres for strong consistency, transactional operations (message metadata, tokens).
- Cache (Redis): cache provider tokens metadata, rate-limiting counters, and recent message status.
- Queues: Azure Service Bus (preferred for advanced features), or Storage Queues for simpler designs.
API Design (Auth Service)
All requests use HTTPS and JSON. OAuth redirects originate from the UI.
1. Begin OAuth (UI integration button)
GET /v1/connect/{provider}?userId={userId}&returnUrl={...}
- Purpose: returns provider OAuth authorization URL to redirect the user.
- Response: 302 redirect to provider or JSON {url}
2. OAuth Callback
POST /v1/connect/{provider}/callback
- Provider redirects to this endpoint with code/state.
- Server exchanges code for access/refresh tokens, stores encrypted tokens in
provider_connections. - Validate
statefor CSRF protection. - Response: 200 + redirect to UI return URL.
3. List Connections
GET /v1/connections?userId={userId}
- Returns active connections for user (provider_name, provider_account_id, connected_at)
4. Revoke / Disconnect
DELETE /v1/connections/{connectionId}
- Revoke tokens in provider (if possible) and mark record inactive.
API Design (Email Service)
1. Send Email (async accept)
POST /v1/messages/send Request JSON (validated):
{
"userId": "uuid",
"providerPreference": "gmail", // optional
"idempotencyKey": "optional-client-key",
"subject": "...",
"body": "<html>...</html>",
"bodyFormat": "html",
"to": ["a@example.com"],
"cc": [],
"bcc": [],
"attachments": ["attachmentId1","attachmentId2"],
"scheduledAt": "2025-11-05T...Z"
}
Response:
- 202 Accepted { "messageId": "uuid", "status": "queued" } Errors: 400 (validation), 413 (attachment size), 422 (recipient count), 429 (rate limit)
Server-side validation steps:
- Validate userId & active provider connection existence
- Validate recipients count against policy
- Validate attachments count & sizes by fetching metadata from
attachments - Save message metadata in
messagestable with statusqueued - Enqueue message id to dispatch queue
2. Attachment CRUD
- Upload: multipart/form-data POST /v1/attachments/upload -> returns attachmentId; server stores blob and metadata
- Delete/Get/List attachments per user
3. Message Status / Get / List
- GET /v1/messages/{messageId} -> returns metadata, status, provider external id etc.
- GET /v1/messages?userId=&limit=&offset= -> returns list
- GET /v1/messages/{messageId}/raw -> returns body and attachments links (signed URLs)
4. Inbox APIs (unified)
- GET /v1/inbox/list?userId={userId}&provider={optional}&since={}
- Implementation: proxy to provider connector that may return cached items from DB or trigger provider API fetch.
- GET /v1/inbox/{providerMessageId} -> unified message representation
Async Dispatch Flow
- Client POST /v1/messages/send -> API validates and persists message + attachments metadata
- API enqueues message id to
dispatch-queue Dispatch Azure Functiontriggered bydispatch-queuepicks message:- Retrieve message metadata and attachments
- Determine provider (explicit or pick by preference/availability)
- Transform message to provider format (MIME, REST payload)
- Place into provider-specific queue (e.g.,
gmail-send-queue) OR attempt immediate send
- Provider Processor picks provider queue entry and calls the provider API (Gmail/Graph):
- Use stored access_token; refresh if expired using refresh_token
- Ensure idempotency header or client-side idempotency if supported
- On success: update messages.external_id and status =
sent; push tosent-queue - On transient failure: increment attempt_count, schedule retry using exponential backoff; if attempts exceed threshold push to DLQ
- On permanent failure: mark
failedand emit events/notifications
- Post-send processors consume
sent-queuefor user notifications, analytics, and webhook firing to client callbacks (if any)
Per-provider queues vs single queue
- Per-provider queue gives better isolation, allows provider-specific throttling and scaling, and simpler backoff policies. Recommended for production.
Retry & Backoff
- Use exponential backoff with jitter: base * 2^attempt +- jitter
- Use attempt_count in
messagestable to limit retries (example: max 5 attempts) - Use a DLQ for messages that consistently fail
Idempotency
- API accepts
idempotencyKey;messagestable has unique index to ensure duplicate client retries map to same message - Provider-level idempotency: use provider features where available (e.g., Gmail
messageIdor idempotent header) or implement dedupe in our processor using external_id + user
Provider Integration Patterns
Gmail (REST API):
- Use Gmail REST
users.messages.sendto send encoded MIME parts or raw base64. Use Gmail push notifications (Pub/Sub) for inbox sync if desired. - OAuth scopes:
https://www.googleapis.com/auth/gmail.send,.../gmail.readonlyas needed. - Token refresh using refresh_token; store refresh_token and rotate securely.
- Use Gmail REST
Outlook / Microsoft Graph:
- Use Graph
POST /me/sendMailormessagesendpoints for sending and reading. - OAuth scopes:
Mail.Send,Mail.Read, etc.
- Use Graph
IMAP/SMTP fallback:
- For providers where REST APIs are not available/desired, support SMTP send connectors and IMAP for fetch, but ensure OAuth XOAUTH2 or app-specific passwords and security.
Inbox sync strategies:
- Push (preferred): use provider push mechanisms (Gmail Pub/Sub); requires additional infra to receive and validate notifications.
- Poll (simple): poll provider on schedule with incremental sync (historyId, delta tokens) and store unified messages in DB.
Security
- Encrypt access/refresh tokens at rest with a KMS-managed key (Azure Key Vault or AWS KMS)
- Encrypt attachments in blob storage; consider client-side encryption for highly sensitive data
- Use least privilege for service principals and provider OAuth client registration
- Sanitize and validate all user-supplied content
- Rate-limit APIs and per-user quotas
- Rotate encryption keys periodically and support token rotation
- Store audit logs of token usage and provider calls
Observability & Monitoring
- Metrics: queue length, dispatch latency, send success/failure rates, API error rates, token refresh counts
- Tracing: distributed tracing across API -> queue -> dispatcher -> provider
- Logs: structured logs with correlation ids
- Alerts: DLQ growth, token refresh failures, high failure rates for providers
Low-Level Design: C# Skeleton
Below are key interfaces and classes to implement core logic. This is a skeleton to start a .NET solution.
Models
public record User(Guid Id, string Email, string DisplayName);
public class ProviderConnection
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public string ProviderName { get; set; }
public string ProviderAccountId { get; set; }
public string EncryptedAccessToken { get; set; }
public string EncryptedRefreshToken { get; set; }
public DateTime? TokenExpiresAt { get; set; }
}
public class MessageEntity
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public Guid? ProviderConnectionId { get; set; }
public string Status { get; set; }
public string Subject { get; set; }
public string Body { get; set; }
public string BodyFormat { get; set; }
public int AttemptCount { get; set; }
public string IdempotencyKey { get; set; }
public string RecipientsJson { get; set; }
public string AttachmentsJson { get; set; }
public string ExternalId { get; set; }
}
public class AttachmentEntity { /* blob id, name, size, mime */ }
Provider Connector Interface
public interface IEmailProviderConnector
{
string ProviderName { get; }
Task<SendResult> SendAsync(ProviderConnection connection, MessageEntity message, CancellationToken ct);
Task<ProviderMessage> GetMessageAsync(ProviderConnection connection, string providerMessageId, CancellationToken ct);
Task<IEnumerable<ProviderMessage>> ListMessagesAsync(ProviderConnection connection, ListOptions options, CancellationToken ct);
}
Dispatch Flow (Azure Function skeleton)
public class DispatchFunction
{
private readonly IMessageRepository _messages;
private readonly IProviderFactory _providerFactory; // returns connector by name
private readonly IQueueClient _providerQueueClient; // Service Bus client
[FunctionName("DispatchQueueTrigger")]
public async Task Run(
[ServiceBusTrigger("dispatch-queue", Connection = "ServiceBusConn")] string messageId,
ILogger log)
{
var msg = await _messages.GetAsync(Guid.Parse(messageId));
// choose provider
var connector = _providerFactory.Resolve(msg.ProviderConnectionId ?? default);
// transform and enqueue to provider queue
var providerEntry = new ProviderQueueEntry { MessageId = msg.Id, Payload = Transform(msg) };
await _providerQueueClient.SendMessageAsync(providerEntry);
}
}
Provider Processor (Service Bus Trigger)
public class ProviderProcessor
{
private readonly IEmailProviderConnector _connector;
private readonly IMessageRepository _messages;
[FunctionName("GmailSendProcessor")]
public async Task Run([ServiceBusTrigger("gmail-send-queue")] ProviderQueueEntry entry, ILogger log)
{
var msg = await _messages.GetAsync(entry.MessageId);
try {
var conn = await _messages.GetProviderConnection(msg.UserId, "gmail");
var result = await _connector.SendAsync(conn, msg, CancellationToken.None);
// update status
msg.Status = result.Success ? "sent" : "failed";
msg.ExternalId = result.ProviderMessageId;
await _messages.UpdateAsync(msg);
} catch (TransientException t) {
await _messages.IncrementAttempt(msg.Id);
// schedule retry using Service Bus ScheduledEnqueueTimeUtc
}
}
}
Token Refresh Helper
- Implement a background job (e.g., timer-triggered Azure Function) to scan
provider_connectionsfor tokens near expiry and refresh them proactively. Store rotated tokens encrypted and update DB.
Operational Concerns & Edge Cases
- Duplicate sends: handle via idempotency keys and provider-side idempotency where possible.
- Token revocation: handle revoked tokens gracefully and surface helpful UI messages to re-authenticate.
- Large attachments: enforce limits and surface user-friendly errors; consider chunked upload if needed.
- Provider rate limits: implement per-provider rate limiter and backoff.
- Inbox sync conflicts: use message IDs and history tokens to dedupe.
- Privacy and compliance: purge tokens and attachments on user request (Right to be forgotten).
Monitoring and Quality Gates
- Build: .NET solution should compile and unit tests run.
- Lint/Typecheck: use Roslyn analyzers and CI checks.
- Tests: unit tests for connectors (mocked), and integration test harness against sandbox Gmail/Graph accounts.
Next Steps / Implementation Plan
- Implement Auth Service OAuth flows and token DB (MVP)
- Build Email Service API: send, attachments, list
- Implement Dispatch Function and provider connectors for Gmail & Outlook
- Add monitoring and tracing
- Create end-to-end test harness with sandbox accounts