Sending Multi-Part Messages with %Net.SMTP

Introduction
It is probably true to say electronic mail (e-mail) was the 'killer' application of the eighties and is now used by most people in their working lives.

Originally a text-only communications medium, e-mail has been extended to carry multi-media content attachments with the Multipurpose Internet Mail Extensions (MIME) standard. Although originally derided for use with e-mail, HTML mark-up is now commonly used in e-mail messages.

However, when sending an e-mail with HTML mark-up, it is usually good practice to include a plain text version as well, because some e-mail clients may not be able to read the HTML (e.g. mobile phones). When building an e-mail message with multiple formats, typically plain text and HTML, the simplest format should be first, for e-mail clients that cannot choose the 'best' representation. In addition, creating an HTML e-mail is not without complications, but there are various templates available to simplify this task, see some example links below.

www.campaignmonitor.com/templates www.mailchimp.com/resources/html-email-templates

Another complication with HTML messages is images, because they are referenced as a separate resource (i.e. the image data is not contained within the mark-up). The Internet Engineering Task Force (IETF), however, has provided a URL scheme to allow for embedded references to point to other parts of the same message, see 'RFC2392' for more information.

Sample Content
This sample illustrates an e-mail with two messages (in different formats), one that includes an image link that is embedded in the same e-mail.

From: Andrew Neil Other  Date: Tue, 4 Oct 2011 17:49:53 +0100 Subject: Multi-part message with embedded image To: Cache Wiki  Content-Type: multipart/related; boundary=bcaec50162ab57e3e104ae7be259

--bcaec50162ab57e3e104ae7be259 Content-Type: multipart/alternative; boundary=bcaec50162ab57e3df04ae7be258

--bcaec50162ab57e3df04ae7be258 Content-Type: text/plain; charset=ISO-8859-1

This is a plain text message

--bcaec50162ab57e3df04ae7be258 Content-Type: text/html; charset=ISO-8859-1 Content-Transfer-Encoding: quoted-printable

 This is a html formatted message     

--bcaec50162ab57e3df04ae7be258-- --bcaec50162ab57e3e104ae7be259 Content-Type: image/png; name="image.png" Content-Transfer-Encoding: base64 Content-ID: <4B427437-D152-4114-A449-EE582A30B6C0> X-Attachment-Id: edd1357439bb6fa2_0.1.1

iVBORw0KGgoAAAANSUhEUgAAAUAAAAHgCAYAAADUjLREAAAgAElEQVR4AWy9aZclyXGm53nz3ty3 qszaq7uxNDaBIAmCHFHSnCON/py+6J/og/RBR3O0c46GpIb7ACTQABq9VXftue8376LneS0iqzBH ... ... xPkkOIMBQ0DTCYoShTOd3hB/ZxcPuHGnSVxcYqjHeBLH58gVwzkQi/nJY6xixyy9/H8noEn59N7j EAAAAABJRU5ErkJggg== --bcaec50162ab57e3e104ae7be259--

Example
The following example was written with Cache 2010.2 on Windows and allows both alternative message formats and embedded images to be specified in the same message. Information on the InterSystems' Caché Newsgroup helped with this solution.

USER>Write ##class(User.MailMessage).%New.Test

Class User.MailMessage Extends (%Persistent, %XML.Adaptor) {

Property State As %Integer(DISPLAYLIST = ",Errored,New,Queued,Sending,Sent,Archived", VALUELIST = ",-1,0,1,2,3,4") [ Required, SqlColumnNumber = 2 ];

Property StateName As %String(TRUNCATE = 1) [ Calculated, SqlColumnNumber = 3, SqlComputeCode = { Set {*}=##class(User.MailMessage).StateLogicalToDisplay({State})}, SqlComputed ];

Property Priority As %Integer(DISPLAYLIST = ",Low,Medium,High,Critical", VALUELIST = ",0,10,20,30") [ InitialExpression = 10, Required, SqlColumnNumber = 4 ];

Property ContentPath As %String [ SqlColumnNumber = 5 ];

Property ContentName As %String [ SqlColumnNumber = 6 ];

Property ContentText As %Stream.GlobalCharacter [ SqlColumnNumber = 7 ];

Property ContentHTML As %Stream.GlobalCharacter [ SqlColumnNumber = 8 ];

Property Data As array Of %String;

Property States As array Of User.MailMessage.State;

Property Attachments As list Of User.MailMessage.Attachment;

Property MailMessage As %Net.MailMessage [ Required ];

Method StateNameGet As %String {	Quit ..StateLogicalToDisplay(..State) }

Method Test As %Status {	/*		Write ##class(User.MailMessage).%New.Test */	// create a message object and define some properties Set mm=##class(%Net.MailMessage).%New Set mm.Charset="iso-8859-1" Set mm.Subject="Test message at "_$ZDateTime($HoroLog, 2) Set mm.From="From Name " Set mm.ReplyTo="Reply Name "

// set the 'return-path' for bounced e-mails Do mm.Headers.SetAt("", "return-path")

// either address with name or just e-mail address supported Do mm.To.Insert("To Name ") // define some data items Set cid=$Translate($HoroLog_","_$Job, ",", ".")_"@somedomain.com" Do ..Data.SetAt(cid, "Content-Id") Do ..Data.SetAt("someone", "Username") Do ..Data.SetAt("something", "Password") // define content path and name // (or define text or html content instead) Set ..ContentPath="C:\InterSystems\Cache2010\mgr\user\stream" Set ..ContentName="template" // define attachments Set mma=##class(User.MailMessage.Attachment).%New Set mma.Path="C:\InterSystems\Cache2010\mgr\user\stream" Set mma.Name="somepic", mma.Type="png" Set mma.Inline=1, mma.ContentId=cid Set sc=..Attachments.Insert(mma) Set sc=..Create(mm) If $$$ISERR(sc) Quit sc	Quit ..Send }

Method Create(mm As %Net.MailMessage) As %Status {	// change state Set sc=..ChangeState("New", "New") If $$$ISERR(sc) Quit sc	// save the message object into this one Set ..MailMessage=mm // find the templates and convert Do ..Convert(..ContentPath, ..ContentName, "txt") Do ..Convert(..ContentPath, ..ContentName, "html") // do some initial checks If ..ContentText.Size=0, ..ContentHTML.Size=0 { Do ..ChangeState("", "New") Do ..ChangeState("Errored", "Errored", "No message content defined") Quit $$$ERROR($$$GeneralError, "No message content defined.") }	// change state Set sc=..ChangeState("Queued", "New") Quit sc }

Method Convert(path As %String = "", name As %String = "", ext As %String = "") As %Status [ Private ] {	// check template file exists If '##class(%File).Exists(path_"\"_name_"."_ext) { Quit $$$ERROR($$$GeneralError, "File does not exist.") }	// check template file type Set type=$ZConvert(ext, "l") If type="txt" { Set sc=..ContentText.Clear(0) } ElseIf type="htm" { Set sc=..ContentHTML.Clear(0) } ElseIf type="html" { Set sc=..ContentHTML.Clear(0) } Else { Quit $$$ERROR($$$GeneralError, "File type not valid.") }	// read the relevant file stream Set file=##class(%Stream.FileCharacter).%New Set file.Filename=path_"\"_name_"."_ext While 'file.AtEnd { Set line=file.ReadLine // do some data replacements While line?.E1"*|".E.1"|*".E { // define merge tag, prefix, key and default value Set mtag=$Piece($Piece(line, "|*"), "*|", 2) Set mpre=$Piece(mtag, ":") If mpre="" Set mpre=" " Set mkey=$Piece(mtag, ":", 2) Set mval="#"_mtag_"#" // replace merge tag with data If mpre="Data", ..Data.IsDefined(mkey) Set mval=..Data.GetAt(mkey) Set line=..Replace(line, "*|"_mtag_"|*", mval) }		// copy data to new stream If type="txt" { Set sc=..ContentText.WriteLine(line) } Else { Set sc=..ContentHTML.WriteLine(line) }	}	Quit $$$OK }

ClassMethod Replace(text As %String, find As %String, newstr As %String) As %String [ Language = basic ] {	' DocBook.UI.Page.cls?KEY=RBAS_freplace ' Replace(expression,find,replacewith[,start[,count[,compare]]]) Return Replace(text, find, newstr) }

Method Send As %Status {	// change state Set sc=..ChangeState("Sending", "Queued") If $$$ISERR(sc) Quit sc	// do some initial checks If ..ContentText.Size=0, ..ContentHTML.Size=0 { Do ..ChangeState("", "Sending") Do ..ChangeState("Errored", "Errored", "No message content to send") Quit $$$ERROR($$$GeneralError, "No message content to send.") }

// retrieve the mail message Set mm=..MailMessage // set multi-part related properties Set mm.IsMultiPart=1 Set mm.MultiPartType="related"

// first nest the alternative messages Set partalt=##class(%Net.MailMessagePart).%New Set partalt.IsMultiPart=1 Set partalt.MultiPartType="alternative"

// add text alternative and save text data If ..ContentText.Size>0 { Set part=##class(%Net.MailMessagePart).%New Set part.ContentType="text/plain" Do part.TextData.CopyFrom(..ContentText) Set part.TextData.RemoveOnClose=1 Do partalt.Parts.SetAt(part, partalt.Parts.Count+1) }

// add html alternative and save text data If ..ContentHTML.Size>0 { Set part=##class(%Net.MailMessagePart).%New Set part.ContentType="text/html" Do part.TextData.CopyFrom(..ContentHTML) Set part.TextData.RemoveOnClose=1 Do partalt.Parts.SetAt(part, partalt.Parts.Count+1) }	// include alternative messages into main Do mm.Parts.SetAt(partalt, mm.Parts.Count+1) // add file attachments Set key="" Do { Set mma=..Attachments.GetNext(key) If $IsObject(mma) { Set sc=mm.AttachFile(mma.Path, mma.Name_"."_mma.Type, mma.Binary, "", .count) If mma.Inline Set mm.Parts.GetAt(count).InlineAttachment=1 If $Length(mma.MimeType) Set mm.Parts.GetAt(count).ContentType=mma.MimeType If $Length(mma.ContentId) Do mm.Parts.GetAt(count).Headers.SetAt("<"_mma.ContentId_">", "Content-Id") }	} While (key'="") // create a smtp object and send Set mail=##class(%Net.SMTP).%New Set mail.smtpserver=" " Set mail.timezone="LOCAL" Set sc=mail.Send(mm) If $$$ISERR(sc) { Do ..ChangeState("", "Sending") Do ..ChangeState("Errored", "Errored", "Message not sent") Quit sc	} // change state to complete Do ..ChangeState("", "Sending") Do ..ChangeState("Sent", "Sent") Quit $$$OK }

Method ChangeState(newname As %String = "", oldname As %String = "", text As %String) As %Status {	// change the state? If $Length(newname) { // convert state name to code Set new=..StateDisplayToLogical(newname) If new="" Quit $$$ERROR($$$GeneralError, "The new state name does not exist.") // change to new state Set ..State=new // check if state details already exists Set sd=..States.GetAt(new) If $IsObject(sd) { // clear old entries and increment count Set sd.TimeBegin=$ZDateTime($HoroLog, 3) Set sd.TimeFinish="" Set sd.DetailText=$Get(text) Set sd.UseCount=sd.UseCount+1 }		Else { // create state details instance Set sd=##class(User.MailMessage.State).%New Set sd.State=new Set sd.TimeBegin=$ZDateTime($HoroLog, 3) Set sd.DetailText=$Get(text) Set sd.UseCount=1 }		// update state details Do ..States.SetAt(sd, new) }	// update the previous state details If $Length(oldname) { // convert state name to code Set old=..StateDisplayToLogical(oldname) If old="" Quit $$$ERROR($$$GeneralError, "The old state name does not exist.") // get the state details instance and update Set sd=..States.GetAt(old) If $IsObject(sd) { Set sd.TimeFinish=$ZDateTime($HoroLog, 3) Set sd.TimeTaken=..TimeDiffCalc(sd.TimeBegin, sd.TimeFinish) Do ..States.SetAt(sd, old) }	}	// save and return Quit ..%Save }

ClassMethod TimeDiffCalc(start As %TimeStamp, end As %TimeStamp) As %Integer {	// calculate difference between two timestamps If start="" Quit "" If end="" Quit "" If start=end Quit 0 If $Piece(start, " ")=$Piece(end, " ") { // work out time difference quickly Set start=$ZTimeH($Piece(start, " ", 2)) Set end=$ZTimeH($Piece(end, " ", 2)) Quit end-start }	Quit $System.SQL.DATEDIFF("second", start, end) }

}

Class User.MailMessage.State Extends (%SerialObject, %XML.Adaptor) {

Property State As %Integer;

Property StateName As %String [ Calculated, SqlComputeCode = { Set {*}=##class(User.MailMessage).StateLogicalToDisplay({State})}, SqlComputed ];

Property TimeBegin As %TimeStamp;

Property TimeFinish As %TimeStamp;

Property TimeTaken As %Integer;

Property UseCount As %Integer;

Property DetailText As %String(MAXLEN = "");

Method StateNameGet As %String {	Quit ##class(User.MailMessage).StateLogicalToDisplay(..State) }

}

Class User.MailMessage.Attachment Extends (%SerialObject, %XML.Adaptor) {

Property Path As %String(MAXLEN = "");

Property Name As %String;

Property Type As %String;

Property Inline As %Boolean [ InitialExpression = 0 ];

Property Binary As %Boolean [ InitialExpression = 1 ];

Property ContentId As %String;

Property MimeType As %String [ Calculated, SqlComputeCode = { Set {*}=##class(User.MailMessage.Attachments).MimeTypeGet({Type})}, SqlComputed ];

Method MimeTypeGet As %String {	Quit $Case($ZConvert(..Type, "l"), 		"tif": "image/tiff", 		"jpg": "image/jpeg", 		"png": "image/png", 		"gif": "image/gif", 		"bmp": "image/bmp", 		"htm": "text/html", 		"txt": "text/text", 		"xml": "text/xml", 		"pdf": "application/pdf", 		"doc": "application/msword", 		"xls": "application/vnd.ms-excel", 		"ppt": "application/vnd.ms-powerpoint", 		"zip": "application/zip", 		: "") }

}