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 }