Ctrl K
ComunidadGuías…

lib/linkedin-mail-drop.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync } from 'node:fs';
import { basename, dirname, extname, join } from 'node:path';
import { LINKEDIN_IMPORT_MAX_CHARS, linkedInOffers, planLinkedInImport } from './linkedin-import.mjs';
import { resolveTrackerPath } from '../path-resolver.mjs';
import { withPipelineLock } from '../pipeline-lock.mjs';

const MAIL_EXT = new Set(['.eml', '.txt', '.mbox']);

/**
 * @param {string} dir
 * @returns {string[]}
 */
export function mailDropFiles(dir) {
  return readdirSync(dir, { withFileTypes: true })
    .filter((entry) => entry.isFile() && MAIL_EXT.has(extname(entry.name).toLowerCase()))
    .map((entry) => entry.name)
    .sort()
    .map((name) => join(dir, name));
}

// Only a missing file reads as empty. An unreadable tracker would hide the
// jobs already in it, and the import would add them a second time.
function readIf(path) {
  try {
    return readFileSync(path, 'utf8');
  } catch (err) {
    if (err?.code === 'ENOENT') return '';
    throw err;
  }
}

// Saved .eml and .mbox files keep each MIME part in its transfer encoding,
// and a job link inside a base64 part is invisible until decoded. Spaces and
// tabs inside a base64 line are ignored, the way MIME requires. Decoding stops
// once the text handed to the planner is over the character limit, so a huge
// part is not expanded on every poll.
function decodeBase64Parts(text, maxChars) {
  const limit = maxChars + 1;
  const lines = text.split(/\r?\n/);
  let out = '';
  let pending = false;
  const push = (chunk) => {
    if (out.length >= limit) return;
    const room = limit - out.length;
    out += chunk.length > room ? chunk.slice(0, room) : chunk;
  };
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i];
    if (/^content-transfer-encoding:\s*base64\s*$/i.test(line)) pending = true;
    push(i === 0 ? line : `\n${line}`);
    if (out.length >= limit) return out;
    if (!pending || line !== '') continue;
    pending = false;
    let end = i + 1;
    const body = [];
    let encoded = 0;
    while (end < lines.length) {
      const compact = lines[end].replace(/[ \t]/g, '');
      if (compact.length === 0 || !/^[A-Za-z0-9+/=]+$/.test(compact)) break;
      // Four-byte UTF-8 is the smallest decoded character count this part can
      // produce. Past the limit, stop without building the decoded text.
      if (out.length + Math.floor((encoded + compact.length) * 3 / 16) >= limit) {
        return out + 'x'.repeat(limit - out.length);
      }
      body.push(compact);
      encoded += compact.length;
      end += 1;
    }
    if (body.length > 0) push(`\n${Buffer.from(body.join(''), 'base64').toString('utf8')}`);
    i = end - 1;
    if (out.length >= limit) return out;
  }
  return out;
}

// JS string length counts UTF-16 code units. The planner keeps the decoded
// text, not the base64 lines. One CJK character is one unit and three UTF-8
// bytes, and base64 stores those three bytes as four ASCII characters. MIME
// wraps that at 76 characters and adds CRLF. The smallest header that starts
// a part is the encoding line and a blank line, and the decoder inserts one
// more newline before the decoded text. A file bigger than this cannot fit
// the character limit. Twice or three times the limit is too small: base64
// CJK text can be larger than that and still fit. Spaces and tabs inside a
// base64 line can push a smaller message past this size, and those files are
// skipped before the read.
const BASE64_HEADER = 'Content-Transfer-Encoding: base64\r\n\r\n';
const headerChars = 'Content-Transfer-Encoding: base64\n'.length + 1;
const base64Chars = (LINKEDIN_IMPORT_MAX_CHARS - headerChars) * 4;
export const MAIL_BYTES_OVER_LIMIT = Buffer.byteLength(BASE64_HEADER) + base64Chars + 2 * Math.ceil(base64Chars / 76);

export function mailReadFailure(err) {
  const code = err && err.code;
  if (code === 'ERR_FS_FILE_TOO_LARGE' || code === 'ERR_STRING_TOO_LONG') return 'skipped';
  return 'unreadable';
}

// One descriptor for the size check and the read. A path checked and then
// opened again can be replaced by the mail app in between, and the second
// open can be larger than the limit. Bytes past the checked size are not read.
function readBoundedMail(file) {
  const fd = openSync(file, 'r');
  try {
    const size = fstatSync(fd).size;
    if (size > MAIL_BYTES_OVER_LIMIT) return { kind: 'skipped' };
    const buf = Buffer.alloc(size);
    let offset = 0;
    while (offset < size) {
      const n = readSync(fd, buf, offset, size - offset, offset);
      if (n === 0) break;
      offset += n;
    }
    return { kind: 'text', text: buf.subarray(0, offset).toString('utf8') };
  } finally {
    closeSync(fd);
  }
}

function park(file) {
  const dir = join(dirname(file), 'imported');
  mkdirSync(dir, { recursive: true });
  const base = basename(file);
  let dest = join(dir, base);
  let n = 1;
  while (existsSync(dest)) {
    dest = join(dir, `${base}.${n}`);
    n += 1;
  }
  renameSync(file, dest);
}

/**
 * The pipeline and tracker resolve the way scan.mjs resolves them, so
 * CAREER_OPS_PIPELINE and CAREER_OPS_TRACKER point here too.
 *
 * The scheduled poll and linkedin-watch.mjs can run at once, so a real import
 * holds a lock on the folder from listing to parking. Without it both read the
 * same pipeline, add the same job, and the second park fails.
 *
 * @param {{ root: string, dir: string, dryRun?: boolean }} input
 * @returns {Promise<{ added: number, already: number, found: number, skipped: string[], unreadable: string[], message: string }>}
 */
export function importMailDrop(input) {
  if (input.dryRun) return importUnlocked(input);
  return withPipelineLock(join(input.dir, '.import'), () => importUnlocked(input));
}

async function importUnlocked({ root, dir, dryRun = false }) {
  const files = mailDropFiles(dir);
  const pipelinePath = process.env.CAREER_OPS_PIPELINE || join(root, 'data', 'pipeline.md');
  const historyPath = process.env.CAREER_OPS_SCAN_HISTORY || join(root, 'data', 'scan-history.tsv');
  const applicationsPath = resolveTrackerPath(root);
  let seen = `${readIf(pipelinePath)}\n${readIf(applicationsPath)}`;
  const added = [];
  const offers = [];
  const historyOffers = [];
  const already = [];
  let found = 0;
  const parked = [];
  const skipped = [];
  const unreadable = [];
  for (const file of files) {
    let text;
    try {
      const read = readBoundedMail(file);
      if (read.kind === 'skipped') {
        skipped.push(basename(file));
        continue;
      }
      text = read.text;
    } catch (err) {
      if (mailReadFailure(err) === 'skipped') skipped.push(basename(file));
      else unreadable.push(basename(file));
      continue;
    }
    const decoded = decodeBase64Parts(text, LINKEDIN_IMPORT_MAX_CHARS);
    const plan = planLinkedInImport(decoded, seen);
    found += plan.found;
    if (plan.error) {
      skipped.push(basename(file));
      continue;
    }
    for (const job of plan.added) {
      added.push(job);
      seen += `\n${job.url}`;
    }
    if (!dryRun && (plan.added.length > 0 || plan.already.length > 0)) {
      const parsed = linkedInOffers([...plan.added, ...plan.already], decoded);
      if (plan.added.length > 0) offers.push(...parsed.slice(0, plan.added.length));
      historyOffers.push(...parsed);
    }
    already.push(...plan.already);
    parked.push(file);
  }
  let historyError = null;
  if (!dryRun && (added.length > 0 || historyOffers.length > 0)) {
    const { appendToPipeline, appendToScanHistory } = await import('../scan.mjs');
    const { localToday } = await import('./local-today.mjs');
    if (added.length > 0) {
      mkdirSync(dirname(pipelinePath), { recursive: true });
      await appendToPipeline(offers, { pipelinePath, scanHistoryPath: historyPath });
    }
    if (historyOffers.length > 0) {
      try {
        await appendToScanHistory(historyOffers, localToday(), 'added', { historyPath });
      } catch (err) {
        historyError = err instanceof Error ? err.message : String(err);
      }
    }
  }
  if (!dryRun && !historyError) {
    for (const file of parked) park(file);
  }
  const jobs = `${added.length} LinkedIn job${added.length === 1 ? '' : 's'}`;
  let message = added.length === 0
    ? (found === 0 ? 'No LinkedIn jobs found in the alert folder.' : `${already.length} LinkedIn job${already.length === 1 ? ' was' : 's were'} already in your list.`)
    : dryRun ? `${jobs} found. Nothing was saved.` : `${jobs} added.`;
  if (skipped.length > 0) {
    message += ` Skipped ${skipped.length === 1 ? 'a file' : `${skipped.length} files`} too large to read: ${skipped.join(', ')}. Split it into smaller files or export single emails.`;
  }
  if (unreadable.length > 0) {
    message += ` Could not read ${unreadable.join(', ')}. Check the file permissions; it stays in the folder for the next run.`;
  }
  if (historyError) {
    message += ' The scan history could not be updated. The alert file stays in the folder until the next run.';
  }
  return { added: added.length, already: already.length, found, skipped, unreadable, message };
}