Importar y exportar correo
MBOX, EML, ZIP y PST desde Gmail, Titan, Outlook o Thunderbird; buzones faltantes, cifrado y salvaguardas.
En esta página
La consola de administración (Importar / Exportar) y Ajustes > Mi buzón mueven correo entre BloomX y otros proveedores (Gmail, Titan, Outlook/Hotmail, Thunderbird, Apple Mail) con formatos estándar. Es la función que más contenido ajeno mueve de toda la aplicación, así que está muy acotada: ver Salvaguardas.
Formatos#
| Formato | Importar | Exportar | Notas |
|---|---|---|---|
| MBOX (mboxrd) | Sí | Sí | Google Takeout (X-Gmail-Labels), Thunderbird (X-Mozilla-Status, X-Mozilla-Keys), Apple Mail, Titan. Una línea From solo separa mensajes si va tras una línea en blanco y antes de una cabecera; el resto se desescapa (>From). Al exportar: un .mbox por buzón y carpeta, en partes de hasta 32 MB. |
| EML suelto / ZIP de EML | Sí | Sí | Estructura buzón@dominio/Carpeta/… o un buzón único elegido. La carpeta de usuario pasa a archivo + etiqueta. |
ZIP con .mbox, .eml o Maildir | Sí | Sí (ZIP) | Maildir: cur/new/tmp y banderas del nombre (:2,RS); Maildir++ (.Sent). También archivos sin extensión (Thunderbird) por contenido. |
| GZ / TGZ / TAR | Sí | No | .mbox.gz, Takeout .tgz. Se descomprime por trozos con tope. |
| PST (Outlook/Hotmail) | Sí, hasta MAIL_TRANSFER_PST_MAX_MB (2048) | No | Con pst-extractor (JS puro, MIT), leído por rangos (caché LRU de trozos de 4 MiB; no se copia a /tmp). Carpetas, mensajes, adjuntos y mensajes incrustados (como .eml, 3 niveles); se convierten a mbox por lotes. Probado con 3 PST/OST reales y un PST generado por código (pst-testkit, sin CRC: válido para pst-extractor, no garantizado para Outlook); no con un PST real de varios GB. |
.emlx (Apple Mail) | Sí (suelto o en ZIP) | No | Longitud + mensaje RFC 822 + plist de banderas (leído, marcado, borrador...). El DOCTYPE estándar del plist se elimina; cualquier otro DOCTYPE/ENTITY se rechaza. |
.msg (Outlook) | Sí (suelto o en ZIP) | No | Con @kenjiuno/msgreader (Apache-2.0). Cabeceras de transporte (lista blanca), destinatarios, cuerpo texto/HTML/RTF, adjuntos y .msg incrustados (como .eml, 3 niveles). |
.olm (Outlook para Mac) | Sí | No | ZIP con un XML por mensaje (com.microsoft.__Messages/…), leído con un parser XML propio sin DTD/entidades. Validado con `.olm` sintéticos según la estructura documentada; no hay un `.olm` real en las pruebas. |
| ZIP con contraseña | Sí (ZipCrypto y WinZip AES-128/192/256) | No | El asistente pide la contraseña; se comprueba y se guarda cifrada con la clave de la instancia solo hasta expandir las entradas cifradas, y se borra. Verificado contra 7-Zip e Info-ZIP. No se instaló @zip.js/zip.js (8,5 MB, duplica fflate y saltaría los límites anti zip-bomb). |
| Borradores | Sí | Sí | Export: Drafts.mbox (o Drafts/*.eml) con X-Bloomx-Draft: 1. Import: la marca, la etiqueta Drafts de Gmail, una carpeta Borradores o la bandera D de Maildir crean filas en Draft del usuario (no correo). |
Contactos .vcf | Sí | Sí (contacts.vcf, vCard 4.0) | Dedupe por correo sin sobrescribir. El modelo solo guarda correo, nombre y notas: teléfonos, organización, direcciones… van a notes en líneas etiquetadas. |
Calendario .ics | Sí | Sí (calendar.ics) | Asistentes, zonas horarias (IANA, tabla de Windows, VTIMEZONE), excepciones. El modelo no tiene recurrencia: la RRULE se conserva como texto en description y se expanden las ocurrencias (hasta 500) como eventos. |
Filtros de Gmail mailFilters.xml | Sí | Sí (mailFilters.xml) | A reglas de src/lib/rules. Sin negación en el motor: doesNotHaveTheWord, tamaño, importancia, categorías, forwardTo y shouldNeverSpam van al informe como *filtro no convertible* (nunca se crea un reenvío automático). |
manifest.json | Se ignora | Sí | Buzones, carpetas, etiquetas, conteos, fechas, origen del MIME (mimeSource) y sha256 de cada archivo. |
El formato se detecta por contenido (firmas PK, gzip, !BDN, OLE, ustar, línea de longitud de .emlx, BEGIN:VCARD / BEGIN:VCALENDAR, línea From + cabecera, cabeceras RFC 822), no por la extensión.
No se admite
PST cifrados o dañados (la librería no los lee), ZIP con cifrado fuerte por certificado, y ZIP cuyas entradas usan contraseñas distintas (el trabajo falla con zip_wrong_password). El paquete cifrado de BloomX (BLMXENC1) debe descifrarse antes de reimportarlo (scripts/bloomx-decrypt.mjs).
Fase de extracción (ZIP y GZ grandes)#
- Las entradas deflate de más de 16 MiB, las entradas cifradas y los
.gz/.tgzse expanden antes de importar a trozos de 4 MiB (x/<entrada>/NNNNNN) con inflate en trozos de 16 KiB: nunca se carga una entrada entera en memoria. La importación lee luego esos trozos con un cursor por offset. - Reanudable: al agotarse el presupuesto del tick (
MAIL_TRANSFER_BUDGET_MS) se guarda la cola y el estado del escritor en el cursor. Deflate no se puede retomar a mitad de flujo, así que el siguiente tick re-infla el prefijo ya escrito sin escribirlo (solo CPU, cientos de MB/s) y sigue desde ahí; cada byte se escribe una sola vez y el tiempo de re-inflado no cuenta contra el presupuesto de escritura. Si el proceso muere entre escribir y guardar el cursor, se reanuda del último checkpoint y los trozos se reescriben de forma determinista. - Coste documentado: reanudar una entrada de N bytes cuesta re-inflar N bytes (CPU). Con el tope por entrada de 1 GiB son unos segundos; un
.gzúnico de decenas de GB repite hasta ese prefijo en cada tick (por eso avanza al menos un trozo de 4 MiB por tick y el latido de bloqueo se mantiene mientras se re-infla).
Carpetas, etiquetas y banderas#
- Prioridad:
X-Bloomx-Folder(exportaciones propias) >X-Gmail-Labels> banderas (X-Mozilla-Statusborrado,junk) > carpeta de la ruta > Recibidos. - Gmail:
Inbox/Recibidos,Sent/Enviados,Trash/Papelera,Spampasan a carpetas propias;Starred= destacado;Unread= no leído; las demás etiquetas se crean como Label del usuario si no existen. - Leído/destacado:
Status/X-Status(RO,F),X-Mozilla-Status,X-Bloomx-Flags. Sin datos: se importa como leído. - La fecha del correo es la original (cabecera
Date, o la líneaFromsi falta); un futuro absurdo se acota a "ahora". Si la columnaEmail.previousFolderexiste, papelera/spam la rellenan coninbox. - Se guardan con el mismo esquema que la ingesta normal (
emails/<fecha>/<uuid>/…), así que la retención y el borrado completo funcionan igual. - No se ejecutan reglas ni hooks
EMAIL_RECEIVED, no hay notificaciones push y no se envía ningún correo.
Buzones que no existen#
En la vista previa se listan las direcciones detectadas (X-Bloomx-Mailbox, ruta del ZIP, From en enviados, Delivered-To / X-Original-To, To/Cc) con su estado: existe, no existe o dominio ajeno. Si faltan, un diálogo accesible pregunta «¿Desea crear los correos faltantes?»: cada dirección se crea (solo con dominio de la instancia: TOP_DOMAIN, host de NEXT_PUBLIC_APP_URL o MAIL_TRANSFER_ALLOWED_DOMAINS), se mapea a un buzón existente o se descarta. Contraseña aleatoria única por usuario (recomendada) o genérica (política de 12+ caracteres, con medidor) y «obligar a cambiar la contraseña en el primer inicio» (mustChangePassword, activada por defecto). Nada se crea hasta confirmar el resumen escribiendo el dominio. Se usa la misma función que el alta de usuarios de la consola (createUserAccount, hash bcrypt). Las credenciales generadas se devuelven una sola vez al navegador, que las descarga como CSV; nunca se guardan en claro ni se registran.
Diseño de los trabajos#
- Subida por trozos reanudable: 4 MiB por petición (cabe en el límite de 4,5 MB de Vercel),
PUT /jobs/:id/chunks/:nconx-chunk-sha256verificado en el servidor;GET …/chunksdice qué trozos hay para reanudar;POST …/completecomprueba que están todos y guarda un digest del conjunto. Nunca se ensambla el archivo: se lee por rangos. - Segundo plano por lotes (patrón del worker de Elixir): estados
created → uploading → analyzing → ready → queued → running → done|failed|canceled|expired. Cada tick bloquea el trabajo (lockedUntil), avanza ~45 s y guarda un cursor (entrada + desplazamiento del mensaje). Se encadena conafter()+POST /api/cron/mail-transfer(BearerCRON_SECRETo clave interna), el cron diario devercel.jsony el empuje desde el navegador (POST /jobs/:id/tick) si el sondeo detecta que se atascó. - Idempotente: cada mensaje tiene clave de origen (
entrada:offset) y elmessageIdguardado es el esquema por usuario del webhook (<Message-ID>-<userId>, único). Reintentar, reanudar tras una caída o reimportar no duplica, ni siquiera con dos trabajos a la vez. Los contadores salen de contarMailTransferItem. - Exportación: por buzón y carpeta, con paginación por clave (fecha, id), escribiendo el ZIP por trozos (entradas completas con CRC conocido, directorio central en objetos aparte, ZIP64 si hace falta). Las colas de los escritores están versionadas, así que una caída entre escribir y guardar el cursor se reanuda sin corromper el paquete.
- Descarga: enlace firmado (HMAC) ligado al trabajo y al administrador, 10 min de vida, más la sesión del mismo actor; «descargar una vez» reclama el archivo de forma atómica y una descarga interrumpida no lo consume. Caduca a las 24 h (
MAIL_TRANSFER_EXPORT_TTL_HOURS) y la retención lo borra del storage. - Informe CSV descargable: importados, duplicados omitidos y errores con motivo (sin contenido).
Cifrado del paquete#
Opcional: contenedor BLMXENC1 = cabecera de 37 bytes (magic, versión, KDF scrypt N=2^15 r=8 p=1, sal de 16 B, prefijo de nonce, tamaño de registro) + registros AES-256-GCM de 1 MiB (nonce = prefijo ‖ contador, AAD = cabecera ‖ byte final) + un registro final vacío obligatorio (detecta truncado). La contraseña no se guarda; mientras dura el trabajo el servidor conserva solo la clave derivada cifrada con la clave de datos y la borra al terminar. Para abrirlo:
node scripts/bloomx-decrypt.mjs paquete.zip.bmx paquete.zip
# o BLOOMX_PACKAGE_PASSWORD='…' node scripts/bloomx-decrypt.mjs paquete.zip.bmx paquete.zipSalvaguardas#
- Solo
requireAdmin(dueño del dominio oADMIN_EMAILScon MFA). «Mi buzón» exige sesión y solo toca el buzón del propio usuario (ni crea usuarios ni ve trabajos ajenos). - Re-autenticación reciente (10 min: contraseña o código MFA; vale también una sesión con MFA verificado hace menos de 10 min) para crear importaciones, confirmar, exportar y generar enlaces de descarga. Prueba en cookie httpOnly
SameSite=Strictligada al administrador. Un administrador sin MFA puede reingresar la contraseña; el diálogo le recomienda activar MFA. - Confirmación escribiendo el nombre del dominio,
rateLimitAsyncpor endpoint, tope de trabajos concurrentes por dominio (MAIL_TRANSFER_MAX_CONCURRENT, 2) y propiedad estricta: un trabajo solo lo ve quien lo creó y en su dominio (404 en otro caso). - Auditoría (
admin.mail_transfer.*): administrador, alcance, nº de buzones y mensajes, bytes, sha256 e IP. Nunca contenido, asuntos, contraseñas ni claves. - Aviso opcional por correo a los usuarios afectados.
- Adjuntos validados con
file-type(magic bytes) y el antivirus opcional (av-hook) como en la ingesta normal; ejecutables y HTML activo se bloquean o sanean. Cabeceras saneadas (sin CR/LF ni control) al leer y al generar. - ZIP: nombres normalizados (sin
.., rutas absolutas, unidad, NUL), enlaces simbólicos y entradas cifradas omitidos, tope de entradas, tamaño por entrada y total, razón de compresión, y se cuentan los bytes realmente inflados (una cabecera que miente aborta). Se infla en trozos de 16 KiB. No se escribe nada en el sistema de ficheros. - Memoria acotada: mbox por streaming (mensajes de más de
MAIL_TRANSFER_MAX_MESSAGE_MB= 50 se descartan sin acumular), PST mensaje a mensaje. Los archivos subidos se borran al terminar el trabajo (o a las 48 h si se abandona). - Las rutas del storage no se exponen: solo ids de trabajo y enlaces firmados.
Variables#
| Variable | Por defecto | Efecto |
|---|---|---|
MAIL_TRANSFER_ALLOWED_DOMAINS | (vacía) | Dominios extra donde se pueden crear buzones. Sin TOP_DOMAIN, se usan los dominios de ADMIN_EMAILS. |
MAIL_TRANSFER_MAX_UPLOAD_MB | 16384 | Tamaño máximo del archivo subido |
MAIL_TRANSFER_MAX_MESSAGE_MB | 50 | Mensaje mayor: error too_large |
MAIL_TRANSFER_MAX_ATTACHMENTS | 100 | Adjuntos por mensaje |
MAIL_TRANSFER_MAX_CONCURRENT | 2 | Trabajos activos por dominio |
MAIL_TRANSFER_BUDGET_MS | 45000 | Duración de cada tick |
MAIL_TRANSFER_PST_MAX_MB | 2048 | Tope de PST (lectura por rangos, sin copiar a /tmp; medido hasta 640 MiB) |
MAIL_TRANSFER_EXPORT_TTL_HOURS | 24 | Caducidad del paquete |
RAW_MIME_MAX_MB | 25 | Tamaño máximo del MIME original guardado como raw.eml |
MAIL_STORE_RAW_MIME | (activo) | false desactiva el guardado de raw.eml |
MAIL_TRANSFER_MAX_CONTACTS / _MAX_EVENTS | 20000 / 50000 | Contactos y eventos importados por buzón |
MAIL_TRANSFER_MAX_EXPANDED_GB | 40 | Tope de descompresión (zip-bomb) |
Tablas#
Aditivas, creadas por db:ensure (también en prisma/schema.prisma). Sin ellas las rutas devuelven 503 mail_transfer_tables_missing.
CREATE TABLE IF NOT EXISTS "MailTransferJob" (
"id" TEXT NOT NULL, "userId" TEXT NOT NULL, "actorKind" TEXT NOT NULL DEFAULT 'user', "domain" TEXT NOT NULL DEFAULT '',
"kind" TEXT NOT NULL, "scope" TEXT NOT NULL DEFAULT 'domain', "targetUserId" TEXT, "format" TEXT NOT NULL DEFAULT 'unknown',
"status" TEXT NOT NULL DEFAULT 'created', "phase" TEXT NOT NULL DEFAULT '', "fileName" TEXT,
"options" JSONB NOT NULL DEFAULT '{}', "summary" JSONB NOT NULL DEFAULT '{}', "cursor" JSONB NOT NULL DEFAULT '{}',
"totalBytes" BIGINT NOT NULL DEFAULT 0, "uploadedBytes" BIGINT NOT NULL DEFAULT 0, "totalItems" INTEGER NOT NULL DEFAULT 0,
"doneItems" INTEGER NOT NULL DEFAULT 0, "importedItems" INTEGER NOT NULL DEFAULT 0, "duplicateItems" INTEGER NOT NULL DEFAULT 0,
"skippedItems" INTEGER NOT NULL DEFAULT 0, "errorItems" INTEGER NOT NULL DEFAULT 0, "bytesProcessed" BIGINT NOT NULL DEFAULT 0,
"outputBytes" BIGINT NOT NULL DEFAULT 0, "outputSha256" TEXT, "lockedUntil" TIMESTAMPTZ, "lastError" TEXT,
"cancelRequested" BOOLEAN NOT NULL DEFAULT FALSE, "downloadedAt" TIMESTAMPTZ, "expiresAt" TIMESTAMPTZ,
"startedAt" TIMESTAMPTZ, "finishedAt" TIMESTAMPTZ, "createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "MailTransferJob_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "MailTransferItem" (
"id" BIGSERIAL NOT NULL, "jobId" TEXT NOT NULL, "mailbox" TEXT NOT NULL DEFAULT '', "sourceKey" TEXT NOT NULL,
"messageId" TEXT, "status" TEXT NOT NULL DEFAULT 'pending', "error" TEXT, "emailId" TEXT, "folder" TEXT,
"bytes" BIGINT NOT NULL DEFAULT 0, "createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "MailTransferItem_pkey" PRIMARY KEY ("id"),
CONSTRAINT "MailTransferItem_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "MailTransferJob"("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "MailTransferItem_jobId_sourceKey_key" ON "MailTransferItem" ("jobId","sourceKey");
CREATE INDEX IF NOT EXISTS "MailTransferItem_jobId_status_idx" ON "MailTransferItem" ("jobId","status");
CREATE INDEX IF NOT EXISTS "MailTransferJob_userId_createdAt_idx" ON "MailTransferJob" ("userId","createdAt" DESC);
CREATE INDEX IF NOT EXISTS "MailTransferJob_domain_status_idx" ON "MailTransferJob" ("domain","status");
CREATE INDEX IF NOT EXISTS "MailTransferJob_status_updatedAt_idx" ON "MailTransferJob" ("status","updatedAt");MIME original al exportar#
process-attachmentsya descarga el MIME crudo de Resend: ahora lo guarda también comoraw.emljunto araw.jsonsiMAIL_STORE_RAW_MIMEno esfalsey pesa como máximoRAW_MIME_MAX_MB(25).Email.rawKeysigue apuntando a `raw.json` (otras rutas lo parsean como JSON); la clave deraw.emlse deriva de ella (src/lib/raw-mime.ts).- La exportación prefiere
raw.eml(cabeceras exactas:Received,Authentication-Results,DKIM-Signature…) y le antepone las cabeceras de carpeta, etiquetas y banderas. Para correos anteriores intentaEmail.rawMimeUrl(solo https público, IP privada bloqueada, timeout 4 s, tope de tamaño; sin bloquear) y cae a la reconstrucción. Con «sin adjuntos» siempre se reconstruye. Elmanifest.jsoncuenta cuántos salieron de cada origen. - Retención: el borrado completo y la política
rawincluyenraw.eml(y no lo borran si otro destinatario del mismo mensaje aún lo usa). Coste: hasta 25 MB extra por mensaje; desactívalo conMAIL_STORE_RAW_MIME=false.
Límites conocidos#
- Verificado con Postgres embebido, almacenamiento local y un servidor compatible S3 local (
scripts/fake-s3.mjs: multipart, Range, ListObjectsV2 paginado, DeleteObjects por lotes de 1000, firma SigV4). No contra S3/B2 reales: la aceptación de checksums, firmas y límites del proveedor solo se confirma con él (se usaWHEN_REQUIREDpara los checksums del SDK). - Rendimiento medido con
scripts/bench-mail-transfer.mjs(ver la cabecera del script); la importación está limitada por Postgres y por escribir 3–4 objetos por mensaje, no por memoria. .olm: no hay un archivo real en las pruebas (ver tabla).