OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-social / bridge-email / lib / imap-client.js

224 lines · javascript ref:
import tls from 'tls'

class ImapClient {
  constructor (opts = {}) {
    this.host = opts.host
    this.port = opts.port || 993
    this.user = opts.user
    this.pass = opts.pass
    this._socket = null
    this._tag = 0
    this._buffer = ''
    this._handlers = new Map()
    this._connected = false
  }

  _nextTag () {
    return `A${++this._tag}`
  }

  connect () {
    return new Promise((resolve, reject) => {
      this._socket = tls.connect({
        host: this.host,
        port: this.port,
        rejectUnauthorized: true
      })

      this._socket.setEncoding('utf8')

      let greeting = false
      this._socket.on('data', (data) => {
        this._buffer += data

        if (!greeting && this._buffer.includes('\r\n')) {
          greeting = true
          this._buffer = ''
          this._connected = true
          resolve()
        }

        this._processBuffer()
      })

      this._socket.on('error', (err) => {
        if (!greeting) reject(err)
      })

      this._socket.setTimeout(15000, () => {
        if (!greeting) reject(new Error('IMAP connection timeout'))
      })
    })
  }

  _processBuffer () {
    const lines = this._buffer.split('\r\n')
    this._buffer = lines.pop() // keep incomplete

    for (const line of lines) {
      if (!line) continue

      // Check if it's a tagged response
      for (const [tag, handler] of this._handlers) {
        if (line.startsWith(tag + ' ')) {
          const rest = line.slice(tag.length + 1)
          handler.lines.push(line)
          if (rest.startsWith('OK')) {
            handler.resolve(handler.lines)
          } else {
            handler.reject(new Error(`IMAP error: ${line}`))
          }
          this._handlers.delete(tag)
          break
        }
      }

      // Untagged response — add to current handler
      if (line.startsWith('* ')) {
        for (const handler of this._handlers.values()) {
          handler.lines.push(line)
        }
      }
    }
  }

  _command (cmd) {
    return new Promise((resolve, reject) => {
      const tag = this._nextTag()
      this._handlers.set(tag, { resolve, reject, lines: [] })
      this._socket.write(`${tag} ${cmd}\r\n`)

      // Timeout
      setTimeout(() => {
        if (this._handlers.has(tag)) {
          this._handlers.delete(tag)
          reject(new Error(`IMAP timeout: ${cmd}`))
        }
      }, 15000)
    })
  }

  async login () {
    // Escape password with quotes
    const pass = this.pass.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
    return this._command(`LOGIN "${this.user}" "${pass}"`)
  }

  async select (mailbox = 'INBOX') {
    return this._command(`SELECT "${mailbox}"`)
  }

  async searchUnseen () {
    const lines = await this._command('SEARCH UNSEEN')
    const searchLine = lines.find(l => l.startsWith('* SEARCH'))
    if (!searchLine) return []

    const ids = searchLine.slice(9).trim().split(/\s+/).filter(Boolean)
    return ids.map(Number).filter(n => !isNaN(n))
  }

  async fetch (id) {
    const lines = await this._command(`FETCH ${id} (RFC822)`)
    // Extract the raw email body from FETCH response
    const content = lines
      .filter(l => !l.startsWith(`A${this._tag}`) && !l.match(/^\* \d+ FETCH/))
      .join('\r\n')

    return parseEmail(content)
  }

  async markSeen (id) {
    return this._command(`STORE ${id} +FLAGS (\\Seen)`)
  }

  async logout () {
    try {
      await this._command('LOGOUT')
    } catch {}
    this.close()
  }

  close () {
    this._connected = false
    if (this._socket) {
      this._socket.destroy()
      this._socket = null
    }
    this._handlers.clear()
  }
}

// Minimal email parser — extracts headers and text body
function parseEmail (raw) {
  const headerEnd = raw.indexOf('\r\n\r\n')
  if (headerEnd === -1) {
    return { from: '', to: '', subject: '', body: raw, date: '', messageId: '' }
  }

  const headerSection = raw.slice(0, headerEnd)
  const body = raw.slice(headerEnd + 4)

  function getHeader (name) {
    const regex = new RegExp(`^${name}:\\s*(.+?)$`, 'mi')
    const match = headerSection.match(regex)
    return match ? match[1].trim() : ''
  }

  // Extract plain text from multipart if needed
  let textBody = body
  const contentType = getHeader('Content-Type')

  if (contentType.includes('multipart')) {
    const boundaryMatch = contentType.match(/boundary="?([^";\s]+)"?/)
    if (boundaryMatch) {
      const boundary = boundaryMatch[1]
      const parts = body.split('--' + boundary)

      for (const part of parts) {
        if (part.includes('text/plain')) {
          const partBody = part.split('\r\n\r\n').slice(1).join('\r\n\r\n')
          textBody = partBody.replace(/--\s*$/, '').trim()
          break
        }
      }
    }
  }

  // Decode quoted-printable if needed
  if (raw.includes('Content-Transfer-Encoding: quoted-printable')) {
    textBody = textBody.replace(/=\r?\n/g, '') // soft line breaks
    // Collect bytes, then decode as UTF-8
    const bytes = []
    for (let i = 0; i < textBody.length; i++) {
      if (textBody[i] === '=' && i + 2 < textBody.length) {
        const hex = textBody.slice(i + 1, i + 3)
        if (/^[0-9A-Fa-f]{2}$/.test(hex)) {
          bytes.push(parseInt(hex, 16))
          i += 2
          continue
        }
      }
      bytes.push(textBody.charCodeAt(i))
    }
    textBody = Buffer.from(bytes).toString('utf8')
  }

  // Decode base64 if needed
  if (raw.includes('Content-Transfer-Encoding: base64')) {
    try {
      textBody = Buffer.from(textBody.replace(/\s/g, ''), 'base64').toString('utf8')
    } catch {}
  }

  return {
    from: getHeader('From'),
    to: getHeader('To'),
    subject: getHeader('Subject'),
    body: textBody.trim(),
    date: getHeader('Date'),
    messageId: getHeader('Message-ID') || getHeader('Message-Id')
  }
}

export { ImapClient, parseEmail }