Skip to content
Snippets Groups Projects
DDMsgReader.js 767 KiB
Newer Older
// Removes ANSI codes from a string.
//
// Parameters:
//  pStr: The string to remove ANSI codes from
//  pANSIRegexes: An array of regular expressions to use for searching for ANSI codes
//
// Return value: A version of the string without ANSI codes
function removeANSIFromStr(pStr, pANSIRegexes)
	for (var i = 0; i < pANSIRegexes.length; ++i)
		theStr = theStr.replace(pANSIRegexes[i], "");
	return theStr;
}

// Returns the last index in a string where a regex is found.
// From this page:
// http://stackoverflow.com/questions/273789/is-there-a-version-of-javascripts-string-indexof-that-allows-for-regular-expr
//
// Parameters:
//  pStr: The string to search
//  pRegex: The regular expression to match in the string
//  pStartPos: Optional - The starting position in the string.  If this is not
//             passed, then the end of the string will be used.
//
// Return value: The last index in the string where the regex is found, or -1 if not found.
function regexLastIndexOf(pStr, pRegex, pStartPos)
{
	pRegex = (pRegex.global) ? pRegex : new RegExp(pRegex.source, "g" + (pRegex.ignoreCase ? "i" : "") + (pRegex.multiLine ? "m" : ""));
	if (typeof(pStartPos) == "undefined")
		pStartPos = pStr.length;
	else if (pStartPos < 0)
		pStartPos = 0;
	var stringToWorkWith = pStr.substring(0, pStartPos + 1);
	var lastIndexOf = -1;
	var nextStop = 0;
	while ((result = pRegex.exec(stringToWorkWith)) != null)
	{
		lastIndexOf = result.index;
		pRegex.lastIndex = ++nextStop;
	}
    return lastIndexOf;
// Returns the first index in a string where a regex is found.
//
// Parameters:
//  pStr: The string to search
//  pRegex: The regular expression to match in the string
//
// Return value: The first index in the string where the regex is found, or -1 if not found.
function regexFirstIndexOf(pStr, pRegex)
{
	pRegex = (pRegex.global) ? pRegex : new RegExp(pRegex.source, "g" + (pRegex.ignoreCase ? "i" : "") + (pRegex.multiLine ? "m" : ""));
	var indexOfRegex = -1;
	var nextStop = 0;
	while ((result = pRegex.exec(pStr)) != null)
	{
		indexOfRegex = result.index;
		pRegex.lastIndex = ++nextStop;
	}
    return indexOfRegex;
}

// Converts ANSI ;-delimited modes (such as alue;...;Valuem) to Synchronet
// attribute codes
//
// Parameters:
//  pText: The text with ANSI ;-delimited modes to convert
//
// Return value: The text with ANSI ;-delimited modes converted to Synchronet attributes
function ANSIMultiConvertToSyncCodes(pText)
{
	var multiMatches = pText.match(/\[[0-9]+(;[0-9]+)+m/g);
	if (multiMatches == null)
		return pText;
	var updatedText = pText;
	for (var i = 0; i < multiMatches.length; ++i)
	{
		// Copy the string, with the [ removed from the beginning and the
		// trailing 'm' removed
		var text = multiMatches[i].substr(2);
		text = text.substr(0, text.length-1);
		var codes = text.split(";");
		var syncCodes = "";
		for (var idx = 0; idx < codes.length; ++idx)
		{
			if (codes[idx] == "0") // All attributes off
				syncCodes += "\1n";
			else if (codes[idx] == "1") // Bold on (high intensity)
				syncCodes += "\1h";
			else if (codes[idx] == "5") // Blink on
				syncCodes += "\1i";
			else if (codes[idx] == "30") // Black foreground
				syncCodes += "\1k";
			else if (codes[idx] == "31") // Red foreground
				syncCodes += "\1r";
			else if (codes[idx] == "32") // Green foreground
				syncCodes += "\1g";
			else if (codes[idx] == "33") // Yellow foreground
				syncCodes += "\1y";
			else if (codes[idx] == "34") // Blue foreground
				syncCodes += "\1b";
			else if (codes[idx] == "35") // Magenta foreground
				syncCodes += "\1m";
			else if (codes[idx] == "36") // Cyan foreground
				syncCodes += "\1c";
			else if (codes[idx] == "37") // White foreground
				syncCodes += "\1w";
			else if (codes[idx] == "40") // Black background
				syncCodes += "\1" + "0";
			else if (codes[idx] == "41") // Red background
				syncCodes += "\1" + "1";
			else if (codes[idx] == "42") // Green background
				syncCodes += "\1" + "2";
			else if (codes[idx] == "43") // Yellow background
				syncCodes += "\1" + "3";
			else if (codes[idx] == "44") // Blue background
				syncCodes += "\1" + "4";
			else if (codes[idx] == "45") // Magenta background
				syncCodes += "\1" + "5";
			else if (codes[idx] == "46") // Cyan background
				syncCodes += "\1" + "6";
			else if (codes[idx] == "47") // White background
				syncCodes += "\1" + "7";
		}
		updatedText = updatedText.replace(multiMatches[i], syncCodes);
	}
	return updatedText;
}

// Given some text, this converts ANSI color codes to Synchronet codes and
// removes unwanted ANSI codes (such as cursor movement codes, etc.).
//
// Parameters:
//  pText: A string to process
//
// Return value: A version of the string with Synchronet color codes converted to
//               Synchronet attribute codes and unwanted ANSI codes removed
function cvtANSIToSyncAndRemoveUnwantedANSI(pText)
{
	// Attributes
	var txt = pText.replace(/\[0[mM]/g, "\1n"); // All attributes off
	txt = txt.replace(/\[1[mM]/g, "\1h"); // Bold on (use high intensity)
	txt = txt.replace(/\[5[mM]/g, "\1i"); // Blink on
	// Foreground colors
	txt = txt.replace(/\[30[mM]/g, "\1k"); // Black foreground
	txt = txt.replace(/\[31[mM]/g, "\1r"); // Red foreground
	txt = txt.replace(/\[32[mM]/g, "\1g"); // Green foreground
	txt = txt.replace(/\[33[mM]/g, "\1y"); // Yellow foreground
	txt = txt.replace(/\[34[mM]/g, "\1b"); // Blue foreground
	txt = txt.replace(/\[35[mM]/g, "\1m"); // Magenta foreground
	txt = txt.replace(/\[36[mM]/g, "\1c"); // Cyan foreground
	txt = txt.replace(/\[37[mM]/g, "\1w"); // White foreground
	// Background colors
	txt = txt.replace(/\[40[mM]/g, "\1" + "0"); // Black background
	txt = txt.replace(/\[41[mM]/g, "\1" + "1"); // Red background
	txt = txt.replace(/\[42[mM]/g, "\1" + "2"); // Green background
	txt = txt.replace(/\[43[mM]/g, "\1" + "3"); // Yellow background
	txt = txt.replace(/\[44[mM]/g, "\1" + "4"); // Blue background
	txt = txt.replace(/\[45[mM]/g, "\1" + "5"); // Magenta background
	txt = txt.replace(/\[46[mM]/g, "\1" + "6"); // Cyan background
	txt = txt.replace(/\[47[mM]/g, "\1" + "7"); // White background
	// Convert ;-delimited modes (such as alue;...;Valuem)
	txt = ANSIMultiConvertToSyncCodes(txt);
	// Remove ANSI codes that are not wanted (such as moving the cursor, etc.)
	txt = txt.replace(/\[[0-9]+[aA]/g, ""); // Cursor up
	txt = txt.replace(/\[[0-9]+[bB]/g, ""); // Cursor down
	txt = txt.replace(/\[[0-9]+[cC]/g, ""); // Cursor forward
	txt = txt.replace(/\[[0-9]+[dD]/g, ""); // Cursor backward
	txt = txt.replace(/\[[0-9]+;[0-9]+[hH]/g, ""); // Cursor position
	txt = txt.replace(/\[[0-9]+;[0-9]+[fF]/g, ""); // Cursor position
	txt = txt.replace(/\[[sS]/g, ""); // Restore cursor position
	txt = txt.replace(/\[2[jJ]/g, ""); // Erase display
	txt = txt.replace(/\[[kK]/g, ""); // Erase line
	txt = txt.replace(/\[=[0-9]+[hH]/g, ""); // Set various screen modes
	txt = txt.replace(/\[=[0-9]+[lL]/g, ""); // Reset various screen modes
	return txt;
}

// Returns whether a given message group index & sub-board index (or the current ones,
// based on bbs.curgrp and bbs.cursub) are for the last message sub-board on the system.
//
// Parameters:
//  pGrpIdx: Optional - The index of the message group.  If not specified, this will
//           default to bbs.curgrp.  If bbs.curgrp is not defined in that case,
//           then this method will return false.
//  pSubIdx: Optional - The index of the message sub-board.  If not specified, this will
//           default to bbs.cursub.  If bbs.cursub is not defined in that case,
//           then this method will return false.
//
// Return value: Boolean - Whether or not the current/given message group index & sub-board
//               index are for the last message sub-board on the system.  If there
//               are any issues with any of the values (including bbs.curgrp or
//               bbs.cursub), this method will return false.
function curMsgSubBoardIsLast(pGrpIdx, pSubIdx)
{
   var curGrp = 0;
   if (typeof(pGrpIdx) == "number")
      curGrp = pGrpIdx;
   else if (typeof(bbs.curgrp) == "number")
      curGrp = bbs.curgrp;
   else
      return false;
   var curSub = 0;
   if (typeof(pSubIdx) == "number")
      curSub = pSubIdx;
   else if (typeof(bbs.cursub) == "number")
      curSub = bbs.cursub;
   else
      return false;

   return (curGrp == msg_area.grp_list.length-1) && (curSub == msg_area.grp_list[msg_area.grp_list.length-1].sub_list.length-1);
}

// Parses arguments, where each argument in the given array is in the format
// -arg=val.  If the value is the string "true" or "false", then the value will
// be a boolean.  Otherwise, the value will be a string.
//
// Parameters:
//  pArgArr: An array of strings containing values in the format -arg=val
//
// Return value: An object containing the argument values.  The index will be
//               the argument names, converted to lowercase.  The values will
//               be either the string argument values or boolean values, depending
//               on the formats of the arguments passed in.
function parseArgs(pArgArr)
{
	var argVals = new Object();
	// Set default values for parameters that are just true/false values
	argVals.chooseareafirst = false;
	argVals.personalemail = false;
	argVals.personalemailsent = false;
	argVals.verboselogging = false;
	argVals.suppresssearchtypetext = false;

	// Sanity checking for pArgArr - Make sure it's an array
	if ((typeof(pArgArr) != "object") || (typeof(pArgArr.length) != "number"))
		return argVals;

	// Go through pArgArr looking for strings in the format -arg=val and parse them
	// into objects in the argVals array.
	var equalsIdx = 0;
	var argName = "";
	var argVal = "";
	var argValLower = ""; // For case-insensitive "true"/"false" matching
	var argValIsTrue = false;
	for (var i = 0; i < pArgArr.length; ++i)
	{
		// We're looking for strings that start with "-", except strings that are
		// only "-".
		if ((typeof(pArgArr[i]) != "string") || (pArgArr[i].length == 0) ||
		    (pArgArr[i].charAt(0) != "-") || (pArgArr[i] == "-"))
		{
			continue;
		}
		// Look for an = and if found, split the string on the =
		equalsIdx = pArgArr[i].indexOf("=");
		// If a = is found, then split on it and add the argument name & value
		// to the array.  Otherwise (if the = is not found), then treat the
		// argument as a boolean and set it to true (to enable an option).
		if (equalsIdx > -1)
		{
			argName = pArgArr[i].substring(1, equalsIdx).toLowerCase();
			argVal = pArgArr[i].substr(equalsIdx+1);
			argValLower = argVal.toLowerCase();
			// If the argument value is the word "true" or "false", then add it as a
			// boolean.  Otherwise, add it as a string.
			argValIsTrue = (argValLower == "true");
			if (argValIsTrue || (argValLower == "false"))
				argVals[argName] = argValIsTrue;
			else
				argVals[argName] = argVal;
		}
		else // An equals sign (=) was not found.  Add as a boolean set to true to enable the option.
		{
			argName = pArgArr[i].substr(1).toLowerCase();
			if ((argName == "chooseareafirst") || (argName == "personalemail") ||
			    (argName == "personalemailsent") || (argName == "allpersonalemail") ||
				(argName == "verboselogging") || (argName == "suppresssearchtypetext"))

	// Sanity checking
	// If the arguments include personalEmail and personalEmail is enabled,
	// then check to see if a search type was specified - If so, only allow
	// keyword search and from name search.
	if (argVals.hasOwnProperty("personalemail") && argVals.personalemail)
	{
		// If a search type is specified, only allow keyword search & from name
		// search
		if (argVals.hasOwnProperty("search"))
		{
			var searchValLower = argVals.search.toLowerCase();
			if ((searchValLower != "keyword_search") && (searchValLower != "from_name_search"))
				delete argVals.search;
		}
	}
	// If the arguments include userNum, make sure the value is all digits.  If so,
	// add altUserNum to the arguments as a number type for user matching when looking
	// for personal email to the user.
	if (argVals.hasOwnProperty("usernum"))
	{
		if (/^[0-9]+$/.test(argVals.usernum))
		{
			var specifiedUserNum = Number(argVals.usernum);
			// If the specified number is different than the current logged-in
			// user, then load the other user account and read their name and
			// alias and also store their user number in the arg vals as a
			// number.
			if (specifiedUserNum != user.number)
			{
				var theUser = new User(specifiedUserNum);
				argVals.altUserNum = theUser.number;
				argVals.altUserName = theUser.name;
				argVals.altUserAlias = theUser.alias;
			}
			else
				delete argVals.usernum;
		}
		else
			delete argVals.usernum;
	}

	return argVals;
}

// Returns a string describing all message attributes (main, auxiliary, and net).
//
// Parameters:
//  pMsgHdr: A message header object.  
//
// Return value: A string describing all of the message attributes
function makeAllMsgAttrStr(pMsgHdr)
{
   if ((pMsgHdr == null) || (typeof(pMsgHdr) != "object"))
      return "";

   var msgAttrStr = makeMainMsgAttrStr(pMsgHdr.attr);
   var auxAttrStr = makeAuxMsgAttrStr(pMsgHdr.auxattr);
   if (auxAttrStr.length > 0)
   {
      if (msgAttrStr.length > 0)
         msgAttrStr += ", ";
      msgAttrStr += auxAttrStr;
   }
   var netAttrStr = makeNetMsgAttrStr(pMsgHdr.netattr);
   if (netAttrStr.length > 0)
   {
      if (msgAttrStr.length > 0)
         msgAttrStr += ", ";
      msgAttrStr += netAttrStr;
   }
   return msgAttrStr;
}

// Returns a string describing the main message attributes.  Makes use of the
// gMainMsgAttrStrs object for the main message attributes and description
// strings.
//
// Parameters:
//  pMainMsgAttrs: The bit field for the main message attributes
//                 (normally, the 'attr' property of a header object)
//  pIfEmptyString: Optional - A string to use if there are no attributes set
//
// Return value: A string describing the main message attributes
function makeMainMsgAttrStr(pMainMsgAttrs, pIfEmptyString)
{
   var msgAttrStr = "";
   if (typeof(pMainMsgAttrs) == "number")
   {
      for (var prop in gMainMsgAttrStrs)
      {
         if ((pMainMsgAttrs & prop) == prop)
         {
            if (msgAttrStr.length > 0)
               msgAttrStr += ", ";
            msgAttrStr += gMainMsgAttrStrs[prop];
         }
      }
   }
   if ((msgAttrStr.length == 0) && (typeof(pIfEmptyString) == "string"))
	   msgAttrStr = pIfEmptyString;
   return msgAttrStr;
}

// Returns a string describing auxiliary message attributes.  Makes use of the
// gAuxMsgAttrStrs object for the auxiliary message attributes and description
// strings.
//
// Parameters:
//  pAuxMsgAttrs: The bit field for the auxiliary message attributes
//                (normally, the 'auxattr' property of a header object)
//  pIfEmptyString: Optional - A string to use if there are no attributes set
//
// Return value: A string describing the auxiliary message attributes
function makeAuxMsgAttrStr(pAuxMsgAttrs, pIfEmptyString)
   if (typeof(pAuxMsgAttrs) == "number")
         {
            if (msgAttrStr.length > 0)
               msgAttrStr += ", ";
            msgAttrStr += gAuxMsgAttrStrs[prop];
         }
      }
   }
   if ((msgAttrStr.length == 0) && (typeof(pIfEmptyString) == "string"))
	   msgAttrStr = pIfEmptyString;
   return msgAttrStr;
}

// Returns a string describing network message attributes.  Makes use of the
// gNetMsgAttrStrs object for the network message attributes and description
// strings.
//
// Parameters:
//  pNetMsgAttrs: The bit field for the network message attributes
//                (normally, the 'netattr' property of a header object)
//  pIfEmptyString: Optional - A string to use if there are no attributes set
//
// Return value: A string describing the network message attributes
function makeNetMsgAttrStr(pNetMsgAttrs, pIfEmptyString)
   if (typeof(pNetMsgAttrs) == "number")
         {
            if (msgAttrStr.length > 0)
               msgAttrStr += ", ";
            msgAttrStr += gNetMsgAttrStrs[prop];
         }
      }
   }
   if ((msgAttrStr.length == 0) && (typeof(pIfEmptyString) == "string"))
	   msgAttrStr = pIfEmptyString;
   return msgAttrStr;
}

// Given a sub-board code, this function returns a sub-board's group and name.
// If the given sub-board code is "mail", then this will return "Personal mail".
//
// Parameters:
//  pSubBoardCode: An internal sub-board code
//
// Return value: A string containing the sub-board code group & name, or
//               "Personal email" if it's the personal email sub-board
function subBoardGrpAndName(pSubBoardCode)
{
	if (typeof(pSubBoardCode) != "string")
		return "";

	var subBoardGrpAndName = "";
	if (pSubBoardCode == "mail")
		subBoardGrpAndName = "Personal mail";
	else
	{
		subBoardGrpAndName = msg_area.sub[pSubBoardCode].grp_name + " - "
                         + msg_area.sub[pSubBoardCode].name;
	}

	return subBoardGrpAndName;
}

// Returns whether a given string matches the current user's name, handle, or alias.
// Does a case-insensitive match.
//
// Parameters:
//  pStr: The string to match against the user's name/handle/alias
//
// Return value: Boolean - Whether or not the string matches the current user's name,
//               handle, or alias
function userNameHandleAliasMatch(pStr)
{
	if (typeof(pStr) != "string")
		return false;
	var strUpper = pStr.toUpperCase();
	return ((strUpper == user.name.toUpperCase()) || (strUpper == user.handle.toUpperCase()) || (strUpper == user.alias.toUpperCase()));
}

// Writes a log message to the system log (using LOG_INFO log level) and to the
// node log.  This will prepend the text "Digital Distortion Message Reader ("
// + user.alias + "): " to the log message.
// 
// Parameters:
//  pMessage: The message to log
//  pLogLevel: The log level.  Optional - Defaults to LOG_INFO.
function writeToSysAndNodeLog(pMessage, pLogLevel)
{
	if (typeof(pMessage) != "string")
		return;

	var logMessage = "Digital Distortion Message Reader (" +  user.alias + "): " + pMessage;
	var logLevel = (typeof(pLogLevel) == "number" ? pLogLevel : LOG_INFO);
	log(logLevel, logMessage);
// This function looks up and returns a sub-board code from the sub-board number.
// If no matching sub-board is found, this will return an empty string.
//
// Parameters:
//  pSubBoardNum: A sub-board number
//
// Return value: The sub-board code.  If no matching sub-board is found, an empty
//               string will be returned.
function getSubBoardCodeFromNum(pSubBoardNum)
{
	// Ensure we're using a numeric type for the sub-board number
	// (in case pSubBoardNum is a string rather than a number)
	var subNum = Number(pSubBoardNum);

	var subBoardCode = "";
	for (var subCode in msg_area.sub)
	{
		if (msg_area.sub[subCode].number == subNum)
		{
			subBoardCode = subCode;
			break;
		}
	}
	return subBoardCode;
}

// Separates message text and any attachment data.
//
// Parameters:
//  pMsgHdr: The message header object
//  pMsgText: The text of a message
//  pGetB64Data: Optional boolean - Whether or not to get the Base64-encoded
//               data for base64-encoded attachments (i.e., in multi-part MIME
//               emails).  Defaults to true.
//
// Return value: An object containing the following properties:
//               msgText: The text of the message, without any of the
//                        attachment base64-encoded data, etc.  If
//                        the message doesn't have any attachments, then
//                        this will likely be the same as pMsgText.
//               attachments: An array of objects containing the following properties
//                            for each attachment:
//                            B64Data: Base64-encoded file data - Only for attachments
//                                     that were attached as base64 in the message (i.e.,
//                                     in a multi-part MIME message).  If the attachment
//                                     was uploaded to the user's Synchronet mailbox,
//                                     then the object won't have the B64Data property.
//                            filename: The name of the attached file
//                            fullyPathedFilename: The full path & filename of the
//                                                 attached file saved on the BBS machine
//               errorMsg: An error message if anything went wrong.  If
//                         nothing went wrong, this will be an empty string.
function determineMsgAttachments(pMsgHdr, pMsgText, pGetB64Data)
{
	var retObj = new Object();
	retObj.msgText = "";
	retObj.attachments = [];
	retObj.errorMsg = "";

	// Keep track of the user's inbox directory:  sbbs/data/file/<userNum>.in
	var userInboxDir = backslash(backslash(system.data_dir + "file") + format("%04d.in", user.number));
	// If the message subject is a filename that exists in the user's
	// inbox directory, then add its filename to the list of attached
	// filenames that will be returned
	var fullyPathedAttachmentFilename = userInboxDir + pMsgHdr.subject;
	if (file_exists(fullyPathedAttachmentFilename))
	{
		retObj.attachments.push({ filename: pMsgHdr.subject,
		                          fullyPathedFilename: fullyPathedAttachmentFilename });
	}

	// The message to prepend onto the message text if the message has attachments
	var msgHasAttachmentsTxt = "\1n\1g\1h- This message contains one or more attachments. Press CTRL-A to download.\1n\r\n"
	                         + "\1n\1g\1h--------------------------------------------------------------------------\1n\r\n";

	// Sanity checking
	if (typeof(pMsgText) != "string")
	{
		// If there are any attachments, prepend the message text with a message
		// saying that the message contains attachments.
		if (retObj.attachments.length > 0)
			retObj.msgText = msgHasAttachmentsTxt + retObj.msgText;
		return retObj;
	}

	// If the message text doesn't include a line starting with -- and a
	// line starting with "Content-type:", then then just return the
	// the same text in retObj.
	//var hasMultiParts = /--\S+\s*Content-Type:/.test(pMsgText);
	//var hasMultiParts = ((dashDashIdx > -1) && (/Content-Type/.test(pMsgText)));
	var dashDashIdx = pMsgText.indexOf("--");
	var hasMultiParts = ((dashDashIdx > -1) && (pMsgText.indexOf("Content-Type", dashDashIdx+1) > dashDashIdx));
	if (!hasMultiParts)
	{
		//retObj.msgText = pMsgText;
		// If there are any attachments, prepend the message text with a message
		// saying that the message contains attachments.
		if (retObj.attachments.length > 0)
			retObj.msgText = msgHasAttachmentsTxt + pMsgText;
		else
			retObj.msgText = pMsgText;
		return retObj;
	}

	var getB64Data = true;
	if (typeof(pGetB64Data) == "boolean")
		getB64Data = pGetB64Data;

	// Look in the message text for a line starting with -- followed by some characters,
	// then whitespace
	var sepMatches = /--\S+\s/.exec(pMsgText);
	var msgSeparator = sepMatches[0];
	// If the last character in msgSeparator is a whitepsace character, then
	// remove it.
	if (/\s/.test(msgSeparator.substr(msgSeparator.length-1, 1)))
		msgSeparator = msgSeparator.substr(0, msgSeparator.length-1);
	var contentType = ""; // The content type of the current section
	var lastContentType = ""; // The content type of the last section
	var contentEncodingType = "";
	var sepIdx = 0;
	var lastSepIdx = -1;
	var lastContentTypeIdx = -1;
	var lastContentEncodingTypeIdx = -1;
	var startIdx = 0;
	var gotMessageText = false; // In case the message has both text/plain & text/html
	while ((sepIdx = pMsgText.indexOf(msgSeparator, startIdx)) >= 0)
	{
		var contentEncodingTypeIdx = -1;
		// Look for a "Content-Type:" from the starting index
		var contentTypeIdx = pMsgText.indexOf("Content-Type: ", startIdx+msgSeparator.length);
		if (contentTypeIdx > -1)
		{
			// Extract the content-type string up to a newline or 15 characters
			// if there's no newline
			var newlineIdx = pMsgText.indexOf("\n", contentTypeIdx+14);
			contentType = pMsgText.substring(contentTypeIdx+14, newlineIdx > -1 ? newlineIdx : contentTypeIdx+29);
			// If the last character is whitespace (i.e., a newline), then remove it.
			if (/\s/.test(contentType.substr(contentType.length-1, 1)))
				contentType = contentType.substr(0, contentType.length-1);

			// Update the start index for looking for the next message separator string
			// - This should be after the "Content-type:" value.
			startIdx = contentTypeIdx + contentType.length;
		}
		else
		{
			// No "Content-Type:" string was found
			// Update the start index for looking for the next message separator string
			startIdx = sepIdx + msgSeparator.length;
		}

		if ((lastSepIdx > -1) && (lastContentTypeIdx > -1))
		{
			// msgTextSearchStartIdx stores the index of where to start looking
			// for the message text.  It could be lastContentTypeIdx, or it could
			// be the content encoding type index if the "Content encoding type"
			// text is found for the current message part.
			var msgTextSearchStartIdx = lastContentTypeIdx;

			// Look for "Content-Transfer-Encoding:" right after the content type
			// and extract the content encoding type string
			contentEncodingTypeIdx = pMsgText.indexOf("Content-Transfer-Encoding:", lastContentTypeIdx);
			// If "Content-Transfer-Encoding:" wasn't found after the content type,
			// then look just before the content type, but after the last separator
			// string.
			if (contentEncodingTypeIdx == -1)
				contentEncodingTypeIdx = pMsgText.indexOf("Content-Transfer-Encoding:", lastSepIdx);
			// If the next "Content-Encoding-Type" is after the current section,
			// then this section doesn't have a content type, so blank it out.
			if (contentEncodingTypeIdx > sepIdx)
			{
				contentEncodingTypeIdx = -1;
				contentEncodingType = "";
			}
			else
			{
				msgTextSearchStartIdx = contentEncodingTypeIdx;
				// Extract the content encoding type
				var newlineIdx = pMsgText.indexOf("\n", contentEncodingTypeIdx+26);
				contentEncodingType = pMsgText.substring(contentEncodingTypeIdx, newlineIdx);
				// If the last character is whitespace (i.e., a newline), then remove it.
				if (/\s/.test(contentEncodingType.substr(contentEncodingType.length-1, 1)))
					contentEncodingType = contentEncodingType.substr(0, contentEncodingType.length-1);
				// Update startIdx based on the length of the "content encoding type" string
				startIdx += contentEncodingType.length;
				// Now, store just the content type in contentEncodingType (i.e., "base64").
				contentEncodingType = contentEncodingType.substr(27).toLowerCase();
			}

			// Look for the message text
			var contentTypeSearchIdx = -1;
			//if ((contentTypeSearchIdx = lastContentType.indexOf("text/plain")) > -1)
			if ((contentTypeSearchIdx = lastContentType.indexOf("text/")) > -1)
			{
				if (!gotMessageText)
				{
					var newlineIdx = pMsgText.indexOf("\n", msgTextSearchStartIdx); // Used to be lastContentTypeIdx
					if (newlineIdx > -1)
						retObj.msgText = pMsgText.substring(newlineIdx+1, sepIdx);
					else
						retObj.msgText = pMsgText.substring(lastSepIdx, sepIdx);
					gotMessageText = true;
				}
			}
			else
			{
				// Look for a filename in the content-type specification
				// If it doesn't contain the filename, then we'll have to look on the
				// next line for the filename.
				var attachmentFilename = "";
				var matches = /name="(.*)"/.exec(lastContentType);
				if (matches != null)
				{
					if (matches.length >= 2)
						attachmentFilename = matches[1];
				}
				if (attachmentFilename.length == 0)
				{
					// Look for the filename on the next line
					var newlineIdx = pMsgText.indexOf("\n", lastContentTypeIdx);
					if (newlineIdx > -1)
					{
						// 1000 chars should be enough
						var nextLine = pMsgText.substr(newlineIdx+1, 1000);
						var matches = /name="(.*)"/.exec(nextLine);
						if (matches != null)
						{
							if (matches.length >= 2)
								attachmentFilename = matches[1];
						}
					}
				}
				// If we got a filename, then extract the base64-encoded file data.
				if (attachmentFilename.length > 0)
				{
					var fileInfo = { filename: attachmentFilename,
					                 fullyPathedFilename: gFileAttachDir + attachmentFilename };
					// Only extract the base64-encoded data if getB64Data is true
					// and the current section's encoding type was actually specified
					// as base64.
					if (getB64Data && (contentEncodingType == "base64"))
					{
						// There should be 2 newlines before the base64 data
						// TODO: There's a bug here where sometimes it isn't getting
						// the correct section for base64 data.  The code later that
						// looks for an existing filename in the attachments is sort
						// of a way around that though.
						var lineSeparator = ascii(13) + ascii(10);
						var twoNLIdx = pMsgText.indexOf(lineSeparator + lineSeparator, lastContentTypeIdx);
						if (twoNLIdx > -1)
						{
							// Get the base64-encoded data for the current file from the message,
							// and remove the newline & carriage return characters and whitespace
							// from it.
							fileInfo.B64Data = pMsgText.substring(twoNLIdx+2, sepIdx);
							fileInfo.B64Data = fileInfo.B64Data.replace(new RegExp(ascii(13) + "|" + ascii(10), "g"), "").trim();

							// Update the start index for looking for the next message separator
							// string
							startIdx = twoNLIdx;
						}
					}
					// Add the file attachment information to the return object.
					// If there is already an entry with the filename, then replace
					// that one; otherwise, append it.
					var fileExists = false;
					for (var fileIdx = 0; (fileIdx < retObj.attachments.length) && !fileExists; ++fileIdx)
					{
						if (retObj.attachments[fileIdx].filename == fileInfo.filename)
						{
							fileExists = true;
							if (getB64Data && fileInfo.hasOwnProperty("B64Data"))
								retObj.attachments[fileIdx].B64Data = fileInfo.B64Data;
						}
					}
					if (!fileExists)
						retObj.attachments.push(fileInfo);
				}
			}
		}

		lastContentType = contentType;
		lastSepIdx = sepIdx;
		lastContentTypeIdx = contentTypeIdx;
		lastContentEncodingTypeIdx = contentEncodingTypeIdx;

		// The end of the message will have the message separator string with
		// "--" appended to it.  If we've reached that point, then we know we
		// can stop.
		if (pMsgText.substr(sepIdx, msgSeparator.length+2) == msgSeparator + "--")
			break;
	}

	// If there are any attachments, prepend the message text with a message
	// saying that the message contains attachments.
	if (retObj.attachments.length > 0)
		retObj.msgText = msgHasAttachmentsTxt + retObj.msgText;

	// If there are attachments and the message text is more than will fit on the
	// screen (75% of the console height to account for the ), then append text at
	// the end to say there are attachments.
	var maxNumCharsOnScreen = 79 * Math.floor(console.screen_rows * 0.75);
	if ((retObj.attachments.length > 0) && (retObj.msgText.length > maxNumCharsOnScreen))
	{
		retObj.msgText += "\1n\r\n\1g\1h--------------------------------------------------------------------------\1n\r\n";
		retObj.msgText += "\1g\1h- This message contains one or more attachments. Press CTRL-A to download.\1n";
	}

	return retObj;
}

// Allows the user to download files that were attached to a message.  Takes an
// array of file information given by determineMsgAttachments().
//
// Parameters:
//  pAttachments: An array of file attachment information returned by
//                determineMsgAttachments()
//                            for each attachment:
//                            B64Data: Base64-encoded file data - Only for attachments
//                                     that were attached as base64 in the message (i.e.,
//                                     in a multi-part MIME message).  If the attachment
//                                     was uploaded to the user's Synchronet mailbox,
//                                     then the object won't have the B64Data property.
//                            filename: The name of the attached file
//                            fullyPathedFilename: The full path & filename of the
//                                                 attached file saved on the BBS machine
function sendAttachedFiles(pAttachments)
{
	if (Object.prototype.toString.call(pAttachments) !== "[object Array]")
		return;

	// Synchronet doesn't allow batch downloading of files that aren't in the
	// file database, so we have to send each one at a time. :(

	// Get the file download confirmation text from text.dat
	// 662: "\r\nDownload attached file: \1w%s\1b (%s bytes)"
	var DLPromptTextOrig = bbs.text(DownloadAttachedFileQ);

	var anyErrors = false;
	// For each item in the array, allow the user to download the attachment.
	var fileNum = 1;
	pAttachments.forEach(function(fileInfo) {
		console.print("\1n");
		console.crlf();

		// If the file doesn't exist and base64 data is available for the file,
		// then save it to the temporary attachments directory.
		// Note that we need to save the file first in order to get the file's size
		// to display in the confirmation prompt to download the file.
		// errorMsg will contain an error if something went wrong creating the
		// temporary attachments directory, etc.
		var errorMsg = "";
		var savedFileToBBS = false; // If we base64-decoded the file, we'll want to delete it after it's sent.
		if (!file_exists(fileInfo.fullyPathedFilename))
		{
			if (fileInfo.hasOwnProperty("B64Data"))
			{
				// If the temporary attachments directory doesn't exist,
				// then create it.
				var attachmentDirExists = true; // Will be false if it couldn't be created
				if (!file_isdir(gFileAttachDir))
				{
					// If it's a file rather than a directory, then remove it
					// before creating it as a directory.
					if (file_exists(gFileAttachDir))
						file_remove(gFileAttachDir);
					attachmentDirExists = mkdir(gFileAttachDir);
				}

				// Write the file to the BBS machine
				if (attachmentDirExists)
				{
					var attachedFile = new File(fileInfo.fullyPathedFilename);
					if (attachedFile.open("wb"))
					{
						attachedFile.base64 = true;
						if (!attachedFile.write(fileInfo.B64Data))
							errorMsg = "\1h\1g* \1n\1cCan't send " + quoteStrWithSpaces(fileInfo.filename) + " - Failed to save it to the BBS!";
						attachedFile.close();
						// Saved the file to the temporary attachments directory (even if it failed
						// to write, there's probably still an empty file there).
						savedFileToBBS = true;
					}
					else
						errorMsg = "\1h\1g* \1n\1cFailed to save " + quoteStrWithSpaces(fileInfo.filename) + "!";
				}
				else
					errorMsg = "\1h\1g* \1n\1cFailed to create temporary directory on the BBS!";
			}
			else
				errorMsg = "\1h\1g* \1n\1cCan't send " + quoteStrWithSpaces(fileInfo.filename) + " because it doesn't exist or wasn't encoded in a known format";
		}
		// If we can send the file, then prompt the user for confirmation, and if they
		// answer yes, then send it.
		// Note that we needed to save the file first in order to get the file's size
		// to display in the confirmation prompt.
		if (errorMsg.length == 0)
		{
			// Print the file number
			console.print("\1n\1cFile \1g" + fileNum + "\1c of \1g" + pAttachments.length + "\1n");
			console.crlf();
			// Prompt the user to confirm whether they want to download the
			// file.  If the user chooses yes, then send it.
			var fileSize = Math.round(file_size(fileInfo.fullyPathedFilename));
			var DLPromptText = format(DLPromptTextOrig, fileInfo.filename, fileSize);
			if (console.yesno(DLPromptText))
				bbs.send_file(fileInfo.fullyPathedFilename);

			// If the file was base64-decoded and saved to the BBS machine (as opposed to
			// being in the user's mailbox), then delete the file.
			if (savedFileToBBS)
				file_remove(fileInfo.fullyPathedFilename);
		}
		else
		{
			// There was an error creating the temporary attachment directory, etc., so
			// display the error and pause to let the user read it.
			//console.print(errorMsg);
			//console.putmsg(word_wrap(errorMsg, console.screen_columns-1, errorMsg.length, false));
			//console.crlf();
			var errMsgLines = lfexpand(word_wrap(errorMsg, console.screen_columns-1, errorMsg.length, false)).split("\r\n");
			console.print("\1n");
			for (var errorIdx = 0; errorIdx < errMsgLines.length; ++errorIdx)
			{
				console.print(errMsgLines[errorIdx]);
				console.crlf();
			}
			console.pause();
		}

		++fileNum;
	});

	// If the temporary attachments directory exists, then delete it.
	if (file_exists(gFileAttachDir))
		deltree(gFileAttachDir);
}

// This function recursively removes a directory and all of its contents.  Returns
// whether or not the directory was removed.
//
// Parameters:
//  pDir: The directory to remove (with trailing slash).
//
// Return value: Boolean - Whether or not the directory was removed.
function deltree(pDir)
{
	if ((pDir == null) || (pDir == undefined))
		return false;
	if (typeof(pDir) != "string")
		return false;
	if (pDir.length == 0)
		return false;
	// Make sure pDir actually specifies a directory.
	if (!file_isdir(pDir))
		return false;
	// Don't wipe out a root directory.
	if ((pDir == "/") || (pDir == "\\") || (/:\\$/.test(pDir)) || (/:\/$/.test(pDir)) || (/:$/.test(pDir)))
		return false;

	// If we're on Windows, then use the "RD /S /Q" command to delete
	// the directory.  Otherwise, assume *nix and use "rm -rf" to
	// delete the directory.
	if (deltree.inWindows == undefined)
		deltree.inWindows = (/^WIN/.test(system.platform.toUpperCase()));
	if (deltree.inWindows)
		system.exec("RD " + withoutTrailingSlash(pDir) + " /s /q");
	else
		system.exec("rm -rf " + withoutTrailingSlash(pDir));
	// The directory should be gone, so we should return true.  I'd like to verify that the
	// directory really is gone, but file_exists() seems to return false for directories,
	// even if the directory does exist.  So I test to make sure no files are seen in the dir.
	return (directory(pDir + "*").length == 0);

	/*
	// Recursively deleting each file & dir using JavaScript:
	var retval = true;

	// Open the directory and delete each entry.
	var files = directory(pDir + "*");
	for (var i = 0; i < files.length; ++i)
	{
		// If the entry is a directory, then deltree it (Note: The entry
		// should have a trailing slash).  Otherwise, delete the file.
		// If the directory/file couldn't be removed, then break out