Descripción
Conjunto de endpoints que gestionan la cola de peticiones (encargos) que un miembro encola para que la secretaria de correo atienda. Son simples, CRUD sin lógica multiturno.
- Navegador crea peticiones (
POST, CF Access). - Navegador lista su cola (
GET, CF Access). - Pasada desatendida de
/correolas atiende (PUT, Bearer token). - Miembro descarta una suya (
DELETE, CF Access o Bearer).
Autenticación
Dos canales de identidad escopo estricto:
CF Access (Navegador)
resolveTeamMember(request) — extrae la identidad CF Access. Usa X-Forwarded-For o headers CF típicos.
Bearer Token (Pasada Desatendida)
memberFromBearer(request, env) — extrae el token del header Authorization: Bearer <token> y lo mapea a un miembro:
const auth = request.headers.get('Authorization');
if (!auth?.startsWith('Bearer ')) return null;
const token = auth.slice(7);
for (const pair of (env.MCP_TOKENS || '').split(',').filter(Boolean)) {
const [name, t] = pair.split(':');
if (t && t === token) {
const cap = name.trim().charAt(0).toUpperCase() + name.trim().slice(1).toLowerCase();
return isTeamMember(cap) ? cap : null;
}
}
Env var MCP_TOKENS = edu:token123,dani:token456,... (pares de nombre:token).
Resolución Unificada
function resolveMember(request: Request, env: Env): TeamMember | null {
return resolveTeamMember(request) ?? memberFromBearer(request, env);
}
Intenta CF Access primero, luego Bearer. Si ninguno, 403 Forbidden.
GET /api/correo/peticiones
Lista las peticiones del miembro autenticado.
Query params (opcionales):
estado: filtro ('pendiente'|'atendida'|'descartada'). Sin este parámetro, devuelve todas.limit: máx filas (default 20, capped a 50).
Ejemplo:
GET /api/correo/peticiones?estado=pendiente&limit=10
Respuesta (200):
[
{
"id": 1,
"owner": "Edu",
"texto": "Respóndele a Hetzner que confirmen la entrega",
"estado": "pendiente",
"respuesta": null,
"compose_url": null,
"created_at": "2026-08-12 14:30:00",
"atendida_at": null
},
{
"id": 2,
"owner": "Edu",
"texto": "Aviso a los clientes sobre la caída",
"estado": "atendida",
"respuesta": "Estimados, la infraestructura fue afectada...",
"compose_url": "https://mail.zoho.com/...#compose&to=...",
"created_at": "2026-08-12 13:15:00",
"atendida_at": "2026-08-12 14:28:00"
}
]
POST /api/correo/peticiones
Crea una petición nueva. Solo desde navegador (CF Access).
Body:
{
"texto": "Respóndele a X que Z" // ≤1000 chars, obligatorio
}
Validaciones:
textorequerido y no vacío.texto.length ≤ 1000.
Respuesta (201 Created):
{
"id": 3,
"owner": "Edu",
"texto": "Respóndele a X que Z",
"estado": "pendiente",
"respuesta": null,
"compose_url": null,
"created_at": "2026-08-12 14:35:00",
"atendida_at": null
}
Errores:
403 Forbidden: no autenticado.400 Bad Request: body JSON inválido otextovacío.413 Payload Too Large:texto> 1000 chars.
PUT /api/correo/peticiones/:id
Atiende una petición: marca como atendida, añade respuesta y deep-link a Zoho Compose.
Body (todos opcionales, parcial):
{
"estado": "pendiente|atendida|descartada", // estado nuevo
"respuesta": "el borrador que la secretaria generó",
"compose_url": "https://mail.zoho.com/..."
}
Validaciones:
estado, si presente, debe ser uno de['pendiente', 'atendida', 'descartada'].respuesta, si presente, ≤4000 chars.compose_url, si presente, debe comenzar conhttps://mail.zoho..
Lógica:
- Solo el propietario de la petición (verificar
existing.owner == member) puede actualizar. - Si
estado == 'atendida', estableceatendida_at = now(). - Usa
COALESCEpara parciales: solo los campos presentes se actualizan, el resto quedan intactos.
Respuesta (200):
{
"id": 2,
"owner": "Edu",
"texto": "Aviso a los clientes sobre la caída",
"estado": "atendida",
"respuesta": "Estimados, se produjo un corte...",
"compose_url": "https://mail.zoho.com/...",
"created_at": "2026-08-12 13:15:00",
"atendida_at": "2026-08-12 14:28:00"
}
Errores:
404 Not Found: petición no existe.403 Forbidden: petición es de otro miembro.400 Bad Request: campos inválidos (estado fuera de enum, respuesta muy larga, etc.).
DELETE /api/correo/peticiones/:id
Descarta una petición (elimina permanentemente).
Validación:
- Solo el propietario puede eliminar.
Respuesta (204 No Content).
Errores:
404 Not Found: petición no existe.403 Forbidden: petición es de otro miembro.
Flujo de Integración (F2)
Paso 1: Navegador Crea
const response = await fetch('/api/correo/peticiones', {
method: 'POST',
body: JSON.stringify({ texto: "Respóndele a Hetzner que confirmen..." })
});
→ Crea fila con estado='pendiente' en correo_peticiones.
Paso 2: Pasada Desatendida Atiende
La pasada de /correo (parte de Paso 7 de claude-method) corre periódicamente y:
- Autentica con
Bearer {miembro_token}(delMCP_TOKENS). - Llama
GET /api/correo/peticiones?estado=pendiente&limit=5→ obtiene hasta 5 peticiones pendientes. - Para cada petición, genera un borrador de correo (en claude-method, Paso 7).
- Produce un
compose_urlde Zoho Compose con el borrador pre-rellenado. - Llama
PUT /api/correo/peticiones/:idcon{"estado":"atendida", "respuesta":"...", "compose_url":"..."}.
Paso 3: Navegador ve el Resultado
El miembro abre /correo, ve la petición ahora con estado='atendida' + botón compose_url para ir a Zoho y enviar manualmente.
Tabla Underlying (correo_peticiones)
Ver [[entity—correo—table—peticiones]] para schema e índices.
Diseño Aplazado
- Envío directo: toggle F3 que permite que la pasada enviara el correo directamente en lugar de dejar solo el borrador.
- Hoy: 100% manual — miembro recibe el borrador y hace clic en el deep-link a Zoho para revisar y enviar.
Véase también
- [[feature—workspace—correo-menu-f0-f1-f2]]
- [[entity—correo—table—peticiones]]
- [[entity—correo—endpoint—ask]]
- [[concept—saas—multi-tenancy]]
- [[decision—20260403—multi-tenancy-rls]]