@pavas-core/email-orchestrator
v1.0.3
Published
NestJS library for email orchestration - Google Workspace, Microsoft Outlook, IMAP/SMTP
Readme
@pavas-core/email-orchestrator
Librería NestJS para orquestar correos electrónicos con múltiples proveedores desde una API unificada.
Características
- Google Workspace (Gmail) — OAuth2, Gmail API y push notifications vía Pub/Sub
- Microsoft Outlook — OAuth2, Microsoft Graph y webhooks para notificaciones
- IMAP/SMTP — Conexión estándar, config por operación (sin
forRoot)
Interfaces compartidas, paginación por cursor y flujos OAuth delegados al consumidor. La librería no almacena credenciales; tú gestionas tokens y configs.
- Scopes de perfil siempre incluidos —
email,profile,openid(Google) yUser.Read,openid,profile(Outlook) se agregan automáticamente para obtener info de la cuenta. - Listar conexiones —
listConnections(connectionKey)por usuario;listConnections()sin key devuelve todas agrupadas por key.
Requisitos
- NestJS 10 o 11
- Node.js 18+
- Todas las credenciales las provee el consumidor (client ID, secret, scopes, tokens). La librería no persiste ni gestiona credenciales.
Instalación
npm install @pavas-core/email-orchestratorRepositorio: github.com/juanpavasgarzon/email-orchestrator
Quick Start
Google (Gmail)
tokenCache opcional (usa InMemoryTokenCache por defecto). global opcional: si true, GoogleService disponible en toda la app; si false (por defecto), importa el módulo donde lo necesites.
import { GoogleModule, GoogleService } from '@pavas-core/email-orchestrator';
// Opción 1: Sin config (usa InMemoryTokenCache - almacenamiento automático)
@Module({
imports: [
GoogleModule.forRoot({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectUri: process.env.GOOGLE_REDIRECT_URI!,
scopes: [
'https://www.googleapis.com/auth/gmail.readonly',
'https://www.googleapis.com/auth/gmail.send',
],
global: true, // opcional: false por defecto
}),
],
})
export class AppModule {}// OAuth: redirigir al login
@Get('auth/google')
redirectToGoogle() {
return { url: this.googleService.getAuthUrl() };
}
// Callback: tokens se almacenan automáticamente con key userId-google
@Get('auth/google/callback')
async handleCallback(@Query('code') code: string, @Req() req) {
const userId = req.user?.id ?? 'default';
await this.googleService.exchangeCodeForTokens(code, userId);
return { ok: true };
}
// Listar emails (connectionKey = userId del request)
const page = await this.googleService.listEmails(userId, { limit: 20 });Propagación de state OAuth: Pasa datos (userId, returnTo, etc.) entre inicio y callback:
import {
GoogleService,
encodeOAuthState,
decodeOAuthState,
} from '@pavas-core/email-orchestrator';
// Inicio: incluir state con datos
@Get('auth/google')
redirectToGoogle(@Req() req) {
const userId = req.user?.id ?? 'default';
const state = encodeOAuthState({ userId, returnTo: '/dashboard' });
return { url: this.googleService.getAuthUrl(state) };
}
// Callback: leer state para obtener connectionKey
@Get('auth/google/callback')
async handleCallback(
@Query('code') code: string,
@Query('state') state: string,
) {
const payload = decodeOAuthState(state ?? '');
const connectionKey = payload?.userId ?? 'default';
await this.googleService.exchangeCodeForTokens(code, connectionKey);
return { redirect: payload?.returnTo ?? '/' };
}tokenCache custom (DB, Redis, etc.) — los tokens se almacenan automáticamente con key connectionKey-provider:
import type { TokenCache } from '@pavas-core/email-orchestrator';
const myCache: TokenCache = {
async get(key) { return db.get(key); },
async set(key, tokens) { await db.set(key, tokens); },
};
GoogleModule.forRoot({
clientId: '...',
clientSecret: '...',
redirectUri: '...',
scopes: ['...'],
tokenCache: myCache,
});Outlook
global opcional (false por defecto). tokenCache opcional.
import { OutlookModule } from '@pavas-core/email-orchestrator';
OutlookModule.forRoot({
clientId: process.env.OUTLOOK_CLIENT_ID!,
clientSecret: process.env.OUTLOOK_CLIENT_SECRET!,
tenantId: process.env.OUTLOOK_TENANT_ID!,
redirectUri: process.env.OUTLOOK_REDIRECT_URI!,
scopes: [
'https://graph.microsoft.com/Mail.Read',
'https://graph.microsoft.com/Mail.Send',
],
global: true, // opcional
tokenCache: myCache,
});Flujo OAuth: getAuthUrl() → redirect → callback con exchangeCodeForTokens(code) → tokens guardados en tokenCache.
Google y Outlook usan la misma configuración: clientId, clientSecret, redirectUri, scopes, tokenCache. Solo Outlook añade tenantId. El cliente provee la persistencia (tokenCache); la librería construye el token provider internamente.
MSAL (@azure/msal-node): La librería no incluye MSAL. Instálalo en tu app (npm install @azure/msal-node). Implementa un tokenCache que lea de MSAL; es el único punto externo de persistencia:
import { ConfidentialClientApplication } from '@azure/msal-node';
import type { TokenCache, OAuthTokens } from '@pavas-core/email-orchestrator';
// Tu TokenCache que adapta MSAL
const msalTokenCache: TokenCache<OAuthTokens> = {
async get(key: string) {
const connectionKey = key.replace('-outlook', '');
const integration = await getIntegrationByConnectionKey(connectionKey);
const homeAccountId = integration.metadata.home_account_id as string;
const client = createMsalClient(homeAccountId); // ConfidentialClientApplication con cache
const account = await client.getTokenCache().getAccountByHomeId(homeAccountId);
const result = await client.acquireTokenSilent({ account, scopes: integration.scopes });
return {
accessToken: result.accessToken,
refreshToken: '',
expiresAt: result.expiresOn ? result.expiresOn.getTime() : undefined,
};
},
async set(key, tokens) {
// MSAL ya persiste en acquireTokenByCode; o sincroniza aquí si usas nuestro exchange
},
};
OutlookModule.forRoot({ ...config, tokenCache: msalTokenCache });En el callback OAuth usa acquireTokenByCode de MSAL (no exchangeCodeForTokens). MSAL almacena en su cache; tu tokenCache.get() lee desde ahí.
IMAP / SMTP
Sin config de credenciales a nivel módulo. Usa forRoot(). Opción global: si true, ImapSmtpService está disponible en toda la app; si false (por defecto), importa el módulo en cada módulo que lo use.
import { ImapSmtpModule, ImapSmtpService } from '@pavas-core/email-orchestrator';
import type { ImapSmtpConnectionConfig } from '@pavas-core/email-orchestrator';
@Module({
imports: [ImapSmtpModule.forRoot()], // forRoot({ global: true }) si quieres ImapSmtpService en toda la app
})
export class AppModule {}
// Registrar conexión (aparece en listConnections)
@Post('imap-smtp/connect')
async register(@Body() config: ImapSmtpConnectionConfig, @Req() req) {
const userId = req.user?.id;
await this.imapSmtp.registerConnection(userId, config);
}
// Enviar con config pasada (solo smtp configurado = envío)
await this.imapSmtp.sendEmail(config, { to: '...', subject: '...', text: '...' });
// Listar con config pasada (solo imap configurado = lectura)
await this.imapSmtp.listEmails(config, { limit: 20 });
// Config: imap y/o smtp (al menos uno)
const connection: ImapSmtpConnectionConfig = {
imap: { host: 'imap.ejemplo.com', port: 993, user: '...', password: '...', tls: true },
smtp: { host: 'smtp.ejemplo.com', port: 587, user: '...', password: '...', secure: false },
};Listar conexiones establecidas
Con Google, Outlook o ImapSmtp importados, ConnectionRegistry está disponible. Inyecta y pasa el connectionKey (userId, apiKey, etc.):
import { ConnectionRegistry } from '@pavas-core/email-orchestrator';
@Injectable()
export class ConnectionsController {
constructor(private readonly connections: ConnectionRegistry) {}
@Get('connections')
async list(@Req() req, @Query('all') all?: string) {
if (all === 'true') {
return this.connections.listConnections(); // todas las conexiones (por key)
}
const connectionKey = req.user?.id ?? req.headers['x-api-key'];
return this.connections.listConnections(connectionKey); // solo ese usuario
}
}Cada ítem incluye:
provider—'google'|'outlook'|'imap-smtp'email,name,picture— datos de la cuentarequestedScopes— permisos solicitados en la configgrantedScopes— permisos efectivamente otorgados (pueden ser menos según Workspace/Admin)
Persistencia sustituible: Por defecto usa memoria. Para DB, Redis, etc.:
import {
ConnectionRegistry,
CONNECTION_PERSISTENCE,
type ConnectionPersistence,
} from '@pavas-core/email-orchestrator';
@Injectable()
class MyDbConnectionPersistence implements ConnectionPersistence {
async get(connectionKey: string) { /* devuelve ConnectionInfo[] | null */ }
async set(connectionKey: string, connections: ConnectionInfo[]) { /* almacena */ }
async getAll() { /* devuelve ConnectionGroup[] = { connectionKey, connections }[] */ }
}
@Module({
imports: [GoogleModule, OutlookModule],
providers: [
{ provide: CONNECTION_PERSISTENCE, useClass: MyDbConnectionPersistence },
],
})
export class AppModule {}listConnections(connectionKey?)— con key: consulta APIs y actualiza. Sin key: devuelve todas desde cache (ConnectionGroup[]).getCachedConnections(connectionKey?)— con key: conexiones del usuario. Sin key: todas (ConnectionGroup[]).mergeConnection(connectionKey, connectionInfo)— fusiona una conexión en la persistencia (usado por getConnectionInfo de cada provider).
Reducción de costos: Los scopes de email (Google Workspace, Microsoft Graph) tienen un costo monetario alto. Para minimizar llamadas a APIs:
import {
ConnectionModule,
CONNECTION_REGISTRY_OPTIONS,
type ConnectionRegistryOptions,
} from '@pavas-core/email-orchestrator';
@Module({
imports: [GoogleModule, OutlookModule],
providers: [
{
provide: CONNECTION_REGISTRY_OPTIONS,
useValue: {
connectionInfoCacheTtlMs: 300000, // 5 min cache para listConnections
} as ConnectionRegistryOptions,
},
],
})
export class AppModule {}getConnectionInfode cada provider (Google, Outlook, IMAP/SMTP) actualiza automáticamente la cache de ConnectionRegistry.
Ejemplo de respuesta listConnections(connectionKey) (un usuario):
[
{
"provider": "google",
"email": "[email protected]",
"name": "Usuario",
"picture": "https://...",
"requestedScopes": ["email", "profile", "openid", "https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/gmail.send"],
"grantedScopes": ["https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile", "openid", "https://www.googleapis.com/auth/gmail.readonly"]
},
{
"provider": "outlook",
"email": "[email protected]",
"name": "Usuario Empresa",
"requestedScopes": ["User.Read", "openid", "profile", "Mail.Read", "Mail.Send"],
"grantedScopes": ["User.Read", "Mail.Read", "Mail.Send"]
}
]Ejemplo de respuesta listConnections() sin key (todas las conexiones, desde cache):
[
{
"connectionKey": "user123",
"connections": [
{ "provider": "google", "email": "[email protected]", "name": "Usuario", "requestedScopes": [...], "grantedScopes": [...] }
]
},
{
"connectionKey": "user456",
"connections": [
{ "provider": "outlook", "email": "[email protected]", "name": "Otro", "requestedScopes": [...], "grantedScopes": [...] }
]
}
]Token Cache
Siempre se usa tokenCache. La key es connectionKey-provider (ej: user123-google).
- Sin tokenCache: usa
InMemoryTokenCache, tokens se guardan en el callback. - Con tokenCache:
exchangeCodeForTokensyrefreshAccessTokenhacenset(key, tokens)automáticamente.
Implementa TokenCache para cache custom (DB, Redis, etc.):
import type { TokenCache, OAuthTokens } from '@pavas-core/email-orchestrator';
class MyTokenCache implements TokenCache {
async get(key: string): Promise<OAuthTokens | null> {
return await db.getTokens(key);
}
async set(key: string, tokens: OAuthTokens): Promise<void> {
await db.saveTokens(key, tokens);
}
}Helpers sin Nest
Puedes usar los helpers OAuth fuera de Nest en rutas externas o scripts:
import {
getGoogleAuthUrl,
exchangeGoogleCodeForTokens,
refreshGoogleAccessToken,
} from '@pavas-core/email-orchestrator/google';
import {
getOutlookAuthUrl,
exchangeOutlookCodeForTokens,
refreshOutlookAccessToken,
} from '@pavas-core/email-orchestrator/outlook';Push Notifications
Gmail (Pub/Sub)
1. Google Cloud Console
- Crea un proyecto y habilita Gmail API.
- Credenciales OAuth con scopes
gmail.readonlyygmail.modify.
2. Pub/Sub
# Crear topic
gcloud pubsub topics create gmail-notifications
# Otorgar permisos a Gmail API
gcloud pubsub topics add-iam-policy-binding gmail-notifications \
--member="serviceAccount:[email protected]" \
--role="roles/pubsub.publisher"3. Push subscription
Crea una suscripción que apunte a tu endpoint HTTPS (ej. https://tu-app.com/webhooks/gmail):
gcloud pubsub subscriptions create gmail-push \
--topic=gmail-notifications \
--push-endpoint=https://tu-app.com/webhooks/gmail4. Código
// Módulo con callbacks configurables (onNewEmail, onReply)
GoogleModule.forRoot({
// ... clientId, clientSecret, etc.
pushNotificationCallbacks: {
onNewEmail: (event) => console.log('Correo nuevo:', event.message?.subject),
onReply: (event) => console.log('Respondieron:', event.message?.subject),
},
});
// Iniciar watch (después de OAuth)
const { historyId, expiration } = await this.googleService.setupPushNotifications(
'projects/TU_PROJECT_ID/topics/gmail-notifications'
);
// Endpoint POST: una sola llamada a handleWebhook invoca los callbacks automáticamente
@Post('webhooks/gmail')
async handleGmailPush(@Body() body: unknown) {
await this.googleService.handleWebhook(body);
}Tipos de notificación (ambos pueden estar habilitados):
onNewEmail: correo nuevo en el buzón (se llama siempre que llega algo)onReply: alguien respondió un correo (tiene In-Reply-To). Si es reply, se invocan ambos callbacks
Outlook (Webhooks)
1. Endpoint HTTPS
Tu app debe exponer un endpoint que reciba solicitudes de Microsoft (ej. https://tu-app.com/webhooks/outlook).
2. Validación inicial
Microsoft envía primero un GET con ?validationToken=xxx. Tu endpoint debe responder 200 con el body = exactamente el validationToken (texto plano):
// Módulo con callbacks configurables
OutlookModule.forRoot({
// ...
pushNotificationCallbacks: {
onNewEmail: (event) => this.notifyNewEmail(event),
onReply: (event) => this.notifyReply(event),
},
});
@All('webhooks/outlook')
async handleOutlookWebhook(@Req() req: Request, @Res() res: Response) {
if (this.outlookService.isValidationRequest(req.query)) {
return res.status(200).send(this.outlookService.getValidationResponse(req.query));
}
res.status(202).send(); // Responder en < 3 segundos
void this.outlookService.handleWebhook(req.body); // Procesa e invoca callbacks
}3. Crear suscripción
const { subscriptionId, expirationDateTime } =
await this.outlookService.setupPushNotifications(
'https://tu-app.com/webhooks/outlook'
);
// Guardar subscriptionId y renovar antes de expirationDateTime4. Renovar
await this.outlookService.renewPushSubscription(subscriptionId);IMAP IDLE (push-like)
Para IMAP no hay webhooks nativos, pero startIdleWatcher simula push:
const stop = await this.imapSmtpService.startIdleWatcher(
connectionConfig,
(newMail) => console.log('Nuevo email:', newMail)
);
// Cuando quieras desconectar:
stop();Interfaces Comunes
Todos los servicios usan las mismas interfaces y paginación por cursor:
| Tipo | Descripción |
|---------------------|--------------------------------------------------|
| EmailMessage | Mensaje normalizado (id, subject, from, to, body) |
| ListEmailsOptions | { limit?, cursor?, filter?, orderBy?, query?, labelIds? } — filter/orderBy unificados |
| ListEmailsResult | { items, nextCursor?, hasMore } |
| SendEmailOptions | Opciones para enviar |
| SendEmailResult | { messageId } |
| FetchHistoryOptions | { startHistoryId?, cursor?, limit? } |
| FetchHistoryResult | { events, nextCursor?, hasMore } |
import type {
EmailMessage,
ListEmailsOptions,
ListEmailsFilter,
ListEmailsOrderBy,
ListEmailsResult,
SendEmailOptions,
SendEmailResult,
} from '@pavas-core/email-orchestrator';
// Filtro unificado (Google, Outlook, IMAP/SMTP - misma sintaxis)
await googleService.listEmails(userId, {
filter: { from: '[email protected]', subject: 'foo', isUnread: true },
orderBy: { field: 'date', direction: 'desc' },
limit: 20,
});Varios Proveedores
Puedes importar varios módulos en el mismo proyecto:
@Injectable()
export class EmailOrchestrator {
constructor(
private readonly googleService: GoogleService,
private readonly outlookService: OutlookService,
private readonly imapSmtpService: ImapSmtpService,
) {}
async sendViaGoogle(connectionKey: string, opts: SendEmailOptions) {
return this.googleService.sendEmail(connectionKey, opts);
}
async sendViaImap(config: ImapSmtpConnectionConfig, opts: SendEmailOptions) {
return this.imapSmtpService.sendEmail(config, opts);
}
}Arquitectura de servicios (SOLID)
Cada provider expone 2 servicios con responsabilidades separadas:
| Provider | Auth/Connection | Utilidad |
|----------|-----------------|----------|
| Google | GoogleAuthService — OAuth, tokens, getAuthUrl | GoogleService — email ops, push (extiende Auth) |
| Outlook | OutlookAuthService — OAuth, tokens, MSAL | OutlookService — email ops, webhooks (extiende Auth) |
| IMAP/SMTP | ImapSmtpConnectionService — registerConnection, getConnectionInfo | ImapSmtpService — sendEmail, listEmails (delega conexiones) |
Inyecta GoogleAuthService o OutlookAuthService cuando solo necesitas auth. GoogleService y OutlookService heredan los métodos de auth y añaden los de correo.
API Reference — Métodos y Scopes
GoogleModule
forRoot y forRootAsync aceptan global?: boolean (false por defecto). Exporta GoogleAuthService y GoogleService.
GoogleService / GoogleAuthService
GoogleService extiende GoogleAuthService. Todos los métodos reciben connectionKey como primer argumento (excepto getAuthUrl).
| Método | Descripción | Scope requerido |
|--------|-------------|-----------------|
| getAuthUrl() | URL de autorización OAuth | Scopes configurados |
| exchangeCodeForTokens(code, connectionKey) | Intercambiar code por tokens | Scopes configurados |
| refreshAccessToken(refreshToken, connectionKey) | Refrescar access token | Scopes configurados |
| getConnectionInfo(connectionKey) | Info de cuenta + permisos otorgados | email, profile, openid (automático) |
| sendEmail(connectionKey, options) | Enviar correo | https://www.googleapis.com/auth/gmail.send |
| listEmails(connectionKey, options) | Listar correos | https://www.googleapis.com/auth/gmail.readonly |
| getEmail(connectionKey, messageId) | Obtener correo por ID | https://www.googleapis.com/auth/gmail.readonly |
| setupPushNotifications(connectionKey, topic) | Configurar push (Pub/Sub) | https://www.googleapis.com/auth/gmail.modify |
| stopPushNotifications(connectionKey) | Detener push | https://www.googleapis.com/auth/gmail.modify |
| handleWebhook(connectionKey, body) | Maneja webhook: parsea, obtiene cambios e invoca onNewEmail/onReply | gmail.readonly |
| parsePubSubPayload(body) | Parsear payload Pub/Sub | — (sin API) |
| fetchChangesSinceHistory(connectionKey, options) | Obtener cambios tras push | https://www.googleapis.com/auth/gmail.readonly |
Scopes Google más usados:
gmail.send— enviargmail.readonly— leergmail.modify— leer + modificar + push notifications
OutlookModule
forRoot y forRootAsync aceptan global?: boolean (false por defecto). Exporta OutlookAuthService y OutlookService.
OutlookService / OutlookAuthService
OutlookService extiende OutlookAuthService. Todos los métodos reciben connectionKey como primer argumento (excepto getAuthUrl).
| Método | Descripción | Scope requerido |
|--------|-------------|-----------------|
| getAuthUrl() | URL de autorización OAuth | Scopes configurados |
| exchangeCodeForTokens(code, connectionKey) | Intercambiar code por tokens | Scopes configurados |
| refreshAccessToken(refreshToken, connectionKey) | Refrescar tokens | Scopes configurados |
| getConnectionInfo(connectionKey) | Info de cuenta + permisos | User.Read (automático) |
| sendEmail(connectionKey, options) | Enviar correo | https://graph.microsoft.com/Mail.Send |
| listEmails(connectionKey, options) | Listar correos | https://graph.microsoft.com/Mail.Read |
| getEmail(connectionKey, messageId) | Obtener correo por ID | https://graph.microsoft.com/Mail.Read |
| setupPushNotifications(connectionKey, url) | Configurar webhooks | https://graph.microsoft.com/Mail.Read |
| renewPushSubscription(connectionKey, id) | Renovar suscripción | https://graph.microsoft.com/Mail.Read |
| deletePushSubscription(connectionKey, id) | Eliminar suscripción | https://graph.microsoft.com/Mail.Read |
| handleWebhook(connectionKey, body) | Maneja webhook: parsea, obtiene mensajes e invoca onNewEmail/onReply | Mail.Read |
| parsePushNotification(body) | Parsear notificación | — (sin API) |
| fetchFullMessagesForEvents(connectionKey, events) | Mensajes completos para eventos | https://graph.microsoft.com/Mail.Read |
| isValidationRequest(query) | Detectar validación webhook | — (estático) |
| getValidationResponse(query) | Respuesta para validación | — (estático) |
Scopes Outlook más usados:
Mail.Send— enviarMail.Read— leer + push notifications
ImapSmtpModule
| Método | Descripción |
|--------|-------------|
| forRoot(options?) | Registra el módulo. Exporta ImapSmtpConnectionService e ImapSmtpService. |
ImapSmtpConnectionService
Gestiona conexiones: registerConnection, getStoredConnection, removeConnection, getConnectionInfo.
ImapSmtpService
Operaciones de correo. La config se pasa en cada llamada o se registra con registerConnection (delega a ImapSmtpConnectionService).
| Método | Descripción |
|--------|-------------|
| registerConnection(connectionKey, config) | Almacena conexión (aparece en listConnections) |
| getStoredConnection(connectionKey) | Obtiene config almacenada |
| removeConnection(connectionKey) | Elimina conexión almacenada |
| sendEmail(connection, options) | Enviar correo (requiere smtp en config) |
| listEmails(connection, options) | Listar correos (requiere imap en config) |
| getEmail(connection, messageIdOrUid) | Obtener correo |
| startIdleWatcher(connection, onNewMail) | Escuchar nuevos correos (IDLE) |
Config: imap y/o smtp (al menos uno). grantedScopes en listConnections: ['imap'], ['smtp'] o ['imap','smtp'].
ConnectionRegistry
| Método | Descripción |
|--------|-------------|
| listConnections(connectionKey?) | Con key: consulta APIs, actualiza. Sin key: todas desde cache |
| getCachedConnections(connectionKey?) | Con key: cache del usuario. Sin key: todas (ConnectionGroup[]) |
La persistencia es sustituible: por defecto InMemoryConnectionPersistence. Provee CONNECTION_PERSISTENCE con tu implementación de ConnectionPersistence (DB, Redis, etc.).
Exports del paquete
| Export | Contenido |
|--------|-----------|
| ./ (root) | Interfaces, ConnectionRegistry, ConnectionModule, InMemoryTokenCache, CONNECTION_PERSISTENCE, IMAP_SMTP_CONNECTION_STORE |
| ./google | GoogleModule, GoogleService, helpers OAuth |
| ./outlook | OutlookModule, OutlookService, helpers OAuth |
| ./imap-smtp | ImapSmtpModule, ImapSmtpService |
ConnectionRegistry se obtiene al importar GoogleModule u OutlookModule (incluyen ConnectionModule).
Licencia
MIT © Pavas. Ver LICENSE.
