--!parent
--!encoding=utf-8
--****************************************************************************
-- @file Curl SMTP class
-- @author Valentin Schmidt
-- @version 0.3
-- @requires Curl Xtra
-- @requires fileio Xtra
--****************************************************************************
property CRLF
property CURLOPT
property CURLUSESSL_ALL
property _uid_used
property _base64Lookup
property _user_agent
property _tmp_dir
property _host
property _port
property _smtp_url
----------------------------------------
--
----------------------------------------
on new me
CRLF = numtochar(13) & numtochar(10)
CURLOPT = [:]
CURLOPT[#VERBOSE] = 41
CURLOPT[#URL] = 10002
CURLOPT[#MAIL_FROM] = 10186
CURLOPT[#MAIL_RCPT] = 10187
CURLOPT[#SSL_VERIFYHOST] = 81
CURLOPT[#SSL_VERIFYPEER] = 64
CURLOPT[#UPLOAD] = 46
CURLOPT[#USE_SSL] = 119
CURLUSESSL_ALL = 3
me._uid_used = []
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]
me._user_agent = "Curl SMTP Class/1.0"
me._tmp_dir = _movie.path -- in case of an installed app, use the user's TMP dir instead
return me
end
----------------------------------------
-- Sets the SMTP host and login data
-- @param {string} host
-- @param {integer} port - standard SMTP ports: 25=no security, 465=SSL/TLS, 587=STARTTLS
-- @param {string} usr
-- @param {string} pwd
-- @param {boolean} [force_ssl=FALSE] - force SSL also for port <> 465
----------------------------------------
on setHost (me, host, port, usr, pwd, force_ssl)
me._host = host
me._port = port
if port=465 or force_ssl then
me._smtp_url = "smtps://" & curl_escape(usr) & ":" & curl_escape(pwd) & "@" & host & ":" & string(port)
else
me._smtp_url = "smtp://" & curl_escape(usr) & ":" & curl_escape(pwd) & "@" & host & ":" & string(port)
end if
end
----------------------------------------
-- Sends email with SMTP
-- @param {string} from
-- @param {string|list} recipients - single recipient as string or multiple recipients as list
-- @param {string} subject
-- @param {string} message_plain - the plain text part
-- @param {string} [message_html=VOID] - the HTML part (optional)
-- @param {list} [files_attached=VOID] - list of files attached to email (optional)
-- @param {list} [files_inline=VOID] - list of files embedded into HTML part (optional)
-- @return {string} curl error string, "No error" means success
----------------------------------------
on sendMail (me, from, recipients, subject, message_plain, message_html, files_attached, files_inline)
if stringP(recipients) then recipients = [recipients]
if voidP(message_html) then message_html = ""
if voidP(files_inline) then files_inline = []
if voidP(files_attached) then files_attached = []
to_header = recipients[1]
repeat with i = 2 to recipients.count
put ", " & recipients[i] after to_header
end repeat
-- create message, save as temporary file
tmp_file = me._tmp_dir & "~mail.eml"
fp = xtra("fileIO").new()
fp.openFile(tmp_file, 2)
if fp.status()=0 then fp.delete()
fp.createFile(tmp_file)
fp.openFile(tmp_file, 2)
-- to keep the spam score as low as possible, we add both Date and Message-ID headers
fp.writeString("Date: " && me._createUTCDateString() & CRLF)
fp.writeString("Message-ID: <" & me._uid() & "@" & me._host & ">" & CRLF)
fp.writeString("To: " & to_header & CRLF)
fp.writeString("From: " & from & CRLF)
fp.writeString("Subject: " & me._mimeEncode(subject) & CRLF)
fp.writeString("User-Agent: " & me._user_agent & CRLF) -- completely optional
fp.writeString("MIME-Version: 1.0" & CRLF)
cnt_files_attached = count(files_attached)
cnt_files_inline = count(files_inline)
if cnt_files_attached=0 and message_html="" then
fp.writeString("Content-Type: text/plain; charset=utf-8" & CRLF)
fp.writeString("Content-Transfer-Encoding: 8bit" & CRLF)
fp.writeString(CRLF)
fp.writeString(message_plain)
else
if cnt_files_attached then
boundary_mixed = "------------" & me._uid()
fp.writeString("Content-Type: multipart/mixed; boundary=" & QUOTE & boundary_mixed & QUOTE & CRLF & CRLF)
fp.writeString("This is a multi-part message in MIME format." & CRLF)
if message_html<>"" then
-- files_attached and html
fp.writeString("--" & boundary_mixed & CRLF)
boundary_alternative = "------------" & me._uid()
fp.writeString("Content-Type: multipart/alternative; boundary=" & QUOTE & boundary_alternative & QUOTE & CRLF & CRLF)
me._writeMimePart(fp, "text/plain", message_plain, boundary_alternative)
if cnt_files_inline then
fp.writeString("--" & boundary_alternative & CRLF)
boundary_related = "------------" & me._uid()
fp.writeString("Content-Type: multipart/related; boundary=" & QUOTE & boundary_related & QUOTE & CRLF & CRLF)
content_ids = []
repeat with i = 1 to cnt_files_inline
content_ids[i] = "part" & i & "." & me._uid() & "@" & me._host
pos = offset("{{" & i & "}}", message_html)
if pos then
message_html = message_html.char[1..pos-1] & "cid:" & content_ids[i] & message_html.char[pos+length("{{" & i & "}}")..message_html.length]
end if
end repeat
me._writeMimePart(fp, "text/html", message_html, boundary_related)
repeat with i = 1 to cnt_files_inline
f = files_inline[i]
fn = me._getFileName(f)
fp.writeString("--" & boundary_related & CRLF)
fp.writeString("Content-Type: " & me._mimeType(me._getFileType(f)) & "; name=" & QUOTE & fn & QUOTE & CRLF)
fp.writeString("Content-Transfer-Encoding: base64" & CRLF)
fp.writeString("Content-ID: <" & content_ids[i] & ">" & CRLF)
fp.writeString("Content-Disposition: inline; filename=" & QUOTE & fn & QUOTE & CRLF & CRLF)
fp.writeString(me._base64Encode(me._getBytes(f), TRUE) & CRLF & CRLF)
end repeat
fp.writeString("--" & boundary_related & "--" & CRLF & CRLF)
else
me._writeMimePart(fp, "text/html", message_html, boundary_alternative)
end if
fp.writeString("--" & boundary_alternative & "--" & CRLF & CRLF)
else
-- files_attached, no html
me._writeMimePart(fp, "text/plain", message_plain, boundary_mixed)
end if
repeat with f in files_attached
fn = me._getFileName(f)
fp.writeString("--" & boundary_mixed & CRLF)
fp.writeString("Content-Type: " & me._mimeType(me._getFileType(f)) & "; name=" & QUOTE & fn & QUOTE & CRLF)
fp.writeString("Content-Transfer-Encoding: base64" & CRLF)
fp.writeString("Content-Disposition: attachment; filename=" & QUOTE & fn & QUOTE & CRLF & CRLF)
fp.writeString(me._base64Encode(me._getBytes(f), TRUE) & CRLF & CRLF)
end repeat
fp.writeString("--" & boundary_mixed & "--" & CRLF)
else
-- html, no files_attached
boundary_alternative = "------------" & me._uid()
fp.writeString("Content-Type: multipart/alternative; boundary=" & QUOTE & boundary_alternative & QUOTE & CRLF & CRLF)
fp.writeString("This is a multi-part message in MIME format." & CRLF)
me._writeMimePart(fp, "text/plain", message_plain, boundary_alternative)
if cnt_files_inline then
fp.writeString("--" & boundary_alternative & CRLF)
boundary_related = "------------" & me._uid()
fp.writeString("Content-Type: multipart/related; boundary=" & QUOTE & boundary_related & QUOTE & CRLF & CRLF)
content_ids = []
repeat with i = 1 to cnt_files_inline
content_ids[i] = "part" & i & "." & me._uid() & "@" & me._host
pos = offset("{{" & i & "}}", message_html)
if pos then
message_html = message_html.char[1..pos-1] & "cid:" & content_ids[i] & message_html.char[pos+length("{{" & i & "}}")..message_html.length]
end if
end repeat
me._writeMimePart(fp, "text/html", message_html, boundary_related)
repeat with i = 1 to cnt_files_inline
f = files_inline[i]
fn = me._getFileName(f)
fp.writeString("--" & boundary_related & CRLF)
fp.writeString("Content-Type: " & me._mimeType(me._getFileType(f)) & "; name=" & QUOTE & fn & QUOTE & CRLF)
fp.writeString("Content-Transfer-Encoding: base64" & CRLF)
fp.writeString("Content-ID: <" & content_ids[i] & ">" & CRLF)
fp.writeString("Content-Disposition: inline; filename=" & QUOTE & fn & QUOTE & CRLF & CRLF)
fp.writeString(me._base64Encode(me._getBytes(f), TRUE) & CRLF & CRLF)
end repeat
fp.writeString("--" & boundary_related & "--" & CRLF & CRLF)
else
me._writeMimePart(fp, "text/html", message_html, boundary_alternative)
end if
fp.writeString("--" & boundary_alternative & "--" & CRLF)
end if
end if
fp.closeFile()
-- get a CURL handle (xtra instance)
ch = xtra("Curl").new()
-- specify options
--ch.setOption(CURLOPT.VERBOSE, 1) -- uncomment for debugging, prints to stdout
ch.setOption(CURLOPT.URL, me._smtp_url)
ch.setOption(CURLOPT.MAIL_FROM, from)
ch.setOption(CURLOPT.MAIL_RCPT, recipients)
if me._port = 587 then
ch.setOption(CURLOPT.USE_SSL, CURLUSESSL_ALL)
end if
ch.setOption(CURLOPT.SSL_VERIFYPEER, 0)
ch.setOption(CURLOPT.SSL_VERIFYHOST, 0)
ch.setOption(CURLOPT.UPLOAD, 1)
ch.setSourceFile(tmp_file)
-- execute request
res = ch.exec()
-- delete tmp file
fp.openFile(tmp_file, 0)
fp.delete()
return curl_error(res)
end
----------------------------------------
--
----------------------------------------
on _writeMimePart (me, fp, content_type, str, boundary)
fp.writeString("--" & boundary & CRLF)
fp.writeString("Content-Type: " & content_type & "; charset=utf-8" & CRLF)
fp.writeString("Content-Transfer-Encoding: 8bit" & CRLF)
fp.writeString(CRLF)
fp.writeString(str)
fp.writeString(CRLF & CRLF)
end
----------------------------------------
--
----------------------------------------
on _mimeEncode (me, str)
return "=?UTF-8?B?" & me._base64Encode(str) & "?="
end
----------------------------------------
--
----------------------------------------
on _mimeType (me, t)
case t of
"3gp": return "video/3gpp"
"3g2": return "video/3gpp2"
"7z": return "application/x-7z-compressed"
"aac": return "audio/aac"
"abw": return "application/x-abiword"
"aif", "aiff": return "audio/x-aiff"
"arc": return "application/octet-stream"
"avi": return "video/x-msvideo"
"azw": return "application/vnd.amazon.ebook"
"bin": return "application/octet-stream"
"bmp": return "image/bmp"
"bz": return "application/x-bzip"
"bz2": return "application/x-bzip2"
"csh": return "application/x-csh"
"css": return "text/css"
"csv": return "text/csv"
"dcr", "dir", "dxr": return "application/x-director"
"doc": return "application/msword"
"docx": return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
"eot": return "application/vnd.ms-fontobject"
"epub": return "application/epub+zip"
"gif": return "image/gif"
"gz": return "application/gzip"
"htm", "html": return "text/html"
"ico": return "image/x-icon"
"ics": return "text/calendar"
"jar": return "application/java-archive"
"jpeg", "jpg": return "image/jpeg"
"js": return "application/javascript"
"json": return "application/json"
"mid", "midi": return "audio/midi"
"mov": return "video/quicktime"
"mp3": return "audio/mpeg"
"mp4": return "video/mp4"
"mpg", "mpeg": return "video/mpeg"
"mpkg": return "application/vnd.apple.installer+xml"
"odp": return "application/vnd.oasis.opendocument.presentation"
"ods": return "application/vnd.oasis.opendocument.spreadsheet"
"odt": return "application/vnd.oasis.opendocument.text"
"oga","ogg": return "audio/ogg"
"ogv": return "video/ogg"
"otf": return "font/otf"
"png": return "image/png"
"pdf": return "application/pdf"
"ppt": return "application/vnd.ms-powerpoint"
"pptx": return "application/vnd.openxmlformats-officedocument.presentationml.presentation"
"rar": return "application/x-rar-compressed"
"rtf": return "application/rtf"
"sh": return "application/x-sh"
"svg": return "image/svg+xml"
"swf": return "application/x-shockwave-flash"
"tar": return "application/x-tar"
"tif", "tiff": return "image/tiff"
"ts": return "application/typescript"
"ttf": return "font/ttf"
"txt": return "text/plain"
"vsd": return "application/vnd.visio"
"wav": return "audio/wav"
"weba": return "audio/webm"
"webm": return "video/webm"
"webp": return "image/webp"
"woff": return "font/woff"
"woff2": return "font/woff2"
"xhtml": return "application/xhtml+xml"
"xls": return "application/vnd.ms-excel"
"xlsx": return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
"xml": return "application/xml"
"zip": return "application/zip"
end case
return "application/octet-stream"
end
----------------------------------------
-- Returns a session unqiue id
----------------------------------------
on _uid (me)
repeat while TRUE
uid = random(the maxInteger) & "-" & random(the maxInteger)
if not me._uid_used.getPos(uid) then
me._uid_used.add(uid)
return uid
end if
end repeat
end
----------------------------------------
-- Thu, 31 Aug 2017 19:07:29 +0200
-- Sun, 07 Jul 2019 11:36:30 GMT
----------------------------------------
on _createUTCDateString (me, tDateObj)
if voidP(tDateObj) then tDateObj = the systemdate
tDateObj.seconds = tDateObj.seconds - 2*3600
res = ""
put me._getDayOfWeek(tDateObj) & ", " after res
if tDateObj.day<10 then put "0" after res
put tDateObj.day & SPACE after res
put me._getMonth(tDateObj) & SPACE after res
put tDateObj.year & SPACE after res
put me._dateToTimeString(tDateObj) & SPACE after res
put "GMT" after res -- @TODO add timezone support
return res
end
----------------------------------------
-- Returns day of week of specified date object as 3-letter abbrev (e.g. "Mon")
-- @param {date} tDateObj
-- @return {string} day
----------------------------------------
on _getDayOfWeek (me, tDateObj)
refDateObj = date(1905,1,2)
currentDayNum = ((tDateObj - refDateObj) mod 7) + 1
return ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"][currentDayNum]
end
----------------------------------------
-- Returns month of specified date object as 3-letter abbrev (e.g. "Jan")
-- @param {date} tDateObj
-- @return {string} month
----------------------------------------
on _getMonth (me, tDateObj)
monthNum = tDateObj.month
return ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][monthNum]
end
----------------------------------------
-- Returns (current) time as HH:MM:II string
-- @param {date} [tDateObj=void]
-- @returns {string}
----------------------------------------
on _dateToTimeString (me, tDateObj)
if voidP(tDateObj) then tDateObj = the systemdate
sec = tDateObj.seconds
str = ""
s = string(sec / 3600)
sec = sec mod 3600
if s.length<2 then put "0" before s
put s&":" after str
s = string(sec / 60)
sec = sec mod 60
if s.length<2 then put "0" before s
put s after str
s = string(sec)
if s.length<2 then put "0" before s
put ":"&s after str
return str
end
----------------------------------------
-- Extracts filename from path
-- @param {string} tPath
-- @return {string}
----------------------------------------
on _getFileName (me, tPath)
if tPath contains "/" then pd = "/"
else pd = the last char of _movie.path
od = the itemdelimiter
the itemdelimiter = pd
tFileName = the last item of tPath
the itemdelimiter = od
return tFileName
end
----------------------------------------
-- Returns file type
-- @param {string} tFilename
-- @return {string}
----------------------------------------
on _getFileType (me, tFilename)
od = the itemdelimiter
the itemdelimiter = "."
if tFilename.item.count=1 then
the itemdelimiter = od
return ""
end if
tType = the last item of tFilename
the itemdelimiter = od
-- to lower
alphabet = "abcdefghijklmnopqrstuvwxyz"
cnt = tType.length
repeat with i = 1 to cnt
pos = offset(tType.char[i],alphabet)
if pos > 0 then put alphabet.char[pos] into char i of tType
end repeat
return tType
end
----------------------------------------
-- Returns file as ByteArray
-- @param {string} tFile
-- @return {byteArray|FALSE}
----------------------------------------
on _getBytes (me, tFile)
fp = xtra("fileIO").new()
fp.openFile(tFile, 1)
err = fp.status()
if (err) then return FALSE
len = fp.getLength()
if len then
data = fp.readByteArray(len)
else
data = bytearray()
end if
fp.closeFile()
fp = 0
return data
end
----------------------------------------
-- Encodes ByteArray as ASCII Base64 string
-- @param {bytearray|string} baData
-- @param {bool} [splitChunks=FALSE]
-- @return {string}
----------------------------------------
on _base64Encode (me, baData, splitChunks)
if stringP(baData) then baData = byteArray(baData)
baOut = byteArray()
i=1
strRemainder = baData.length mod 3
strLength = baData.length - strRemainder
repeat while i < strLength
a = baData[i]
b = baData[i+1]
c = baData[i+2]
byte1 = me._base64Lookup[ ( bitAnd( a, 252 ) / 4 ) + 1]
byte2 = me._base64Lookup[ ( bitAnd( (a*256)+b, 1008 ) / 16 ) + 1]
byte3 = me._base64Lookup[ ( bitAnd( (b*256)+c, 4032 ) / 64 ) + 1]
byte4 = me._base64Lookup[ ( bitAnd( c, 63 ) ) + 1]
baOut.writeInt32( byte1 + byte2*256 + byte3*65536 + byte4*16777216)
i = i + 3
end repeat
if( strRemainder = 1 ) then
a = baData[i]
baOut.writeInt8( me._base64Lookup[ ( bitAnd( a, 252 ) / 4 ) + 1] )
baOut.writeInt8( me._base64Lookup[ ( bitAnd( a*256, 1008 ) / 16 ) + 1] )
baOut.writeRawString( "==", 2)
else if ( strRemainder = 2 ) then
a = baData[i]
b = baData[i+1]
baOut.writeInt8( me._base64Lookup[ ( bitAnd( a, 252 ) / 4 ) + 1] )
baOut.writeInt8( me._base64Lookup[ ( bitAnd( (a*256)+b, 1008 ) / 16 ) + 1] )
baOut.writeInt8( me._base64Lookup[ ( bitAnd( b*256, 4032 ) / 64 ) + 1] )
baOut.writeRawString( "=", 1)
end if
baOut.position = 1
-- break into lines of 72 chars
if splitChunks then
baOut2 = byteArray()
len = baOut.length
cnt = len/72
repeat with i = 1 to cnt
baOut2.writeRawString(baOut.readRawString(72), 72)
baOut2.writeRawString(CRLF, 2)
end repeat
if baOut.bytesRemaining>0 then
str = baOut.readRawString(baOut.bytesRemaining)
baOut2.writeRawString(str, str.length)
end if
baOut = baOut2
baOut.position = 1
end if
return baOut.readRawString( baOut.length )
end