/**
* Converts HTML email to plain text, conservatively removing signatures
* and quoted reply chains.
*
* Public API:
* htmlEmailToText(html, requesterName?)
*
* Important:
* Email signature extraction is heuristic. Preserve the original HTML
* separately; this output should not be the authoritative ticket record.
*/
const cheerio = require('cheerio');
const BLOCK_TAGS = new Set([
'address', 'article', 'aside', 'blockquote', 'caption',
'dd', 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure',
'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'header', 'li', 'main', 'nav', 'ol', 'p', 'pre',
'section', 'table', 'tr', 'ul',
]);
// Do not include divtagdefaultwrapper: it can wrap the entire message.
const SIGNATURE_MARKERS = [
'signature',
'gmail_signature',
'email-signature',
'mail-signature',
'x_signature',
'moz-signature',
];
const QUOTE_MARKERS = [
'gmail_quote',
'gmail_quote_container',
'freshdesk_quote',
'appendonsend',
// divrplyfwdmsg deliberately excluded: often header-only.
'yahoo_quoted',
'protonmail_quote',
'zmail_extra',
];
const ATTRIBUTION_RE =
/^(?:on\b.{3,500}\bwrote|le\b.{3,500}\ba\s*[ée]crit|el\b.{3,500}\bescribi[óo]|am\b.{1,200}\bschrieb\b.{1,300}|il\b.{3,500}\bha\s+scritto|em\b.{3,500}\bescreveu)\s*:\s*$/i;
const ORIGINAL_MESSAGE_RE =
/^[-–—]{2,}\s*(?:original message|forwarded message)\s*[-–—]{2,}\s*$/i;
const FORWARDED_MESSAGE_RE =
/^begin forwarded message\s*:\s*$/i;
const UNDERSCORE_SEPARATOR_RE = /^_{5,}$/;
const HEADER_LINE_RE =
/^(from|sent|date|to|subject|cc|reply-to|de|envoyé|envoye|à|a|objet|para|fecha|asunto|von|gesendet|an|betreff)\s*:\s*(.*)$/i;
const HEADER_NAMES = {
from: 'from',
de: 'from',
von: 'from',
sent: 'date',
date: 'date',
envoyé: 'date',
envoye: 'date',
fecha: 'date',
gesendet: 'date',
to: 'to',
à: 'to',
a: 'to',
para: 'to',
an: 'to',
subject: 'subject',
objet: 'subject',
asunto: 'subject',
betreff: 'subject',
cc: 'cc',
'reply-to': 'reply-to',
};
const SIGNOFF_RE =
/^(?:thanks(?:\s+(?:(?:and|&)\s+)?regards)?|thank\s+you(?:\s+so\s+much)?|many\s+thanks|best(?:\s+(?:regards|wishes))?|kind\s+regards|warm\s+regards|regards|sincerely|cheers|yours(?:\s+(?:truly|sincerely))?|with\s+appreciation|cordially|respectfully)[,!.\s]*$/i;
const SIGNATURE_SEPARATOR_RE = /^--\s*$/;
const MOBILE_SIGNATURE_RE =
/^(?:sent from (?:my|a) (?:iphone|ipad|ipod|android|samsung|galaxy|blackberry|mobile|phone)\b.*|sent from (?:outlook|mail) for (?:ios|android|windows)\b.*|get outlook for (?:ios|android)\b.*)$/i;
const DISCLAIMER_RE =
/^(?:confidentiality\s+(?:notice|disclaimer)|(?:legal\s+)?disclaimer\s*:\s*(?:this|the|please|if|confidential|privileged)|privileged\s+(?:and\s+)?confidential|this\s+(?:e-?mail|message|communication)(?:\s+and\s+(?:any\s+)?attachments?)?\s+(?:is|are|may\s+be)\s+(?:confidential|privileged|intended)|the\s+information\s+contained\s+in\s+this\s+(?:e-?mail|message|communication)\s+is\s+(?:confidential|privileged|intended)|if\s+you\s+(?:are\s+not\s+the\s+intended\s+recipient|have\s+received\s+this\s+(?:e-?mail|message)\s+in\s+error)|please\s+consider\s+the\s+environment\s+before\s+printing)/i;
const LEGAL_CONTINUATION_RE =
/(?:confidential|privileged|intended recipient|unauthori[sz]ed|disclos(?:e|ure)|distribution|dissemination|attachments?|received.{0,40}error|delete.{0,40}(?:message|email|e-mail)|notify.{0,40}sender|liability|virus|printing this|all rights reserved|copyright)/i;
// Used to prevent a disclaimer/sign-off from eating an obvious new request.
const REQUEST_LINE_RE =
/^(?:p\.?\s*s\.?(?=\s|[:.]|$)\s*[:.]?|please\b|can you\b|could you\b|would you\b|i (?:need|want|have|am|cannot|can't)\b|we (?:need|want|have|are|cannot|can't)\b|also\b|however\b|but\b|the (?:issue|problem|error)\b|it (?:still|does|doesn't|is|isn't)\b)/i;
const FORM_FIELD_RE =
/^(?:first\s*name|last\s*name|full\s*name|name|e-?mail|organization|organisation|company|products?|timezone|time\s*zone|phone|telephone|mobile|subject|message|comments?|description|department|account(?:\s+(?:id|number))?|ticket(?:\s+(?:id|number))?)\s*[:\t]/i;
const EMAIL_RE = /[\w.!#$%&'*+/=?^`{|}~-]+@[\w-]+(?:\.[\w-]+)+/i;
const EMAIL_LINE_RE =
/^(?:(?:e-?mail|e)\s*[:|]\s*)?[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+[.,;]?$/i;
const URL_LINE_RE =
/^(?:(?:web(?:site)?|url|linkedin)\s*[:|]\s*)?(?:https?:\/\/|www\.)\S+$/i;
const PHONE_LINE_RE =
/^(?:(?:tel(?:ephone)?|phone|mobile|cell|fax|direct|office|work|t|m|p|f)\s*[:.]?\s*)?(?:\+?\d|\(\d)[\d\s()./-]*(?:\s*(?:(?:internal|int\.?)\s*[:.]?\s*(?:ex\.?|ext\.?)?|ext\.?|extension|ex\.?|x)\s*[:.]?\s*\d+)?$/i;
const TITLE_LINE_RE =
/^(?:(?:senior|junior|principal|staff|lead|chief|vice president|vp|head of|assistant|associate|regional|global|technical|customer success)\s+)?(?:software|systems?|network|support|sales|security|data|product|project|account|marketing|operations?|business|solutions?)?\s*(?:engineer|architect|developer|manager|director|specialist|consultant|coordinator|analyst|administrator|representative|officer|executive|president|founder|owner|ceo|cto|coo|cfo|designer)(?:\s+of\s+[A-Za-z][A-Za-z0-9&/ -]{1,40})?(?:\s*[,–-]\s*[A-Za-z0-9&/.-]{1,30})?$/i;
const CREDENTIALS_LINE_RE =
/^[A-Z][A-Z0-9/&.-]{1,10}(?:\s+[IVX]{1,4})?(?:,\s*[A-Z][A-Z0-9/&.-]{1,10}(?:\s+[IVX]{1,4})?)+$/;
const SIGNATURE_BOOKING_LINE_RE =
/^(?:(?:schedule|book)\s+(?:a\s+)?(?:meeting|time|call|appointment)(?:\s+with\s+me)?\s*:(?:\s*\S.*)?|calendar)$/i;
const SIGNATURE_STATUS_LINE_RE =
/^(?:out\s+of\s+(?:the\s+)?office|ooo|upcoming\s+(?:pto|out\s+of\s+office|leave)|travel(?:l?ing)?(?:\s+for\s+work)?|working\s+hours)\s*:\s*\S.*$/i;
const COMPANY_LINE_RE =
/(?:\b(?:inc|incorporated|llc|l\.?l\.?c|ltd|limited|corp|corporation|gmbh|plc|llp|p\.?c|s\.?a|b\.?v|pty\.?\s*ltd)\b\.?|\b(?:university|college|institute|hospital|foundation)\b)$/i;
// Corporate suffix followed by a short comma clause (contractor / division / client relationship).
// Example: "Infosys Limited, Contracted to Pfizer Business Technology"
const COMPANY_WITH_CLAUSE_RE =
/\b(?:inc\.?|incorporated|llc|l\.?l\.?c\.?|ltd\.?|limited|corp\.?|corporation|gmbh|plc|llp|p\.?c\.?|s\.?a\.?|b\.?v\.?|pty\.?\s*ltd\.?)\b,\s*[\p{L}\d&/().' -]{2,70}$/iu;
// Title-cased org-unit / department line with a unit keyword, optional (ACRONYM).
// Example: "Cloud Transformation, Infrastructure and Enterprise Solutions(C-TIES)"
const ORG_UNIT_KEYWORD_RE =
/\b(?:solutions?|services|technology|technologies|infrastructure|transformation|operations|engineering|department|division|group|team|practice|unit|cent(?:er|re)|office|business)\b/i;
const ADDRESS_RE =
/^(?:\d{1,6}\s+.{1,70}\b(?:street|st|avenue|ave|road|rd|parkway|pkwy|boulevard|blvd|drive|dr|lane|ln|way|court|ct|suite|ste|floor|fl)\b.*|p\.?\s*o\.?\s*box\s+\d+.*|[\p{L} .'-]+,\s*[\p{L} .'-]+\s+\d{5}(?:-\d{4})?(?:[\s,]+(?:USA|U\.S\.A\.?|US|U\.S\.?|United States(?:\s+of\s+America)?|Canada|UK|U\.K\.?))?)$/iu;
const SIGNATURE_LOOKBACK = 15;
function normalizeLine(value) {
return value
.replace(/\u00a0/g, ' ')
.replace(/[\u200b\u200e\u200f\ufeff]/g, '')
.replace(/[^\S\t\r\n]+/g, ' ')
.trim();
}
function normalizeName(value) {
return normalizeLine(value || '')
.normalize('NFKC')
.toLowerCase()
.replace(/\s+/g, ' ');
}
function attrHitsMarker(value, markers) {
if (!value) return false;
const lower = value.toLowerCase();
return markers.some(marker => {
const escaped = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(
`(?:^|[\\s_-])${escaped}(?:$|[\\s_-])`
).test(lower);
});
}
function hasMarker($el, markers) {
return (
attrHitsMarker($el.attr('class'), markers) ||
attrHitsMarker($el.attr('id'), markers)
);
}
function isAttribution(text) {
return ATTRIBUTION_RE.test(text);
}
const DATE_OR_TIME_EVIDENCE_RE =
/(?:\b(?:mon(?:day)?|tue(?:sday)?|wed(?:nesday)?|thu(?:rsday)?|fri(?:day)?|sat(?:urday)?|sun(?:day)?)\b|\b(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:tember)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?)\b|\b\d{1,2}[/-]\d{1,2}(?:[/-]\d{2,4})?\b|\b\d{4}[/-]\d{1,2}[/-]\d{1,2}\b|\b\d{1,2}:\d{2}(?:\s*[ap]\.?m\.?)?\b)/i;
function isStrongAttribution(text) {
if (!isAttribution(text)) return false;
return (
EMAIL_RE.test(text) ||
DATE_OR_TIME_EVIDENCE_RE.test(text)
);
}
function isExplicitTextBoundary(text) {
return (
ORIGINAL_MESSAGE_RE.test(text) ||
FORWARDED_MESSAGE_RE.test(text) ||
isStrongAttribution(text)
);
}
/**
* Plain-text rendering used to inspect a possible signature card.
* Includes the root node and handles text nodes, blocks, and
.
*/
function visibleText(node) {
if (!node) return '';
if (node.type === 'text') return node.data || '';
const tag = (node.tagName || '').toLowerCase();
if (tag === 'script' || tag === 'style') return '';
if (tag === 'br') return '\n';
const text = (node.children || []).map(visibleText).join('');
if (tag === 'td' || tag === 'th') return `${text}\t`;
return BLOCK_TAGS.has(tag) ? `\n${text}\n` : text;
}
function isPhoneLine(line) {
if (!PHONE_LINE_RE.test(line)) return false;
const digits = line.replace(/\D/g, '');
return digits.length >= 7 && digits.length <= 18;
}
function isContactLine(line) {
return EMAIL_LINE_RE.test(line) || URL_LINE_RE.test(line) || isPhoneLine(line);
}
function looksLikeName(line, requesterName) {
if (requesterName && normalizeName(line) === normalizeName(requesterName)) {
return true;
}
if (line.length > 70 || REQUEST_LINE_RE.test(line)) return false;
if (isTitleLine(line) || isCompanyLine(line)) return false;
// Deliberately conservative without a known sender name.
// Lowercase/non-Latin display names still work through requesterName.
return /^(?:\p{Lu}[\p{L}'’.-]*)(?:\s+(?:(?:de|del|van|von|da|di|la)\s+)?\p{Lu}[\p{L}'’.-]*){1,3}$/u.test(line);
}
function isTitleLine(line) {
return (
line.length <= 90 &&
!/[?!]/.test(line) &&
!/[.]\s*$/.test(line) &&
!REQUEST_LINE_RE.test(line) &&
!/\b(?:i(?!\.)|we|you|your|our|need|please|escalate|send|contact|call|update|fix|change)\b/i.test(line) &&
TITLE_LINE_RE.test(line)
);
}
function isCompanyLine(line) {
return (
line.length <= 90 &&
!/[?!]/.test(line) &&
!REQUEST_LINE_RE.test(line) &&
!/\b(?:i(?!\.)|we|you|your|our|need|please|escalate|send|contact|call|update|fix|change)\b/i.test(line) &&
(COMPANY_LINE_RE.test(line) || COMPANY_WITH_CLAUSE_RE.test(line))
);
}
function isOrgUnitLine(line) {
if (line.length > 90 || /[?!]/.test(line) || /[.]\s*$/.test(line)) return false;
if (REQUEST_LINE_RE.test(line)) return false;
if (/\b(?:i(?!\.)|we|you|your|our|need|please|escalate|send|contact|call|update|fix|change|is|are|was|has|have|will)\b/i.test(line)) return false;
if (!ORG_UNIT_KEYWORD_RE.test(line)) return false;
// Strip an optional trailing "(C-TIES)" acronym, then require Title Case
// words (small connectors like "and"/"of"/"for" allowed).
const body = line.replace(/\s*\([A-Z][A-Z0-9&/-]{1,12}\)\s*$/, '');
const words = body.split(/[\s,/&-]+/).filter(Boolean);
return words.length >= 2 && words.every(w =>
/^(?:and|of|for|the|to|in|on)$/i.test(w) ||
/^\p{Lu}[\p{L}\d'’.-]*$/u.test(w)
);
}
function isSignatureDetail(line, requesterName) {
return (
looksLikeName(line, requesterName) ||
isTitleLine(line) ||
isCompanyLine(line) ||
isOrgUnitLine(line) ||
isContactLine(line) ||
ADDRESS_RE.test(line) ||
MOBILE_SIGNATURE_RE.test(line) ||
SIGNATURE_SEPARATOR_RE.test(line) ||
SIGNOFF_RE.test(line) ||
CREDENTIALS_LINE_RE.test(line) ||
SIGNATURE_BOOKING_LINE_RE.test(line) ||
SIGNATURE_STATUS_LINE_RE.test(line)
);
}
// Examples:
// DPC, Donnelly 115
// MIT, Building 32
//
// Deliberately requires an uppercase campus/site code, a comma,
// a capitalized building name, and a room/building number.
const CAMPUS_LOCATION_RE =
/^[A-Z][A-Z0-9&.-]{1,11},\s*(?:\p{Lu}[\p{L}.'’-]*\s+){1,4}\d{1,5}[A-Za-z]?$/u;
// Examples:
// Room 115
// Office: B-204
// Suite 300
// Building 32
//
// Does not accept arbitrary text after the room identifier.
const LABELED_ROOM_RE =
/^(?:room|rm\.?|office|suite|ste\.?|building|bldg\.?)\s*:?\s*(?:[A-Za-z]{1,3}[- ]?)?\d{1,5}[A-Za-z]?(?:[-/]\d{1,5}[A-Za-z]?)?$/i;
function isSignatureLocationLine(line) {
return (
line.length <= 80 &&
!REQUEST_LINE_RE.test(line) &&
(
CAMPUS_LOCATION_RE.test(line) ||
LABELED_ROOM_RE.test(line)
)
);
}
/**
* Validate a complete signature tail.
*
* A building/room line is allowed only when:
* - the tail contains a recognizable contact line;
* - it also contains a title or company line;
* - at most one line needs the location exception.
*
* Every other line must satisfy the existing signature-detail rules.
* Unknown prose is never silently accepted.
*/
function isSignatureTail(lines, requesterName) {
if (hasStructuredForm(lines)) return false;
const hasContact = lines.some(isContactLine);
const hasProfessionalContext = lines.some(line =>
isTitleLine(line) || isCompanyLine(line) || isOrgUnitLine(line)
);
let locationExceptions = 0;
for (const line of lines) {
if (isSignatureDetail(line, requesterName)) continue;
if (
hasContact &&
hasProfessionalContext &&
locationExceptions === 0 &&
isSignatureLocationLine(line)
) {
locationExceptions++;
continue;
}
return false;
}
return true;
}
/**
* A logo + website alone is not enough: it could be a real message card.
* Require a compact contact card with a person/title signal and a direct
* contact link, and never classify a leading layout table as a signature.
*/
function isSignatureCard($, el, requesterName, hasPrecedingText) {
if (!hasPrecedingText) return false;
const tag = (el.tagName || '').toLowerCase();
if (!['table', 'div', 'section'].includes(tag)) return false;
const $el = $(el);
if (!$el.find('img').length) return false;
const hasDirectContact = $el.find('a[href]').toArray().some(a =>
/^(?:mailto|tel):/i.test(($(a).attr('href') || '').trim())
);
const lines = visibleText(el)
.split(/\r\n?|\n|\t/)
.map(normalizeLine)
.filter(Boolean);
const hasVisibleContact = lines.some(isContactLine);
if (!hasDirectContact && !hasVisibleContact) return false;
if (lines.length < 2 || lines.length > 8) return false;
if (lines.join(' ').length > 500) return false;
return (
lines.some(line =>
looksLikeName(line, requesterName) || isTitleLine(line)
) &&
isSignatureTail(lines, requesterName)
);
}
/**
* Render into structured lines rather than injecting magic text tokens.
*
* A removed quote remains represented as { kind: 'quote' }, allowing:
*
* On ... wrote:
*
old message* My response below the quote. * * to retain the response rather than truncating at the attribution. */ function renderEmailLines($, root, requesterName, options = {}) { const keepQuotes = options.keepQuotes === true; const keepSignatures = options.keepSignatures === true; const lines = []; let buffer = ''; let hasPrecedingText = false; function flush() { for (const part of buffer.split(/\r\n?|\n/)) { const text = normalizeLine(part); if (!text) continue; lines.push({ kind: 'text', text }); hasPrecedingText = true; } buffer = ''; } function marker(kind) { flush(); lines.push({ kind }); } function walk(node) { if (node.type === 'text') { buffer += node.data || ''; return; } const tag = (node.tagName || '').toLowerCase(); // Also traverse document/root nodes. if (!tag) { (node.children || []).forEach(walk); return; } if (['script', 'style', 'head', 'template'].includes(tag)) return; const $el = $(node); const style = $el.attr('style') || ''; if ( $el.attr('hidden') !== undefined || /(?:^|;)\s*display\s*:\s*none\b/i.test(style) || /(?:^|;)\s*visibility\s*:\s*hidden\b/i.test(style) ) { return; } if ($el.attr('data-skip-ai-process') !== undefined) { marker('skipped'); return; } // Document wrappers must not be classified as signatures/quotes. if (!['html', 'body'].includes(tag)) { if (!keepSignatures && hasMarker($el, SIGNATURE_MARKERS)) { marker('signature'); return; } let lastText = normalizeLine(buffer); if (!lastText) { for (let li = lines.length - 1; li >= 0; li--) { if (lines[li].kind === 'text') { lastText = lines[li].text; break; } } } const citePrecededByAttribution = tag === 'blockquote' && ($el.attr('type') || '').toLowerCase() === 'cite' && isStrongAttribution(lastText); const explicitQuote = !keepQuotes && ( hasMarker($el, QUOTE_MARKERS) || citePrecededByAttribution ); if (explicitQuote) { marker('quote'); return; } if ( !keepSignatures && isSignatureCard( $, node, requesterName, hasPrecedingText || Boolean(normalizeLine(buffer)) ) ) { marker('signature'); return; } } if (tag === 'hr') { marker('divider'); return; } if (tag === 'br') { flush(); return; } const block = BLOCK_TAGS.has(tag); if (block) flush(); (node.children || []).forEach(walk); if (tag === 'td' || tag === 'th') { buffer += '\t'; } else if (block) { flush(); } } walk(root); flush(); return lines; } /** * Recognize a contiguous reply-header cluster. * * Unlike collecting arbitrary headers from the next eight lines, this * stops at ordinary prose. "From:" in one paragraph and "Subject:" much * later cannot combine into a false reply boundary. */ function readReplyHeader(lines, start) { const fields = new Map(); let end = start; while (end < lines.length && end - start < 8) { const entry = lines[end]; if (entry.kind !== 'text') break; const match = entry.text.match(HEADER_LINE_RE); if (!match) break; const name = HEADER_NAMES[match[1].toLowerCase()]; fields.set(name, match[2].trim()); end++; } const sender = fields.get('from'); if (!sender) return null; const hasDate = fields.has('date'); const hasEmailSender = EMAIL_RE.test(sender); const confident = ( fields.size >= 2 && hasDate && (hasEmailSender || fields.size >= 3) ) || ( fields.size >= 3 && hasEmailSender && fields.has('subject') ); return confident ? { end } : null; } function nextRelevantIndex(lines, start) { let i = start; while ( i < lines.length && ['divider', 'skipped', 'signature'].includes(lines[i].kind) ) { i++; } return i; } /** * Remove explicit quote nodes and detect unmarked quoted tails. * * Explicit >-prefixed quotes can be interleaved with customer answers. * Do not truncate those answers merely because an attribution precedes * the first quoted line. */ function removeReplyNoise(entries) { const kept = []; for (let i = 0; i < entries.length; i++) { const entry = entries[i]; if (entry.kind !== 'text') continue; const text = entry.text; if (/^>/.test(text)) continue; const header = readReplyHeader(entries, i); const attribution = isAttribution(text); const boundary = isExplicitTextBoundary(text); const underscoreHeader = UNDERSCORE_SEPARATOR_RE.test(text) && readReplyHeader(entries, nextRelevantIndex(entries, i + 1)); if (!header && !attribution && !boundary && !underscoreHeader) { kept.push(text); continue; } const after = header ? header.end : i + 1; const next = nextRelevantIndex(entries, after); const nextEntry = entries[next]; const followedByExplicitQuote = nextEntry?.kind === 'quote' || ( nextEntry?.kind === 'text' && /^>/.test(nextEntry.text) ); if (followedByExplicitQuote) { // Remove only the attribution/header, not later answers. i = after - 1; continue; } if (header || boundary || underscoreHeader) { if (kept.length > 0) break; // A forward/quote at the beginning may be the ticket's entire // submitted content. Preserve its body rather than returning // an empty ticket; omit only the recognized header. i = after - 1; continue; } // Attribution-like prose without corroboration stays intact. kept.push(text); } return kept; } // Contact labels frequently occur in real signatures. They do not, by // themselves, prove that a block is a structured intake submission. const CONTACT_FIELD_LABEL_RE = /^(?:e-?mail|phone|telephone|mobile)\s*[:\t]/i; // These fields strongly suggest submitted ticket/form data. // Preserve them even when there is only one such label in the candidate. const INTAKE_ONLY_FIELD_RE = /^(?:first\s*name|last\s*name|products?|timezone|time\s*zone)\s*[:\t]/i; function hasStructuredForm(lines) { if (lines.some(line => INTAKE_ONLY_FIELD_RE.test(line))) { return true; } const fields = lines.filter(line => FORM_FIELD_RE.test(line)); // Email + Phone can be signature details. // Organization + Email, Name + Phone, etc. remain protected. return ( fields.length >= 2 && fields.some(line => !CONTACT_FIELD_LABEL_RE.test(line)) ); } // Hard-wrapped disclaimer text (a signature block or pasted legal footer // wrapped at a fixed column, using literal newlines instead of