Descripción
Handler de Cloudflare Workers que implementa el chat conversacional F1 del Menú Correo. Usa Gemma 4 (Google AI Studio) con decodificación restringida para elegir acciones (responder, buscar en Zoho Mail, leer cuerpos de mensaje). El chat es efímero: vive solo en la memoria del navegador, sin persistencia en D1 salvo telemetría anónima.
Ubicación: functions/api/correo/ask.ts (330 LOC).
Firma y entrada
export const onRequestPost: PagesFunction<CorreoEnv> = async ({ request, env }) => { ... }
Parámetros (body JSON)
| Campo | Tipo | Requerido | Límite | Descripción |
|---|---|---|---|---|
question | string | ✓ | 2000 chars | Pregunta del usuario sobre su correo/digest |
history | Array | - | 12 msgs | Histórico de conversación (role: ‘user’ | ‘assistant’, content) |
Respuesta exitosa (200)
{
"answer": "Según el informe, el último aviso de Hetzner fue...",
"tool_rounds": 2,
"duration_ms": 1234,
"model": "gemma-4-26b-a4b-it"
}
Respuestas de error
| Código | Razón |
|---|---|
| 403 | Usuario no autenticado (CF Access requerido) |
| 400 | Body JSON inválido o question ausente |
| 413 | Pregunta > 2000 chars |
| 429 | Rate-limit: >20 queries/minuto |
| 503 | GOOGLE_AI_API_KEY no configurada |
| 500 | Error interno del handler o AI Studio |
Lógica de ejecución
1. Autenticación y validación
- Verifica CF Access (miembro del staff).
- Valida
question(presente, no vacía, max 2000 chars). - Valida
history(filtrado a máx 12 mensajes, sin contenido vacío, roles user/assistant). - Comprueba rate-limit en
correo_chat_queries(últimos 60s, max 20 queries).
2. Construcción del contexto
- Obtiene el último digest del usuario desde
correo_digests(tabla D1):generated_at: timestamp de generación.resumen: resumen del informe (ej. “3 correos urgentes, 12 normales”).ruido_count: mensajes filtrados.payload: JSON con los ítems del digest.
- Si no existe digest aún: contextualiza con “(sin informe todavía)”.
3. Llamada a Gemma 4 (decodificación restringida)
Modelo: gemma-4-26b-a4b-it vía Google AI Studio.
Schema JSON forzado:
{
"type": "OBJECT",
"properties": {
"action": { "type": "STRING", "enum": ["responder", "buscar", "leer"] },
"answer": { "type": "STRING" },
"query": { "type": "STRING" },
"from": { "type": "STRING" },
"subject": { "type": "STRING" },
"messageId": { "type": "STRING" },
"folderId": { "type": "STRING" }
},
"required": ["action"]
}
Prompt (pseudocódigo):
Eres la secretaria del buzón de [usuario]. Hablas llano y al grano.
Tienes 3 acciones:
- {"action":"responder","answer":"..."} — cuando puedas contestar.
- {"action":"buscar","query|from|subject":"..."} — buscar en el buzón real.
- {"action":"leer","messageId":"...","folderId":"..."} — leer el cuerpo de un mensaje.
Contexto (último digest):
[resumen + ítems + contexto histórico]
Pregunta del usuario: [question]
Responde SIEMPRE con JSON válido. Sin scratchpad.
Timeout: 40s por intento. Si falla por timeout, reintentos automáticos (máx 2 intentos, footgun s88: cold-start en AI Studio puede exceder el primer timeout).
4. Decodificación de la respuesta
Si action == "responder": devuelve answer al cliente y termina.
Si action == "buscar" (máx 3 rondas totales):
- Ejecuta
toolBuscar(env, member, args). - Busca en Zoho Mail por sender, subject o texto libre.
- Registra el resultado en
toolLog. - Re-pregunta a Gemma con los resultados incluidos (loop).
Si action == "leer" (máx 3 rondas totales):
- Ejecuta
toolLeer(env, member, args). - Obtiene el contenido HTML del mensaje desde Zoho.
- Convierte HTML → texto plano (acotado a 4000 chars).
- Registra en
toolLog. - Re-pregunta a Gemma con el cuerpo incluido (loop).
Si se alcanzan 3 rondas: devuelve la respuesta más reciente, aunque esté incompleta.
5. Telemetría (best-effort)
Inserta en correo_chat_queries:
owner: usuario.question: primeros 500 chars de la pregunta.tool_rounds: número de rondas ejecutadas.duration_ms: tiempo total de procesamiento.created_at: timestamp actual.
Si falla la inserción, se continúa sin error (best-effort).
Componentes internos
htmlToText(html: string): string
Convierte HTML a texto plano legible:
- Elimina
<style>,<script>. - Reemplaza saltos de párrafo (
</p>,</div>) con\n. - Desescapa entidades HTML (
&→&,"→"). - Compacta espacios múltiples y líneas en blanco.
- Acota a máx 4000 chars.
resolveAccountId(env, member, host): Promise<string>
Obtiene el accountId de Zoho Mail del miembro:
- Llamada GET a
https://{host}/api/accountscon OAuth del miembro. - Devuelve el primer
accountId(usuario tiene una sola cuenta en Zoho Mail).
toolBuscar(env, member, args): Promise<string>
Búsqueda en el buzón Zoho:
- Parámetros:
query(texto libre),from(remitente),subject(asunto). - Endpoint:
GET /api/accounts/{accountId}/messages/search?searchKey=.... - Devuelve JSON con hasta 10 resultados:
messageId,folderId,de,asunto,fecha,resumen(150 chars).
toolLeer(env, member, args): Promise<string>
Lectura de un mensaje concreto:
- Parámetros requeridos:
messageId,folderId(del resultado de búsqueda previa). - Endpoint:
GET /api/accounts/{accountId}/folders/{folderId}/messages/{messageId}/content. - Devuelve el cuerpo HTML, convertido a texto (máx 4000 chars).
callGemma(apiKey: string, prompt: string): Promise<ModelAction>
Ejecuta el modelo Gemma 4:
- Usa endpoint
https://generativelanguage.googleapis.com/v1beta/models/{MODEL}:generateContent?key={apiKey}. - POST con
contents,generationConfig(temperature 1.0, topK 64, max_tokens 1024, schema JSON). - Parse JSON de la respuesta; si falla (JSON roto pese al decoding), trata el texto como respuesta.
Guardarraíles
| Límite | Valor | Razón |
|---|---|---|
| MAX_QUESTION_LEN | 2000 | Evitar prompts enormes |
| MAX_HISTORY_MESSAGES | 12 | Contexto acotado sin explotar tokens |
| MAX_TOOL_ROUNDS | 3 | Máx búsquedas/lecturas por pregunta |
| MAX_BODY_CHARS | 4000 | Limitar tamaño de cuerpos leídos |
| RATE_LIMIT_PER_MIN | 20 | Prevenir abuse vía telemetría |
| TIMEOUT_MS | 40000 | Detectar stalls, reintentar |
Flujo de ejemplo
- Usuario: “¿Qué decía el último aviso de Hetzner?”
- Gemma (round 1):
{"action":"buscar","from":"alerts@hetzner.de"} - Worker ejecuta
toolBuscar, obtiene 3 mensajes de Hetzner, actualizatoolLog. - Gemma (round 2):
{"action":"leer","messageId":"msg_123","folderId":"inbox"} - Worker ejecuta
toolLeer, obtiene cuerpo (ej. “Your server was rebooted…”). - Gemma (round 3):
{"action":"responder","answer":"El último aviso de Hetzner decía que tu servidor se reinició por mantenimiento."} - Devuelve al cliente:
{ answer: "...", tool_rounds: 2, duration_ms: 1200 }.
Dependencias externas
- Google AI Studio: Gemma 4 (Google AI API Key en
env.GOOGLE_AI_API_KEY). - Zoho Mail API: búsqueda y lectura per-user (OAuth del miembro en CF Cookies).
- D1 (Cloudflare): lectura de
correo_digests, escritura decorreo_chat_queries. - CF Access: autenticación de usuarios del staff.
Datos sensibles
- Cuerpos de mensaje: se obtienen de Zoho, se envían al navegador, NUNCA se guardan en D1.
- Tokens OAuth: viven en sesión CF Access / cookies del navegador, nunca en logs.
- Preguntas: solo los primeros 500 chars se guardan en telemetría, anonimizados por owner.
Véase también
- [[feature—workspace—correo-menu-f0-f1-f2]]
- [[entity—workspace—endpoint—correo-peticiones]]
- [[feature—autoplan—provider-google-genai]]
- [[runbook—zoho—reautorizacion-oauth-scopes]]