Want to leave a question, comment, or some criticism? Click here!
1. Motivation
By default, the .Net mail classes in System.Web.Mail offer very weak functionality for composing the message format. On the other hand, CDONTS, the "predecessor" to System.Web.Mail is incredibly rich in functionality and very powerful. I use "predecessor" in quotes because, as some may be surprised to learn, System.Web.Mail actually uses CDONTS; System.Web.Mail is not a new mail library, but rather a very limited/severely crippled interface to CDO. You can confirm this for yourself by using Reflector on the System.Web.Mail.SmtpMail class.
Well, if you're satisfied with what's offered in System.Web.Mail or you're already using a third party mail package, then read no further. If you'd like to find out how to take advangate of CDONTS to maximize your flexibility when working with emails in .Net, then this workshop is for you :-) Bear in mind that even the all powerful CDONTS has its limitations (I've had to write a custom SMTP client once, but that's for another workshop), but it is far more powerful than the default classes in .Net. Among the cool things that you can do with CDONTS:
- Build and send messages with embedded images using MHTML format.
- Generate MHTML snapshots of any webpage (I'll do a workshop on this in the future).
- Send email messages from ASPX pages without saving attachments to disk
This last bullet is the subject of this workshop. Using CDONTS, it's possible to send an email with a user uploaded attachment without saving the attachment to disk first.
2. Objective
If you peek at the MSDN documentation for System.Web.Mail.MailAttachment, you'll see that both constructors require a filename for an attachment.
In some scenarios, if you're sending mail from an ASPX page, you may not want to/need to write the file to the disk first (space constraints, sensitive files, no control of disk write access, etc.). Not to mention that it's a double whammy in terms of performance since you need to write the POST'd file to disk, and then simply turn around and read the file again! To work around this we will utilize CDONTS and good old ADODB, via .Net interops, to manually build and send an email message with an attachment without saving the attachment to disk first.
3. Auxiliary Files/Prerequisites
To try this out for yourself, you will need the following:
- CDONTS/CDOSYS DLL. Again, I think most XP Pro and Win2K3 machines should have this. On XP Pro, it's part of the IIS Windows component that you can install via the "Add or Remove Programs" application. Select IIS in the menu and click the "Details" button. "SMTP Service" should be one of the options. Don't hold me to this, but I think this has to be installed to copy CDONTS from the install CD to your machine.
- Visual Studio.Net. This can be done without it, but I'm not going to do so :-)
- If you'd like, you can also download the completed project to work from.
I will assume that anyone that's still reading this knows how to create VB.Net web projects in Visual Studio.
4. Create Web Project and Add References
In VS.Net, create a new web project (I'm using VB.Net for this workshop, but it's trivial if you would prefer C#).
We will need to add a reference to CDONTS/CDOSYS. In the "COM" tab, scroll down to the "M" letters and look for "Microsoft CDO for Windows 2000 Library". You'll note that "Microsoft CDO for NTS 1.2 Library" is right above it. Adding the former will add the "CDO" namespace to your project while adding the latter will add the "CDONTS" namespace to your project. For the purposes of this project, even though I've been using "CDONTS", we will actually be using CDOSYS. On my machine, the path is C:\WINDOWS\System32\cdosys.dll
Note that adding the CDOSYS reference automatically adds the ADODB reference for you. When you're all done, your references should look like so:
5. Create Input Controls
Now that we've added the references, we need to go ahead and build our input form. If you'd like to use my code verbatim, then you should delete the WebForm1.aspx file and add a new web form called Default.aspx.
We will need 5 controls on the page:
- A file input box (System.Web.UI.HtmlControls.HtmlInputFile) for adding our attachment to the message.
- A text input box (System.Web.UI.WebControls.TextBox) for the recipient email address.
- A text input box for the sender email address.
- A text input box for the subject line of the message.
- A text input box for the body of the message (TextMode="MultiLine").
All of these are optional if you don't want to have dynamic input. You can hardcode all of these values. In my sample, I will also be adding some validators as well. Here's my ASPX page:
ASPX page markup
<%@ Page Language="vb"
AutoEventWireup="false"
Codebehind="Default.aspx.vb"
Inherits="_Default"
Trace="true"%>
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">
<html>
<head>
<title>Email Attachment Sample/Test</title>
<style type="text/css">
* {
font-size: 11px;
color: #333;
font-family: verdana
}
div.content {
border: #999 1px solid;
padding: 3px;
background: #eee;
margin: 2px;
width: 500px;
}
div.center {
padding: 4px;
text-align: center
}
p {
margin: 2px 0px;
}
p.heading {
font-style:oblique;
font-weight:bold;
margin-top:5px;
}
</style>
</head>
<body >
<!--///
THE FORM ELEMENT MUST HAVE THE enctype ATTRIBUTE
AND IT MUST BE SET TO "multipart/form-data"
///-->
<form id="Form1" method="post" runat="server"
enctype="multipart/form-data">
<div class="content">
<p><asp:Label
ID="ErrorLabel"
Runat="server"
Visible="False"
style="color:red"
EnableViewState="False"/>
<asp:Label
ID="SuccessLabel"
Runat="server"
Visible="False"
style="color:green"
EnableViewState="False"/></p>
<p class="heading">Select a file to upload:</p>
<!--///
FILE INPUT
///-->
<p><input
type="file"
id="FileUploadBox"
runat="server"
style="width:400px"></p>
<p class="heading">Enter an email address:</p>
<!--///
EMAIL INPUT
///-->
<p><asp:TextBox
Id="EmailTextBox"
Runat="server"
Width="400">yourself@yourdomain.com
</asp:TextBox>
<asp:RequiredFieldValidator
Id="EmailRequiredValidator"
Runat="server"
Display="Dynamic"
ErrorMessage=" * Required"
ControlToValidate="EmailTextBox"/>
<asp:RegularExpressionValidator
Id="EmailExpressionValidator"
Runat="server"
Display="Dynamic"
ErrorMessage=" * Invalid"
ControlToValidate="EmailTextBox"
ValidationExpression="(?:\w|\.|-)+@(?:\w|-)+\.[a-zA-Z-_\.]+"/></p>
<p class="heading">"From" email address:</p>
<!--///
"FROM" EMAIL INPUT (NEEDS TO BE A VALID EMAIL ADDRESS)
///-->
<p><asp:TextBox
Id="FromEmailTextBox"
Runat="server"
Width="400">yourself@yourdomain.com
</asp:TextBox>
<asp:RequiredFieldValidator
Id="FromRequiredValidator"
Runat="server"
Display="Dynamic"
ErrorMessage=" * Required"
ControlToValidate="FromEmailTextBox"/>
<asp:RegularExpressionValidator
Id="FromExpressionValidator"
Runat="server"
Display="Dynamic"
ErrorMessage=" * Invalid"
ControlToValidate="FromEmailTextBox"
ValidationExpression="(?:\w|\.|-)+@(?:\w|-)+\.[a-zA-Z-_\.]+"/></p>
<p class="heading">Subject:</p>
<!--///
THE "SUBJECT" OF THE EMAIL
///-->
<p><asp:TextBox
Id="SubjectTextBox"
Runat="server"
Width="400">Here is my document.
</asp:TextBox>
<asp:RequiredFieldValidator
Id="SubjectRequiredValidator"
Runat="server"
Display="Dynamic"
ErrorMessage=" * Required"
ControlToValidate="SubjectTextBox"/></p>
<p class="heading">Message:</p>
<!--///
THE "MESSAGE" OF THE EMAIL
///-->
<p><asp:TextBox
Id="MessageTextbox"
Runat="server"
TextMode="MultiLine"
Width="400" Height="100">Please read the document.
</asp:TextBox>
<asp:RequiredFieldValidator
Id="MessageRequiredValidator"
Runat="server"
Display="Dynamic"
ErrorMessage=" * Required"
ControlToValidate="MessageTextbox"/></p>
<!--/// SUBMIT BUTTON ///-->
<div class="center">
<asp:Button
Id="SubmitButton"
Runat="server"
Text="Submit"/>
</div>
</div>
</form>
</body>
</html>
If you're using my ASPX markup, you'll also need to add the controls to the codebehind. Here is my skeleton codebehind:
Skeleton codebehind file
Imports System.Configuration
Imports System.IO
Imports System.Web.UI.WebControls
Imports System.Web.UI.HtmlControls
Imports CDO
Imports ADODB
Public Class _Default
Inherits System.Web.UI.Page
Protected WithEvents EmailTextBox As TextBox
Protected WithEvents FromEmailTextBox As TextBox
Protected WithEvents SubmitButton As Button
Protected WithEvents SubjectTextBox As TextBox
Protected WithEvents MessageTextbox As TextBox
Protected WithEvents ErrorLabel As Label
Protected WithEvents SuccessLabel As Label
Protected WithEvents EmailRequiredValidator As RequiredFieldValidator
Protected WithEvents EmailExpressionValidator As RegularExpressionValidator
Protected WithEvents FromRequiredValidator As RequiredFieldValidator
Protected WithEvents FromExpressionValidator As RegularExpressionValidator
Protected WithEvents SubjectRequiredValidator As RequiredFieldValidator
Protected WithEvents MessageRequiredValidator As RequiredFieldValidator
Protected WithEvents FileUploadBox As HtmlInputFile
'//------------------------------------------------------------------------
'// Page load
'//------------------------------------------------------------------------
Private Sub Page_Load(ByVal sender As System.Object, _
ByVal e As System.EventArgs) Handles MyBase.Load
'// NOTHING TO DO.
End Sub
#Region " Web Form Designer Generated Code "
'This call is required by the Web Form Designer.
<System.Diagnostics.DebuggerStepThrough()> Private Sub InitializeComponent()
End Sub
Private Sub Page_Init(ByVal sender As System.Object, _
ByVal e As System.EventArgs) Handles MyBase.Init
'CODEGEN: This method call is required by the Web Form Designer
'Do not modify it using the code editor.
InitializeComponent()
End Sub
#End Region
End Class
In the codebeind you will need to add the reference to ADODB and CDO in the Imports at the top of the page.
Now that we've got the main form and codebehind set up, we need to just quickly add our function stubs. For this example, we will need three functions:
- SendButton_Click(). This is the event handler for the click event of the button on our form. This piece will contain most of the crazy logic (a lot of which can be moved into a seperate class/library for a more sophisticated design).
- ValidateFile(). This function will check to make sure that the file the user uploads is "valid".
- GetFullFilename(). This function will get the full filename from the HttpPostedFile.
Your stubs should look like so:
Function stubs
'//------------------------------------------------------------------------
'// Handles the click event of the submit button.
'// 1. Checks that the uploaded file exists and is valid
'// 2. Use CDO to attach the file without saving it to disk
'// 3. Send the email
'//------------------------------------------------------------------------
Private Sub SubmitButton_Click(ByVal sender As System.Object, _
ByVal e As System.EventArgs) Handles SubmitButton.Click
Throw New NotImplementedException("Not Implemented Yet!")
End Sub
'//------------------------------------------------------------------------
'// Helper function to check whether a file is "valid" based on a number of
'// criteria.
'//------------------------------------------------------------------------
'// [Inputs]
'// file : The file that was POST'ed to the server
'// message : Used to return a string message back to the caller
'// [Outputs]
'// (return) : A boolean value corresponding to whether the file is
'// "valid"
'//------------------------------------------------------------------------
Private Function ValidateFile(ByVal file As HttpPostedFile, _
ByRef message As String) As Boolean
Throw New NotImplementedException("Not Implemented Yet!")
End Function
'//------------------------------------------------------------------------
'// Helper function to check whether a file is "valid" based on a number of
'// criteria. Assumes that ValidateFile() has already been called on the
'// file and the return value is the Boolean value True. If ValidateFile
'// is not called first and/or the return value is thethe Boolean value
'// False, an exception may be thrown.
'//------------------------------------------------------------------------
'// [Inputs]
'// file : The file that was POST'ed to the server
'// [Outputs]
'// (return) : A string value which contains the file's full name (file
'// name and extension).
'//------------------------------------------------------------------------
Private Function GetFullFilename(ByVal file As HttpPostedFile) As String
Throw New NotImplementedException("Not Implemented Yet!")
End Function
6. Fill in File Validation Function
The first step is to fill in the file validation function. Among the things we would like to validate before processing the file are:
- Did we get a null reference (file upload failed somehow)?
- Is the length of the file greater than 0 bytes?
- Does the file have an extension?
- If it does, does the file have a permitted extension (for example, .exe and .js won't be allowed)?
- Is the file under the size limit?
Feel free to come up with your own validation checks. For the last two bullets, it would help to have these values defined in the configuraiton file. So before continuing, add the following keys into your congifuration file:
Web.config settings
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<appSettings>
<!--/// MAXIMUM ALLOWED FILE SIZE IN BYTES ///-->
<add key="MaxFileSize" value="524290"/>
<!--/// FILE EXTENSIONS WHICH ARE NOT ALLOWED ///-->
<add key="InvalidFileExtensions"
value="exe,js,vbs,sql,bat,com,asp,aspx,pif,scr"/>
<!--/// ADDRESS OF THE MAIL SERVER ///-->
<add key="MailServer" value="mail.optonline.net"/>
</appSettings>
<system.web>
<!--///
OVERRIDE THE SYSTEM DEFAULT SIZE LIMIT (AS
NECESSARY). VALUE IS IN KBYTES
///-->
<httpRuntime
maxRequestLength="512"/>
</system.web>
</configuration>
Note that we also added a key for the mail server address and a key to the system.web node which overrides the default setting in machine.config. Note that the maxRequestLength value is in kilobytes while the MaxFileSize key is in bytes (I've set these to be equal in this case). In reality, you could probably use just the maxRequestLength setting, which would effectively disallow requests larger than 512 kilobytes.
Below is my implementation of the method:
ValidateFile() implementation
Private Function ValidateFile(ByVal file As HttpPostedFile, _
ByRef message As String) As Boolean
'// CHECK THAT THE FILE EXISTS
If file Is Nothing Then
message = "The file does not exist or was not successfully "
message = message & "uploaded. Please try again."
Return False
End If
'// CHECK THAT THE FILE HAS A MINIMUM LENGTH GREATER THAN 0
If Not file.ContentLength > 0 Then
message = "The file had a content length of 0. Please try again."
Return False
End If
'// CHECK THAT THE FILE EXTENSION EXISTS
Dim position As Integer = file.FileName.LastIndexOf(".")
If position < 1 Then
message = "The file did not contain an extension. Please add a "
message = message & "valid extension to the file."
Return False
End If
'// CHECK THAT VALID EXTENSIONS ARE DEFINED
Dim invalidFileExtensions As String = _
ConfigurationSettings.AppSettings("InvalidFileExtensions")
If invalidFileExtensions Is Nothing Then
message = "The InvalidFileExtensions key is not defined in the "
message = message & "configuration file."
Return False
End If
'// CHECK THAT THE FILE EXTENSION IS VALID
Dim extension As String = file.FileName.Substring(position + 1)
If invalidFileExtensions.IndexOf(extension) > -1 Then
message = String.Format("The file extension {0} is not valid.", _
extension)
Return False
End If
'// CHECK THAT THE FILE SIZE LIMIT IS DEFINED
Dim maxFileSizeStr As String = _
ConfigurationSettings.AppSettings("MaxFileSize")
If maxFileSizeStr Is Nothing Then
message = "The MaxFileSize key is not defined in the "
message = message & "configuration file."
Return False
End If
Dim maxFileSize As Long = 0
Try
maxFileSize = Long.Parse(maxFileSizeStr)
Catch
message = "The MaxFileSize value defined was not numeric."
Return False
End Try
'// CHECK THE FILE SIZE LIMIT
If file.ContentLength > maxFileSize Then
message = "The size of the file submitted exceeded the maximum "
message = message & "allowable file size."
Return False
End If
'// ALL CHECKS PASSED
message = String.Empty
Return True
End Function
Note my use of ByRef for the second argument. This is a shortcut that I'm using to return two values from the call. A better implementation would be to create a wrapper class like ValidationResult or something. Also note that I broke up the message strings, which is unnecessary; I'm only breaking them up for display purposes (I knew I should have made the body wider in the template).
7. Fill in the Filename Helper Function
Next, we want to fill in the helper function for retrieving the filename (including extension, excluding the full path) from the HttpPostedFile's FileName property. By default, the FileName property contains the full path and filename of the file from the source machine, thus this function is used to extract only the filename and extensio. Feel free to exclude this function and do it inline instead.
My implementation is as follows:
Parse the filename and extension without the path
Private Function GetFullFilename(ByVal file As HttpPostedFile) As String
'// CAPTURE LOCATIONS OF BOTH BACK AND FORWARD SLASH TO ACCOMODATE
'// FILES LOADED FROM A NETWORK PATH.
Dim indexBackSlash As Integer = file.FileName.LastIndexOf("/")
Dim indexForwardSlash As Integer = file.FileName.LastIndexOf("\")
Dim fileName As String = String.Empty
If indexBackSlash >= 0 Then
fileName = file.FileName.Substring(indexBackSlash + 1)
ElseIf indexForwardSlash >= 0 Then
fileName = file.FileName.Substring(indexForwardSlash + 1)
Else
'// CANNOT PARSE THE FILE NAME PROPERLY
Throw New ArgumentException( _
String.Format("The filepath [{0}] is invalid.", _
file.FileName))
End If
Return fileName
End Function
8. Implment the Event Handler
The last step is to pull all of the pieces together and implement the event handler for the click event or our button.
A lot of what I ended up with was figured out through trial and error. The general idea behind this concept goes like this:
- Grab all of the text inputs from the form and the POST'ed file.
- Check the file to make sure that we should continue.
- Convert the HttpPostedFile into a byte array so that we can work with it.
- Create an instance of the CDO.Message class. This is our mail message object that we will be building up.
- Set some configuration values for our message object.
- Start building our multipart message.
- Add our message text to the first body part of the message.
- Add our attachment (now in the form of a byte array) to the second body part of the message.
- Set message properties (recipient, sender, etc.) and send.
- Display a confirmation message.
The real trick is in 3-8. Instead of saving the POST'ed file (now in some byte stream in memory) to the disk first, we use it directly by writing the stream contents, in base 64 encoding to the mail message object. You can figure most of this stuff out with some experimentation and the documenation available online. With some trial and error, I was able to figure out how to get this stuff to work properly.
My implementation is as follows:
Event handler implementation
Private Sub SubmitButton_Click(ByVal sender As System.Object, _
ByVal e As System.EventArgs) Handles SubmitButton.Click
'// CHECK THAT THE FILE IS VALID
Dim message As String = String.Empty
Dim file As HttpPostedFile = FileUploadBox.PostedFile
Page.Validate()
If Page.IsValid And ValidateFile(file, message) Then
'// DECLARE STREAMS
Dim stream1 As ADODB.Stream = Nothing
Dim stream2 As ADODB.Stream = Nothing
'// GET EMAIL CONTENT DATA
Dim recipientEmailAddress As String = EmailTextBox.Text
Dim senderEmailAddress As String = FromEmailTextBox.Text
Dim emailSubject As String = SubjectTextBox.Text
Dim emailMessage As String = MessageTextbox.Text
Try
'// FIRST STEP IS TO CONVERT THE UPLOADED FILE, WHICH IS STORED
'// AS AN IN-MEMORY BYTE STREAM, INTO A BYTE ARRAY.
Dim fileContents(file.InputStream.Length) As Byte
file.InputStream.Read(fileContents, 0, _
Convert.ToInt32(file.InputStream.Length))
'// DECLARE VARIABLES; USING CDO (FOR EXCHANGE) MESSAGE CLASSES
'// SO THAT WE CAN ATTACH FILE WITHOUT SAVING IT TO THE DISK.
Dim mail As CDO.Message = New CDO.MessageClass
Dim mailBody As CDO.IBodyPart = mail.BodyPart
Dim textBody As CDO.IBodyPart
Dim fileBody As CDO.IBodyPart
Dim config As CDO.Configuration = mail.Configuration
'// SET THE CONFIGURATION VALUES FOR THE MAIL MESSAGE
config.Fields(CdoConfiguration.cdoSendUsingMethod).Value = _
CdoSendUsing.cdoSendUsingPort
config.Fields(CdoConfiguration.cdoSMTPServer).Value = _
ConfigurationSettings.AppSettings("MailServer")
config.Fields(CdoConfiguration.cdoSMTPServerPort).Value = 25
config.Fields.Update()
'// CREATE MULTIPART MESSAGE (LEVEL 0)
mailBody.ContentMediaType = "multipart/mixed"
'// BUILD TEXT MESSAGE BODY PART MESSAGE BODY (LEVEL 1)
textBody = mailBody.AddBodyPart(1)
textBody.ContentMediaType = "text/plain"
textBody.ContentTransferEncoding = "7bit"
stream1 = textBody.GetDecodedContentStream()
stream1.WriteText(emailMessage, StreamWriteEnum.stWriteLine)
stream1.Flush()
stream1.Close()
'// BUILD THE ATTACHMENT BODY PART (LEVEL 1)
fileBody = mailBody.AddBodyPart(2)
fileBody.ContentMediaType = _
String.Format("application/octet-stream; name=""{0}""", _
GetFullFilename(file))
fileBody.ContentTransferEncoding = "base64"
stream2 = fileBody.GetDecodedContentStream()
stream2.Type = StreamTypeEnum.adTypeBinary
stream2.Write(fileContents)
stream2.Flush()
stream2.Close()
'// SET MESSAGE PROPERTIES AND SEND THE MESSAGE
mail.To = recipientEmailAddress
mail.From = senderEmailAddress
mail.Subject = emailSubject
mail.Send()
'// FOR TESTING PURPOSES ONLY; WRITES OUT THE TEXT CONTENTS OF THE
'// MESSAGE TO A FILE
'Dim output As ADODB.Stream = mail.GetStream()
'output.SaveToFile( _
'"C:\documents and settings\chen\desktop\output.txt", _
'SaveOptionsEnum.adSaveCreateOverWrite)
SuccessLabel.Visible = True
SuccessLabel.Text = "Your message was sent successfully."
Catch ex As Exception
'// SHOW ERROR MESSAGE
Trace.Warn("[ERROR]", _
"An error occurred while trying to attach and send the message",_
ex)
ErrorLabel.Text = "An error occurred while trying to send the email "
ErrorLabel.Text = ErrorLabel.Text & "with the attachment..."
ErrorLabel.Visible = True
Finally
stream1 = Nothing
stream2 = Nothing
End Try
Else
'// FILE IS NOT VALID; DISPLAY ERROR
ErrorLabel.Text = message
ErrorLabel.Visible = True
End If
End Sub
Not much to it, right :) Just a few things to take note of:
- Don't forget to call Update() on the Fields of the CDO.Configuration object. This caused me a lot of confusion the first time I worked with CDO in .Net as my settings didn't seem to be working correctly.
- IBodyPart instances can be nested further to create very complex messages.
- The main IBodyPart must have its ContentMediaType property set to multipart/mixed.
- I commented out a bit of testing code that I used to write the contents of the SMTP message to a file. Feel free to uncomment it to check out the actual SMTP message that's generated.
- Notice the use of ADODB.Streams to write data to the message body parts.
The IBodyPart MSDN documentation has a good sample of working with multi-level body parts.
9. Testing
Testing should be pretty obvious. Before you do so, make sure that you change the MailServer key in the config file. Please note that you may also have to add authentication to the CDO.Configration fields as well to access your mail server. For the purposes of testing, I recommend that you enable tracing and page output (if you're using my code, exceptions are written to the trace).
Most mail clients will allow you to peek at the raw SMTP message that was received. You can peek at it to check out how CDOSYS built up the mail message.
10. Wrap Up
In this workshop, we've discovered how to utlize CDOSYS to access the full functionality that is is masked by System.Web.Mail to do some cool stuff. CDOSYS has a lot of cool features, including the CreateMHTMLBody() function, which I will reserve for a future workshop. Also in mind is a workshop to create an email message with an embedded background image. This would be cool for creating mail templates/stationary that could be sent without using a URL reference to download the background images (since many clients nowadays filter those HTTP requests anyways).
11. Resources
Additional resources for further exploration: