1. --!parent
  2. --!encoding=utf-8
  3.  
  4. --****************************************************************************
  5. -- @file Curl SMTP class
  6. -- @author Valentin Schmidt
  7. -- @version 0.3
  8. -- @requires Curl Xtra
  9. -- @requires fileio Xtra
  10. --****************************************************************************
  11.  
  12. property CRLF
  13. property CURLOPT
  14. property CURLUSESSL_ALL
  15.  
  16. property _uid_used
  17. property _base64Lookup
  18. property _user_agent
  19. property _tmp_dir
  20. property _host
  21. property _port
  22. property _smtp_url
  23.  
  24. ----------------------------------------
  25. --
  26. ----------------------------------------
  27. on new me
  28.     CRLF = numtochar(13) & numtochar(10)
  29.     CURLOPT = [:]
  30.     CURLOPT[#VERBOSE] = 41
  31.     CURLOPT[#URL] = 10002
  32.     CURLOPT[#MAIL_FROM] = 10186
  33.     CURLOPT[#MAIL_RCPT] = 10187
  34.     CURLOPT[#SSL_VERIFYHOST] = 81
  35.     CURLOPT[#SSL_VERIFYPEER] = 64
  36.     CURLOPT[#UPLOAD] = 46
  37.     CURLOPT[#USE_SSL] = 119
  38.     CURLUSESSL_ALL = 3
  39.  
  40.     me._uid_used = []
  41.     me._base64Lookup = [65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,48,49,50,51,52,53,54,55,56,57,43,47]
  42.     me._user_agent = "Curl SMTP Class/1.0"
  43.     me._tmp_dir = _movie.path -- in case of an installed app, use the user's TMP dir instead
  44.  
  45.     return me
  46. end
  47.  
  48. ----------------------------------------
  49. -- Sets the SMTP host and login data
  50. -- @param {string} host
  51. -- @param {integer} port - standard SMTP ports: 25=no security, 465=SSL/TLS, 587=STARTTLS
  52. -- @param {string} usr
  53. -- @param {string} pwd
  54. -- @param {boolean} [force_ssl=FALSE] - force SSL also for port <> 465
  55. ----------------------------------------
  56. on setHost (me, host, port, usr, pwd, force_ssl)
  57.     me._host = host
  58.     me._port = port
  59.     if port=465 or force_ssl then
  60.         me._smtp_url = "smtps://" & curl_escape(usr) & ":" & curl_escape(pwd) & "@" & host & ":" & string(port)
  61.     else
  62.         me._smtp_url = "smtp://" & curl_escape(usr) & ":" & curl_escape(pwd) & "@" & host & ":" & string(port)
  63.     end if
  64. end
  65.  
  66. ----------------------------------------
  67. -- Sends email with SMTP
  68. -- @param {string} from
  69. -- @param {string|list} recipients - single recipient as string or multiple recipients as list
  70. -- @param {string} subject
  71. -- @param {string} message_plain - the plain text part
  72. -- @param {string} [message_html=VOID] - the HTML part (optional)
  73. -- @param {list} [files_attached=VOID] - list of files attached to email (optional)
  74. -- @param {list} [files_inline=VOID] - list of files embedded into HTML part (optional)
  75. -- @return {string} curl error string, "No error" means success
  76. ----------------------------------------
  77. on sendMail (me, from, recipients, subject, message_plain, message_html, files_attached, files_inline)
  78.     if stringP(recipients) then recipients = [recipients]
  79.     if voidP(message_html) then message_html = ""
  80.     if voidP(files_inline) then files_inline = []
  81.     if voidP(files_attached) then files_attached = []
  82.     to_header = recipients[1]
  83.     repeat with i = 2 to recipients.count
  84.         put ", " & recipients[i] after to_header
  85.     end repeat
  86.  
  87.     -- create message, save as temporary file
  88.     tmp_file = me._tmp_dir & "~mail.eml"
  89.     fp = xtra("fileIO").new()
  90.     fp.openFile(tmp_file, 2)
  91.     if fp.status()=0 then fp.delete()
  92.     fp.createFile(tmp_file)
  93.     fp.openFile(tmp_file, 2)
  94.  
  95.     -- to keep the spam score as low as possible, we add both Date and Message-ID headers
  96.     fp.writeString("Date: " && me._createUTCDateString() & CRLF)
  97.     fp.writeString("Message-ID: <" & me._uid() & "@" & me._host & ">" & CRLF)
  98.  
  99.     fp.writeString("To: " & to_header & CRLF)
  100.     fp.writeString("From: " & from & CRLF)
  101.     fp.writeString("Subject: " & me._mimeEncode(subject) & CRLF)
  102.     fp.writeString("User-Agent: " & me._user_agent & CRLF) -- completely optional
  103.     fp.writeString("MIME-Version: 1.0" & CRLF)
  104.  
  105.     cnt_files_attached = count(files_attached)
  106.     cnt_files_inline = count(files_inline)
  107.  
  108.     if cnt_files_attached=0 and message_html="" then
  109.         fp.writeString("Content-Type: text/plain; charset=utf-8" & CRLF)
  110.         fp.writeString("Content-Transfer-Encoding: 8bit" & CRLF)
  111.         fp.writeString(CRLF)
  112.         fp.writeString(message_plain)
  113.     else
  114.         if cnt_files_attached then
  115.             boundary_mixed = "------------" & me._uid()
  116.  
  117.             fp.writeString("Content-Type: multipart/mixed; boundary=" & QUOTE & boundary_mixed & QUOTE & CRLF & CRLF)
  118.             fp.writeString("This is a multi-part message in MIME format." & CRLF)
  119.  
  120.             if message_html<>"" then
  121.                 -- files_attached and html
  122.                 fp.writeString("--" & boundary_mixed & CRLF)
  123.  
  124.                 boundary_alternative = "------------" & me._uid()
  125.                 fp.writeString("Content-Type: multipart/alternative; boundary=" & QUOTE & boundary_alternative & QUOTE & CRLF & CRLF)
  126.  
  127.                 me._writeMimePart(fp, "text/plain", message_plain, boundary_alternative)
  128.  
  129.                 if cnt_files_inline then
  130.                     fp.writeString("--" & boundary_alternative & CRLF)
  131.  
  132.                     boundary_related = "------------" & me._uid()
  133.                     fp.writeString("Content-Type: multipart/related; boundary=" & QUOTE & boundary_related & QUOTE & CRLF & CRLF)
  134.  
  135.                     content_ids = []
  136.                     repeat with i = 1 to cnt_files_inline
  137.                         content_ids[i] = "part" & i & "." & me._uid() & "@" & me._host
  138.                         pos = offset("{{" & i & "}}", message_html)
  139.                         if pos then
  140.                             message_html = message_html.char[1..pos-1] & "cid:" & content_ids[i] & message_html.char[pos+length("{{" & i & "}}")..message_html.length]
  141.                         end if
  142.                     end repeat
  143.  
  144.                     me._writeMimePart(fp, "text/html", message_html, boundary_related)
  145.  
  146.                     repeat with i = 1 to cnt_files_inline
  147.                         f = files_inline[i]
  148.                         fn = me._getFileName(f)
  149.                         fp.writeString("--" & boundary_related & CRLF)
  150.                         fp.writeString("Content-Type: " & me._mimeType(me._getFileType(f)) & "; name=" & QUOTE & fn & QUOTE & CRLF)
  151.                         fp.writeString("Content-Transfer-Encoding: base64" & CRLF)
  152.                         fp.writeString("Content-ID: <" & content_ids[i] & ">" & CRLF)
  153.                         fp.writeString("Content-Disposition: inline; filename=" & QUOTE & fn & QUOTE & CRLF & CRLF)
  154.                         fp.writeString(me._base64Encode(me._getBytes(f), TRUE) & CRLF & CRLF)
  155.                     end repeat
  156.  
  157.                     fp.writeString("--" & boundary_related & "--" & CRLF & CRLF)
  158.  
  159.                 else
  160.                     me._writeMimePart(fp, "text/html", message_html, boundary_alternative)
  161.                 end if
  162.  
  163.                 fp.writeString("--" & boundary_alternative & "--" & CRLF & CRLF)
  164.             else
  165.                 -- files_attached, no html
  166.                 me._writeMimePart(fp, "text/plain", message_plain, boundary_mixed)
  167.             end if
  168.  
  169.             repeat with f in files_attached
  170.                 fn = me._getFileName(f)
  171.                 fp.writeString("--" & boundary_mixed & CRLF)
  172.                 fp.writeString("Content-Type: " & me._mimeType(me._getFileType(f)) & "; name=" & QUOTE & fn & QUOTE & CRLF)
  173.                 fp.writeString("Content-Transfer-Encoding: base64" & CRLF)
  174.                 fp.writeString("Content-Disposition: attachment; filename=" & QUOTE & fn & QUOTE & CRLF & CRLF)
  175.                 fp.writeString(me._base64Encode(me._getBytes(f), TRUE) & CRLF & CRLF)
  176.             end repeat
  177.  
  178.             fp.writeString("--" & boundary_mixed & "--" & CRLF)
  179.         else
  180.             -- html, no files_attached
  181.             boundary_alternative = "------------" & me._uid()
  182.  
  183.             fp.writeString("Content-Type: multipart/alternative; boundary=" & QUOTE & boundary_alternative & QUOTE & CRLF & CRLF)
  184.             fp.writeString("This is a multi-part message in MIME format." & CRLF)
  185.  
  186.             me._writeMimePart(fp, "text/plain", message_plain, boundary_alternative)
  187.  
  188.             if cnt_files_inline then
  189.                 fp.writeString("--" & boundary_alternative & CRLF)
  190.  
  191.                 boundary_related = "------------" & me._uid()
  192.                 fp.writeString("Content-Type: multipart/related; boundary=" & QUOTE & boundary_related & QUOTE & CRLF & CRLF)
  193.  
  194.                 content_ids = []
  195.                 repeat with i = 1 to cnt_files_inline
  196.                     content_ids[i] = "part" & i & "." & me._uid() & "@" & me._host
  197.                     pos = offset("{{" & i & "}}", message_html)
  198.                     if pos then
  199.                         message_html = message_html.char[1..pos-1] & "cid:" & content_ids[i] & message_html.char[pos+length("{{" & i & "}}")..message_html.length]
  200.                     end if
  201.                 end repeat
  202.  
  203.                 me._writeMimePart(fp, "text/html", message_html, boundary_related)
  204.  
  205.                 repeat with i = 1 to cnt_files_inline
  206.                     f = files_inline[i]
  207.                     fn = me._getFileName(f)
  208.                     fp.writeString("--" & boundary_related & CRLF)
  209.                     fp.writeString("Content-Type: " & me._mimeType(me._getFileType(f)) & "; name=" & QUOTE & fn & QUOTE & CRLF)
  210.                     fp.writeString("Content-Transfer-Encoding: base64" & CRLF)
  211.                     fp.writeString("Content-ID: <" & content_ids[i] & ">" & CRLF)
  212.                     fp.writeString("Content-Disposition: inline; filename=" & QUOTE & fn & QUOTE & CRLF & CRLF)
  213.                     fp.writeString(me._base64Encode(me._getBytes(f), TRUE) & CRLF & CRLF)
  214.                 end repeat
  215.  
  216.                 fp.writeString("--" & boundary_related & "--" & CRLF & CRLF)
  217.             else
  218.                 me._writeMimePart(fp, "text/html", message_html, boundary_alternative)
  219.             end if
  220.  
  221.             fp.writeString("--" & boundary_alternative & "--" & CRLF)
  222.         end if
  223.  
  224.     end if
  225.  
  226.     fp.closeFile()
  227.  
  228.     -- get a CURL handle (xtra instance)
  229.     ch = xtra("Curl").new()
  230.  
  231.     -- specify options
  232.     --ch.setOption(CURLOPT.VERBOSE, 1) -- uncomment for debugging, prints to stdout
  233.     ch.setOption(CURLOPT.URL, me._smtp_url)
  234.     ch.setOption(CURLOPT.MAIL_FROM, from)
  235.     ch.setOption(CURLOPT.MAIL_RCPT, recipients)
  236.     if me._port = 587 then
  237.         ch.setOption(CURLOPT.USE_SSL, CURLUSESSL_ALL)
  238.     end if
  239.     ch.setOption(CURLOPT.SSL_VERIFYPEER, 0)
  240.     ch.setOption(CURLOPT.SSL_VERIFYHOST, 0)
  241.     ch.setOption(CURLOPT.UPLOAD, 1)
  242.     ch.setSourceFile(tmp_file)
  243.  
  244.     -- execute request
  245.     res = ch.exec()
  246.  
  247.     -- delete tmp file
  248.     fp.openFile(tmp_file, 0)
  249.     fp.delete()
  250.  
  251.     return curl_error(res)
  252. end
  253.  
  254. ----------------------------------------
  255. --
  256. ----------------------------------------
  257. on _writeMimePart (me, fp, content_type, str, boundary)
  258.     fp.writeString("--" & boundary & CRLF)
  259.     fp.writeString("Content-Type: " & content_type & "; charset=utf-8" & CRLF)
  260.     fp.writeString("Content-Transfer-Encoding: 8bit" & CRLF)
  261.     fp.writeString(CRLF)
  262.     fp.writeString(str)
  263.     fp.writeString(CRLF & CRLF)
  264. end
  265.  
  266. ----------------------------------------
  267. --
  268. ----------------------------------------
  269. on _mimeEncode (me, str)
  270.     return "=?UTF-8?B?" & me._base64Encode(str) & "?="
  271. end
  272.  
  273. ----------------------------------------
  274. --
  275. ----------------------------------------
  276. on _mimeType (me, t)
  277.     case t of
  278.         "3gp": return "video/3gpp"
  279.         "3g2": return "video/3gpp2"
  280.         "7z": return "application/x-7z-compressed"
  281.         "aac": return "audio/aac"
  282.         "abw": return "application/x-abiword"
  283.         "aif", "aiff": return "audio/x-aiff"
  284.         "arc": return "application/octet-stream"
  285.         "avi": return "video/x-msvideo"
  286.         "azw": return "application/vnd.amazon.ebook"
  287.         "bin": return "application/octet-stream"
  288.         "bmp": return "image/bmp"
  289.         "bz": return "application/x-bzip"
  290.         "bz2": return "application/x-bzip2"
  291.         "csh": return "application/x-csh"
  292.         "css": return "text/css"
  293.         "csv": return "text/csv"
  294.         "dcr", "dir", "dxr": return "application/x-director"
  295.         "doc": return "application/msword"
  296.         "docx": return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
  297.         "eot": return "application/vnd.ms-fontobject"
  298.         "epub": return "application/epub+zip"
  299.         "gif": return "image/gif"
  300.         "gz": return "application/gzip"
  301.         "htm", "html": return "text/html"
  302.         "ico": return "image/x-icon"
  303.         "ics": return "text/calendar"
  304.         "jar": return "application/java-archive"
  305.         "jpeg", "jpg": return "image/jpeg"
  306.         "js": return "application/javascript"
  307.         "json": return "application/json"
  308.         "mid", "midi": return "audio/midi"
  309.         "mov": return "video/quicktime"
  310.         "mp3": return "audio/mpeg"
  311.         "mp4": return "video/mp4"
  312.         "mpg", "mpeg": return "video/mpeg"
  313.         "mpkg": return "application/vnd.apple.installer+xml"
  314.         "odp": return "application/vnd.oasis.opendocument.presentation"
  315.         "ods": return "application/vnd.oasis.opendocument.spreadsheet"
  316.         "odt": return "application/vnd.oasis.opendocument.text"
  317.         "oga","ogg": return "audio/ogg"
  318.         "ogv": return "video/ogg"
  319.         "otf": return "font/otf"
  320.         "png": return "image/png"
  321.         "pdf": return "application/pdf"
  322.         "ppt": return "application/vnd.ms-powerpoint"
  323.         "pptx": return "application/vnd.openxmlformats-officedocument.presentationml.presentation"
  324.         "rar": return "application/x-rar-compressed"
  325.         "rtf": return "application/rtf"
  326.         "sh": return "application/x-sh"
  327.         "svg": return "image/svg+xml"
  328.         "swf": return "application/x-shockwave-flash"
  329.         "tar": return "application/x-tar"
  330.         "tif", "tiff": return "image/tiff"
  331.         "ts": return "application/typescript"
  332.         "ttf": return "font/ttf"
  333.         "txt": return "text/plain"
  334.         "vsd": return "application/vnd.visio"
  335.         "wav": return "audio/wav"
  336.         "weba": return "audio/webm"
  337.         "webm": return "video/webm"
  338.         "webp": return "image/webp"
  339.         "woff": return "font/woff"
  340.         "woff2": return "font/woff2"
  341.         "xhtml": return "application/xhtml+xml"
  342.         "xls": return "application/vnd.ms-excel"
  343.         "xlsx": return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
  344.         "xml": return "application/xml"
  345.         "zip": return "application/zip"
  346.     end case
  347.     return "application/octet-stream"
  348. end
  349.  
  350. ----------------------------------------
  351. -- Returns a session unqiue id
  352. ----------------------------------------
  353. on _uid (me)
  354.     repeat while TRUE
  355.         uid = random(the maxInteger) & "-" & random(the maxInteger)
  356.         if not me._uid_used.getPos(uid) then
  357.             me._uid_used.add(uid)
  358.             return uid
  359.         end if
  360.     end repeat
  361. end
  362.  
  363. ----------------------------------------
  364. -- Thu, 31 Aug 2017 19:07:29 +0200
  365. -- Sun, 07 Jul 2019 11:36:30 GMT
  366. ----------------------------------------
  367. on _createUTCDateString (me, tDateObj)
  368.     if voidP(tDateObj) then tDateObj = the systemdate
  369.     tDateObj.seconds = tDateObj.seconds - 2*3600
  370.     res = ""
  371.     put me._getDayOfWeek(tDateObj) & ", " after res
  372.     if tDateObj.day<10 then put "0" after res
  373.     put tDateObj.day & SPACE after res
  374.     put me._getMonth(tDateObj) & SPACE after res
  375.     put tDateObj.year & SPACE after res
  376.     put me._dateToTimeString(tDateObj) & SPACE after res
  377.     put "GMT" after res -- @TODO add timezone support
  378.     return res
  379. end
  380.  
  381. ----------------------------------------
  382. -- Returns day of week of specified date object as 3-letter abbrev (e.g. "Mon")
  383. -- @param {date} tDateObj
  384. -- @return {string} day
  385. ----------------------------------------
  386. on _getDayOfWeek (me, tDateObj)
  387.     refDateObj = date(1905,1,2)
  388.     currentDayNum = ((tDateObj - refDateObj) mod 7) + 1
  389.     return ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"][currentDayNum]
  390. end
  391.  
  392. ----------------------------------------
  393. -- Returns month of specified date object as 3-letter abbrev (e.g. "Jan")
  394. -- @param {date} tDateObj
  395. -- @return {string} month
  396. ----------------------------------------
  397. on _getMonth (me, tDateObj)
  398.     monthNum = tDateObj.month
  399.     return ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][monthNum]
  400. end
  401.  
  402. ----------------------------------------
  403. -- Returns (current) time as HH:MM:II string
  404. -- @param {date} [tDateObj=void]
  405. -- @returns {string}
  406. ----------------------------------------
  407. on _dateToTimeString (me, tDateObj)
  408.     if voidP(tDateObj) then tDateObj = the systemdate
  409.     sec = tDateObj.seconds
  410.     str = ""
  411.     s = string(sec / 3600)
  412.     sec = sec mod 3600
  413.     if s.length<2 then put "0" before s
  414.     put s&":" after str
  415.     s = string(sec / 60)
  416.     sec = sec mod 60
  417.     if s.length<2 then put "0" before s
  418.     put s after str
  419.     s = string(sec)
  420.     if s.length<2 then put "0" before s
  421.     put ":"&s after str
  422.     return str
  423. end
  424.  
  425. ----------------------------------------
  426. -- Extracts filename from path
  427. -- @param {string} tPath
  428. -- @return {string}
  429. ----------------------------------------
  430. on _getFileName (me, tPath)
  431.     if tPath contains "/" then pd = "/"
  432.     else pd = the last char of _movie.path
  433.     od = the itemdelimiter
  434.     the itemdelimiter = pd
  435.     tFileName = the last item of tPath
  436.     the itemdelimiter = od
  437.     return tFileName
  438. end
  439.  
  440. ----------------------------------------
  441. -- Returns file type
  442. -- @param {string} tFilename
  443. -- @return {string}
  444. ----------------------------------------
  445. on _getFileType (me, tFilename)
  446.     od = the itemdelimiter
  447.     the itemdelimiter = "."
  448.     if tFilename.item.count=1 then
  449.         the itemdelimiter = od
  450.         return ""
  451.     end if
  452.     tType = the last item of tFilename
  453.     the itemdelimiter = od
  454.     -- to lower
  455.     alphabet = "abcdefghijklmnopqrstuvwxyz"
  456.     cnt = tType.length
  457.     repeat with i = 1 to cnt
  458.         pos = offset(tType.char[i],alphabet)
  459.         if pos > 0 then put alphabet.char[pos] into char i of tType
  460.     end repeat
  461.     return tType
  462. end
  463.  
  464. ----------------------------------------
  465. -- Returns file as ByteArray
  466. -- @param {string} tFile
  467. -- @return {byteArray|FALSE}
  468. ----------------------------------------
  469. on _getBytes (me, tFile)
  470.     fp = xtra("fileIO").new()
  471.     fp.openFile(tFile, 1)
  472.     err = fp.status()
  473.     if (err) then return FALSE
  474.     len = fp.getLength()
  475.     if len then
  476.         data = fp.readByteArray(len)
  477.     else
  478.         data = bytearray()
  479.     end if
  480.     fp.closeFile()
  481.     fp = 0
  482.     return data
  483. end
  484.  
  485. ----------------------------------------
  486. -- Encodes ByteArray as ASCII Base64 string
  487. -- @param {bytearray|string} baData
  488. -- @param {bool} [splitChunks=FALSE]
  489. -- @return {string}
  490. ----------------------------------------
  491. on _base64Encode (me, baData, splitChunks)
  492.     if stringP(baData) then baData = byteArray(baData)
  493.     baOut = byteArray()
  494.     i=1
  495.     strRemainder = baData.length mod 3
  496.     strLength = baData.length - strRemainder
  497.     repeat while i < strLength
  498.         a = baData[i]
  499.         b = baData[i+1]
  500.         c = baData[i+2]
  501.         byte1 = me._base64Lookup[ ( bitAnd( a, 252 ) / 4 ) + 1]
  502.         byte2 = me._base64Lookup[ ( bitAnd( (a*256)+b, 1008 ) / 16 ) + 1]
  503.         byte3 = me._base64Lookup[ ( bitAnd( (b*256)+c, 4032 ) / 64 ) + 1]
  504.         byte4 = me._base64Lookup[ ( bitAnd( c, 63 ) ) + 1]
  505.         baOut.writeInt32( byte1 + byte2*256 + byte3*65536 + byte4*16777216)
  506.         i = i + 3
  507.     end repeat
  508.     if( strRemainder = 1 ) then
  509.         a = baData[i]
  510.         baOut.writeInt8( me._base64Lookup[ ( bitAnd( a, 252 ) / 4 ) + 1] )
  511.         baOut.writeInt8( me._base64Lookup[ ( bitAnd( a*256, 1008 ) / 16 ) + 1] )
  512.         baOut.writeRawString( "==", 2)
  513.     else if ( strRemainder = 2 ) then
  514.         a = baData[i]
  515.         b = baData[i+1]
  516.         baOut.writeInt8( me._base64Lookup[ ( bitAnd( a, 252 ) / 4 ) + 1] )
  517.         baOut.writeInt8( me._base64Lookup[ ( bitAnd( (a*256)+b, 1008 ) / 16 ) + 1] )
  518.         baOut.writeInt8( me._base64Lookup[ ( bitAnd( b*256, 4032 ) / 64 ) + 1] )
  519.         baOut.writeRawString( "=", 1)
  520.     end if
  521.     baOut.position = 1
  522.  
  523.     -- break into lines of 72 chars
  524.     if splitChunks then
  525.         baOut2 = byteArray()
  526.         len = baOut.length
  527.         cnt = len/72
  528.         repeat with i = 1 to cnt
  529.             baOut2.writeRawString(baOut.readRawString(72), 72)
  530.             baOut2.writeRawString(CRLF, 2)
  531.         end repeat
  532.         if baOut.bytesRemaining>0 then
  533.             str = baOut.readRawString(baOut.bytesRemaining)
  534.             baOut2.writeRawString(str, str.length)
  535.         end if
  536.         baOut = baOut2
  537.         baOut.position = 1
  538.     end if
  539.  
  540.     return baOut.readRawString( baOut.length )
  541. end
  542.  
[raw code]