The design is complete, and you have all the information you need to start coding the solution. This module's database objects are simple (a single table, with no relationships and foreign keys, and only a few stored procedures for the basic CRUD operations) so I won't demonstrate how to create the table and the stored procedures' code. You can refer to previous chapters for general information about how you can work with these objects from Visual Studio 2005, and you can refer to the downloadable code to see the full implementation.
The code for the NewslettersElement custom configuration element is found in the ~/App_Code/ ConfigSection.cs file, together with the classes that map the other elements under the <theBeer House> section. Following is the whole class, which defines the properties listed in the design section, their default values, whether they are required or not, and their mapping to the respective attributes of the <newsletters> element:
public class NewslettersElement : ConfigurationElement { [ConfigurationProperty("connectionStringName")] public string ConnectionStringName { get { return (string)base["connectionStringName"]; } set { base["connectionStringName"] = value; } } public string ConnectionString { get { string connStringName = (string.IsNullOrEmpty(this.ConnectionStringName) ? Globals.Settings.DefaultConnectionStringName : this.ConnectionStringName); return WebConfigurationManager.ConnectionStrings[ connStringName].ConnectionString; } } [ConfigurationProperty("providerType", DefaultValue = "MB.TheBeerHouse.DAL.SqlClient.SqlNewslettersProvider")] public string ProviderType { get { return (string)base["providerType"]; } set { base["providerType"] = value; } } [ConfigurationProperty("fromEmail", IsRequired=true)] public string FromEmail { get { return (string)base["fromEmail"]; } set { base["fromEmail"] = value; } } [ConfigurationProperty("fromDisplayName", IsRequired = true)] public string FromDisplayName { get { return (string)base["fromDisplayName"]; } set { base["fromDisplayName"] = value; } } [ConfigurationProperty("hideFromArchiveInterval", DefaultValue = "15")] public int HideFromArchiveInterval { get { return (int)base["hideFromArchiveInterval"]; } set { base["hideFromArchiveInterval"] = value; } } [ConfigurationProperty("archiveIsPublic", DefaultValue = "false")] public bool ArchiveIsPublic { get { return (bool)base["archiveIsPublic"]; } set { base["archiveIsPublic"] = value; } } [ConfigurationProperty("enableCaching", DefaultValue = "true")] public bool EnableCaching { get { return (bool)base["enableCaching"]; } set { base["enableCaching"] = value; } } [ConfigurationProperty("cacheDuration")] public int CacheDuration { get { int duration = (int)base["cacheDuration"]; return (duration > 0 ? duration : Globals.Settings.DefaultCacheDuration); } set { base["cacheDuration"] = value; } } }
A property named Newsletters of type NewslettersElement is added to the TheBeerHouseSection (found in the same file), which maps the <theBeerHouse> section:
public class TheBeerHouseSection : ConfigurationSection { // other properties... [ConfigurationProperty("newsletters", IsRequired = true)] public NewslettersElement Newsletters { get { return (NewslettersElement)base["newsletters"]; } } }
Now you can go to the web.config file and configure the module with the attributes of the <newsletters> element. The SenderEmail and SenderDisplayName are required, the others are optional. The following extract shows how you can configure these two attributes, plus others that make the archive public (it is not by default) and specify that 10 days must pass before a sent newsletter appears in the archive:
<theBeerHouse defaultConnectionStringName="LocalSqlServer"> <contactForm mailTo="mbellinaso@wrox.com"/> <articles pageSize="10" /> <polls archiveIsPublic="true" votingLockByIP="false" /> <newsletters fromEmail="mbellinaso@wrox.com" fromDisplayName="TheBeerHouse" archiveIsPublic="true"hideFromArchiveInterval="10" /> </theBeerHouse>
Create a file named NewsletterProvider.cs (located under ~/App_Code/) to define the base provider class (named NewsletterProvider) for the newsletter module. This class has an instance property that returns the instance of the concrete provider indicated in the configuration, and is almost identical to the similarly named property found in the previous modules' base provider class. It loads some other settings from the configuration in its constructor, and defines some abstract methods that specify the signature of the methods described earlier, and which will be given a concrete implementation in the DAL class specific to SQL Server (or any other data store you want to support):
public abstract class NewslettersProvider : DataAccess { static private NewslettersProvider _instance = null; static public NewslettersProvider Instance { get { if (_instance == null) _instance = (NewslettersProvider)Activator.CreateInstance( Type.GetType(Globals.Settings.Newsletters.ProviderType)); return _instance; } } public NewslettersProvider() { this.ConnectionString = Globals.Settings.Newsletters.ConnectionString; this.EnableCaching = Globals.Settings.Newsletters.EnableCaching; this.CacheDuration = Globals.Settings.Newsletters.CacheDuration; } // methods that work with newsletters public abstract List<NewsletterDetails> GetNewsletters(DateTime toDate); public abstract NewsletterDetails GetNewsletterByID(int newsletterID); public abstract bool DeleteNewsletter(int newsletterID); public abstract bool UpdateNewsletter(NewsletterDetails newsletter); public abstract int InsertNewsletter(NewsletterDetails newsletter); /// <summary>Returns a new NewsletterDetails instance filled with the /// DataReader's current record data</summary> protected virtual NewsletterDetails GetNewsletterFromReader(IDataReader reader) { return GetNewsletterFromReader(reader, true); } protected virtual NewsletterDetails GetNewsletterFromReader( IDataReader reader, bool readBody) { NewsletterDetails newsletter = new NewsletterDetails( (int)reader["NewsletterID"], (DateTime)reader["AddedDate"], reader["AddedBy"].ToString(), reader["Subject"].ToString(), null, null); if (readBody) { newsletter.PlainTextBody = reader["PlainTextBody"].ToString(); newsletter.HtmlBody = reader["HtmlBody"].ToString(); } return newsletter; } /// <summary>Returns a collection of NewsletterDetails objects with /// the data read from the input DataReader</summary> protected virtual List<NewsletterDetails> GetNewsletterCollectionFromReader( IDataReader reader) { return GetNewsletterCollectionFromReader(reader, true); } protected virtual List<NewsletterDetails> GetNewsletterCollectionFromReader( IDataReader reader, bool readBody) { List<NewsletterDetails> newsletters = new List<NewsletterDetails>(); while (reader.Read()) newsletters.Add(GetNewsletterFromReader(reader, readBody)); return newsletters; } }
The other methods of the class are GetNewsletterFromReader and GetNewsletterCollectionFromReader, which, respectively, create a new instance of the NewsletterDetails entity class (not shown here, but it's just a simple class that wraps the database fields), or a collection of them. Note that in addition to the IDataReader parameter, both these methods have an overloaded version that accepts a Boolean value indicating whether the newsletter's plain-text and HTML body must be read — together, the NewsletterID, AddedDate, AddedBy, and Subject fields. The SQL Server provider calls these methods specifying false for that parameter from within the GetNewsletters method, while it calls them specifying true from within the GetNewsletterByID method. This is because the body is only returned from the database when you retrieve a specific newsletter, and not when you retrieve the list of newsletters.
The Newsletter class is found under ~/App_Code/BLL/Newsletters/Newsletter.cs. It's the code that wraps the newsletter's data with OOP instance properties, supports updating and deleting the newsletter represented by a particular instance, implements the static methods for retrieving a single newsletter or all newsletters before a specified date, and supports updating and deleting an existing newsletter:
public class Newsletter : BizObject { private static NewslettersElement Settings { get { return Globals.Settings.Newsletters; } } private int _id = 0; public int ID { get { return _id; } private set { _id = value; } } private DateTime _addedDate = DateTime.Now; public DateTime AddedDate { get { return _addedDate; } private set { _addedDate = value; } } private string _addedBy = ""; public string AddedBy { get { return _addedBy; } private set { _addedBy = value; } } private string _subject = ""; public string Subject { get { return _subject; } set { _subject = value; } } private string _plainTextBody = null; public string PlainTextBody { get { if (_plainTextBody == null) FillBody(); return _plainTextBody; } set { _plainTextBody = value; } } private string _htmlBody = null; public string HtmlBody { get { if (_htmlBody == null) FillBody(); return _htmlBody; } set { _htmlBody = value; } } public Newsletter(int id, DateTime addedDate, string addedBy, string subject, string plainTextBody, string htmlBody) { this.ID = id; this.AddedDate = addedDate; this.AddedBy = addedBy; this.Subject = subject; this.PlainTextBody = plainTextBody; this.HtmlBody = htmlBody; } public bool Delete() { bool success = Newsletter.DeleteNewsletter(this.ID); if (success) this.ID = 0; return success; } public bool Update() { return Newsletter.UpdateNewsletter(this.ID, this.Subject, this.PlainTextBody, this.HtmlBody); } private void FillBody() { NewsletterDetails record = SiteProvider.Newsletters.GetNewsletterByID(this.ID); this.PlainTextBody = record.PlainTextBody; this.HtmlBody = record.HtmlBody; } /*********************************** * Static methods ************************************/ /// <summary>Returns a collection with all newsletters sent before /// the specified date</summary> public static List<Newsletter> GetNewsletters() { return GetNewsletters(DateTime.Now); } public static List<Newsletter> GetNewsletters(DateTime toDate) { List<Newsletter> newsletters = null; string key = "Newsletters_Newsletters_" + toDate.ToShortDateString(); if (Settings.EnableCaching && BizObject.Cache[key] != null) { newsletters = (List<Newsletter>)BizObject.Cache[key]; } else { List<NewsletterDetails> recordset = SiteProvider.Newsletters.GetNewsletters(toDate); newsletters = GetNewsletterListFromNewsletterDetailsList(recordset); CacheData(key, newsletters); } return newsletters; } /// <summary>Returns a Newsletter object with the specified ID</summary> public static Newsletter GetNewsletterByID(int newsletterID) { Newsletter newsletter = null; string key = "Newsletters_Newsletter_" + newsletterID.ToString(); if (Settings.EnableCaching && BizObject.Cache[key] != null) { newsletter = (Newsletter)BizObject.Cache[key]; } else { newsletter = GetNewsletterFromNewsletterDetails( SiteProvider.Newsletters.GetNewsletterByID(newsletterID)); CacheData(key, newsletter); } return newsletter; } /// <summary>Updates an existing newsletter</summary> public static bool UpdateNewsletter(int id, string subject, string plainTextBody, string htmlBody) { NewsletterDetails record = new NewsletterDetails(id, DateTime.Now, "", subject, plainTextBody, htmlBody); bool ret = SiteProvider.Newsletters.UpdateNewsletter(record); BizObject.PurgeCacheItems("newsletters_newsletter"); return ret; } /// <summary> /// Deletes an existing newsletter /// </summary> public static bool DeleteNewsletter(int id) { bool ret = SiteProvider.Newsletters.DeleteNewsletter(id); new RecordDeletedEvent("newsletter", id, null).Raise(); BizObject.PurgeCacheItems("newsletters_newsletter"); return ret; } /// <summary>Returns a Newsletter object filled with the data taken /// from the input NewsletterDetails</summary> private static Newsletter GetNewsletterFromNewsletterDetails( NewsletterDetails record) { if (record == null) return null; else { return new Newsletter(record.ID, record.AddedDate, record.AddedBy, record.Subject, record.PlainTextBody, record.HtmlBody); } } /// <summary>Returns a list of Newsletter objects filled with the data /// taken from the input list of NewsletterDetails</summary> private static List<Newsletter> GetNewsletterListFromNewsletterDetailsList( List<NewsletterDetails> recordset) { List<Newsletter> newsletters = new List<Newsletter>(); foreach (NewsletterDetails record in recordset) newsletters.Add(GetNewsletterFromNewsletterDetails(record)); return newsletters; } /// <summary> /// Cache the input data, if caching is enabled /// </summary> private static void CacheData(string key, object data) { if (Settings.EnableCaching && data != null) { BizObject.Cache.Insert(key, data, null, DateTime.Now.AddSeconds(Settings.CacheDuration), TimeSpan.Zero); } } }
All the preceding code is similar to other BLL code seen in the previous chapter, so I won't comment on it again, but it's worth noting that the default value for the PlainTextBody and HtmlBody properties is null, and not an empty string. The null value denotes that the body has not been populated from the DAL, typically because the current Newsletter instance was created as part of a collection of newsletters, and thus the body was not retrieved from the database. However, if the UI code accesses the property, the private FillBody method is called to retrieve another Newsletter instance with all the fields populated so that the body can be copied into the current object.
At this point you add code to send newsletters to the subscribers, starting from the static properties containing the progress information, and the companion ReaderWriterLock field to synchronize access to them:
public static ReaderWriterLock Lock = new ReaderWriterLock(); private static bool _isSending = false; public static bool IsSending { get { return _isSending; } private set { _isSending = value; } } private static double _percentageCompleted = 0.0; public static double PercentageCompleted { get { return _percentageCompleted; } private set { _percentageCompleted = value; } } private static int _totalMails = -1; public static int TotalMails { get { return _totalMails; } private set { _totalMails = value; } } private static int _sentMails = 0; public static int SentMails { get { return _sentMails; } private set { _sentMails = value; } }
Following is the SendNewsletter method, which first resets all the progress status properties and sets IsSending to true, inserts a newsletter record into the archive table through the DAL provider, and finally starts a new thread that will execute the SendEmails private method:
/// <summary>Sends a newsletter</summary> public static int SendNewsletter(string subject, string plainTextBody, string htmlBody) { Lock.AcquireWriterLock(Timeout.Infinite); Newsletter.TotalMails = -1; Newsletter.SentMails = 0; Newsletter.PercentageCompleted = 0.0; Newsletter.IsSending = true; Lock.ReleaseWriterLock(); // if the HTML body is an empty string, use the plain-text body // converted to HTML if (htmlBody.Trim().Length == 0) { htmlBody = HttpUtility.HtmlEncode(plainTextBody).Replace( " ", " ").Replace("\t", " ").Replace("\n", "<br/>"); } // create the record into the DB NewsletterDetails record = new NewsletterDetails(0, DateTime.Now, BizObject.CurrentUserName, subject, plainTextBody, htmlBody); int ret = SiteProvider.Newsletters.InsertNewsletter(record); BizObject.PurgeCacheItems("newsletters_newsletters_" + DateTime.Now.ToShortDateString()); // send the newsletters asynchronously object[] parameters = new object[]{subject, plainTextBody, htmlBody, HttpContext.Current}; ParameterizedThreadStart pts = new ParameterizedThreadStart(SendEmails); Thread thread = new Thread(pts); thread.Name = "SendEmails"; thread.Priority = ThreadPriority.BelowNormal; thread.Start(parameters); return ret; }
Note that only the plain-text body is strictly required by this method. In fact, if the HTML body is an empty string, then the plain-text body will be used for HTML newsletters as well, after encoding it and manually replacing spaces, tabs, and carriage returns with " " and "<br />" strings. But it's the last few lines of the method that are the most important, because that's where the e-mail creation and delivery is started in the background thread. A ParameterizedThreadStart delegate is used instead of a simpler ThreadStart delegate, so you can pass input parameters to the SendEmails method. These parameters consist of an object array containing the newsletter's subject and body in both formats, plus a reference to the current HttpContext. The HttpContext will be required by SendEmails to retrieve the list of members and their profiles — this is required because the method does not run from inside the same thread that is processing the current ASP.NET request, so it won't have access to its context.
The SendEmail method starts by cycling through all the registered members, loads their profiles (refer to Chapter 4 to see how to load member profiles and registration information such as the e-mail) and finds those who are subscribed to the newsletter in either plain-text or HTML format. However, it doesn't send the e-mail to each subscriber immediately, because it must first count the subscribers and update the TotalEmails property, so this information can be updated in the administrator's feedback page. Therefore, upon finding a subscriber, it increments TotalEmails by one and saves the subscriber's information for later use in sending the newsletter. The required information includes the subscription format, the e-mail address, and the first and last names (required for personalization). This information is wrapped by a tailor-made SubscriberInfo structure defined as follows:
public struct SubscriberInfo { public string UserName; public string Email; public string FirstName; public string LastName; public SubscriptionType SubscriptionType; public SubscriberInfo(string userName, string email, string firstName, string lastName, SubscriptionType subscriptionType) { this.UserName = userName; this.Email = email; this.FirstName = firstName; this.LastName = lastName; this.SubscriptionType = subscriptionType; } }
SendMails uses a strongly typed collection of type List<SubscriberInfo> to store the information of all subscribers it finds. Then, for each SubscriberInfo object present in the collection it creates a new MailMessage, sets the sender's e-mail and display name with the values read from the configuration settings, sets its IsBodyHtml property according to the subscriber's SubscriptionType profile property, and sets the subject and body, after replacing the personalization placeholders, if any. When ready, the MailMessage is sent to subscribers by the synchronous Send method of a SmtpClient object, the SentMails property is incremented by one, and the PercentageCompleted value is recalculated. Finally, when the last subscriber has been sent her newsletter, the IsSending property is set to false and the method (and its dedicated background thread) ends. Following is the complete code, whose comments provide more details about the way it works:
private static void SendEmails(object data) { object[] parameters = (object[])data; string subject = (string)parameters[0]; string plainTextBody = (string)parameters[1]; string htmlBody = (string)parameters[2]; HttpContext context = (HttpContext)parameters[3]; Lock.AcquireWriterLock(Timeout.Infinite); Newsletter.TotalMails = 0; Lock.ReleaseWriterLock(); // check if the plain-text and the HTML bodies have personalization placeholders // that will need to be replaced on a per-mail basis. If not, the parsing will // be completely avoided later. bool plainTextIsPersonalized = HasPersonalizationPlaceholders( plainTextBody, false); bool htmlIsPersonalized = HasPersonalizationPlaceholders(htmlBody, true); // retrieve all subscribers to the plain-text and HTML newsletter, List<SubscriberInfo> subscribers = new List<SubscriberInfo>(); ProfileCommon profile = context.Profile as ProfileCommon; foreach (MembershipUser user in Membership.GetAllUsers()) { ProfileCommon userProfile = profile.GetProfile(user.UserName); if (userProfile.Preferences.Newsletter != SubscriptionType.None) { SubscriberInfo subscriber = new SubscriberInfo( user.UserName, user.Email, userProfile.FirstName, userProfile.LastName, userProfile.Preferences.Newsletter); subscribers.Add(subscriber); Lock.AcquireWriterLock(Timeout.Infinite); Newsletter.TotalMails += 1; Lock.ReleaseWriterLock(); } } // send the newsletter SmtpClient smtpClient = new SmtpClient(); foreach (SubscriberInfo subscriber in subscribers) { MailMessage mail = new MailMessage(); mail.From = new MailAddress(Settings.FromEmail, Settings.FromDisplayName); mail.To.Add(subscriber.Email); mail.Subject = subject; if (subscriber.SubscriptionType == SubscriptionType.PlainText) { string body = plainTextBody; if (plainTextIsPersonalized) body = ReplacePersonalizationPlaceholders(body, subscriber, false); mail.Body = body; mail.IsBodyHtml = false; } else { string body = htmlBody; if (htmlIsPersonalized) body = ReplacePersonalizationPlaceholders(body, subscriber, true); mail.Body = body; mail.IsBodyHtml = true; } try { smtpClient.Send(mail); } catch { } Lock.AcquireWriterLock(Timeout.Infinite); Newsletter.SentMails += 1; Newsletter.PercentageCompleted = (double)Newsletter.SentMails * 100 / (double)Newsletter.TotalMails; Lock.ReleaseWriterLock(); } Lock.AcquireWriterLock(Timeout.Infinite); Newsletter.IsSending = false; Lock.ReleaseWriterLock(); }
The SendEmails method uses the HasPersonalizationPlaceholders private method to check whether the plain-text and HTML bodies contain any personalization tags, and it is implemented as follows:
private static bool HasPersonalizationPlaceholders(string text, bool isHtml) { if (isHtml) { if (Regex.IsMatch(text, @"<%\s*username\s*%>", RegexOptions.IgnoreCase | RegexOptions.Compiled)) return true; if (Regex.IsMatch(text, @"<%\s*email\s*%>", RegexOptions.IgnoreCase | RegexOptions.Compiled)) return true; if (Regex.IsMatch(text, @"<%\s*firstname\s*%>", RegexOptions.IgnoreCase | RegexOptions.Compiled)) return true; if (Regex.IsMatch(text, @"<%\s*lastname\s*%>", RegexOptions.IgnoreCase | RegexOptions.Compiled)) return true; } else { if (Regex.IsMatch(text, @"<%\s*username\s*%>", RegexOptions.IgnoreCase | RegexOptions.Compiled)) return true; if (Regex.IsMatch(text, @"<%\s*email\s*%>", RegexOptions.IgnoreCase | RegexOptions.Compiled)) return true; if (Regex.IsMatch(text, @"<%\s*firstname\s*%>", RegexOptions.IgnoreCase | RegexOptions.Compiled)) return true; if (Regex.IsMatch(text, @"<%\s*lastname\s*%>", RegexOptions.IgnoreCase | RegexOptions.Compiled)) return true; } return false; }
In addition to the body text, it takes a Boolean parameter indicating whether the body is in plain-text or HTML format: If it is plain text, it will search for <%placeholder_name_here%> placeholders; other-wise, it searches for <%placeholder_name_here%>. This is because the HTML body will typically be written from the administrator's page, which uses the FCKeditor already covered in Chapter 5. When you enter special characters such as < and > in the default Design View, it will encode them to < and >. This method uses regular expressions to check whether a given pattern is found within the given input string. Please refer to the sidebar "A One-page Summary of Regular Expression Syntax" for more info about the syntax used. It just needs to check whether any placeholder is present. If so, it returns true as soon as it finds one.
If this method returns true, SendEmails will call ReplacePersonalizationPlaceholders on a per-subscriber basis, doing the real replacement of the placeholders with the values received as inputs. Following is the code for this method:
private static string ReplacePersonalizationPlaceholders(string text, SubscriberInfo subscriber, bool isHtml) { if (isHtml) { text = Regex.Replace(text, @"<%\s*username\s*%>", subscriber.UserName, RegexOptions.IgnoreCase | RegexOptions.Compiled); text = Regex.Replace(text, @"<%\s*email\s*%>", subscriber.Email, RegexOptions.IgnoreCase | RegexOptions.Compiled); text = Regex.Replace(text, @"<%\s*firstname\s*%>", (subscriber.FirstName.Length > 0 ? subscriber.FirstName : "reader"), RegexOptions.IgnoreCase | RegexOptions.Compiled); text = Regex.Replace(text, @"<%\s*lastname\s*%>", subscriber.LastName, RegexOptions.IgnoreCase | RegexOptions.Compiled); } else { text = Regex.Replace(text, @"<%\s*username\s*%>", subscriber.UserName, RegexOptions.IgnoreCase | RegexOptions.Compiled); text = Regex.Replace(text, @"<%\s*email\s*%>", subscriber.Email, RegexOptions.IgnoreCase | RegexOptions.Compiled); text = Regex.Replace(text, @"<%\s*firstname\s*%>", (subscriber.FirstName.Length > 0 ? subscriber.FirstName : "reader"), RegexOptions.IgnoreCase | RegexOptions.Compiled); text = Regex.Replace(text, @"<%\s*lastname\s*%>", subscriber.LastName, RegexOptions.IgnoreCase | RegexOptions.Compiled); } return text; }
Regular expressions are a very powerful tool to validate, and find/replace, substrings inside text. They enable you to define very complex patterns, and their processing can be much faster than working with the String class's Replace, Substring, IndexOf, and the other basic methods. Entire books have been written on the subject of regular expressions, such as Beginning Regular Expressions (Wrox Press, ISBN 0-7645-7489-2), or Teach Yourself Regular Expressions in 24 Hours (Sams Press, ISBN 0-672319-36-5).
In this last part of the "Solution" section you'll implement the administration pages for sending out a newsletter and checking its progress, as well as the end-user pages that display the list of archived newsletters, and the content of a specific newsletter. Finally, there is the NewsletterBox user control that you will plug into the site's master page, which creates a subscription box and a link to the archive page.
This page, located under the ~/Admin folder, allows any editor or administrator to create and send a newsletter. It's composed of two panels. The first panel includes the simple textboxes and the FCKeditor that will contain the newsletter's subject, plain-text and HTML body, and the Submit button. The other panel includes a message saying that another newsletter is already being sent, and it shows a link to the SendingNewsletter.aspx page (to show which newsletter is being sent). Which of these two panels will be shown when the page loads is determined according to the value of the static Newsletter .IsSending property. Here's the page's markup code:
<asp:Panel runat="server" ID="panSend"> <small><b>Subject:</b></small><br /> <asp:TextBox ID="txtSubject"runat="server"Width="99%" MaxLength="256"></asp:TextBox> <asp:RequiredFieldValidator ID="valRequireSubject" runat="server" ControlToValidate="txtSubject"SetFocusOnError="true" Text="The Subject field is required."Display="Dynamic" ValidationGroup="Newsletter"></asp:RequiredFieldValidator> <p></p> <small><b>Plain-text Body:</b></small><br /> <asp:TextBox ID="txtPlainTextBody" runat="server" Width="99%" TextMode="MultiLine" Rows="14"></asp:TextBox> <asp:RequiredFieldValidator ID="valRequirePlainTextBody" runat="server" ControlToValidate="txtPlainTextBody" SetFocusOnError="true" Text="The plain-text body is required." Display="Dynamic" ValidationGroup="Newsletter"></asp:RequiredFieldValidator> <p></p> <small><b>HTML Body:</b></small><br /> <fckeditorv2:fckeditor id="txtHtmlBody" runat="server" ToolbarSet="TheBeerHouse" Height="400px" Width="99%" /> <p></p> <asp:Button ID="btnSend" runat="server" Text="Send" ValidationGroup="Newsletter" OnClientClick="if (confirm( 'Are you sure you want to send the newsletter?') == false) return false;" OnClick="btnSend_Click" /> </asp:Panel> <asp:Panel ID="panWait" runat="server" Visible="false"> <asp:Label runat="server" id="lblWait" SkinID="FeedbackKO"> <p>Another newsletter is currently being sent. Please wait until it completes before compiling and sending a new one.</p><p>You can check the current newsletter's completion status from <a href="SendingNewsletter.aspx">this page</a>.</p> </asp:Label> </asp:Panel>
Note that there's no RequiredFieldValidator control for the HTML body editor; if HTML content is not specified, then the plain-text body will be used to create it. When the page loads, is shows or hides the two panels according to the value of IsSending, as shown below:
protected void Page_Load(object sender, EventArgs e) { bool isSending = false; Newsletter.Lock.AcquireReaderLock(Timeout.Infinite); isSending = Newsletter.IsSending; Newsletter.Lock.ReleaseReaderLock(); if (!this.IsPostBack && isSending) { panWait.Visible = true; panSend.Visible = false; } txtHtmlBody.BasePath = this.BaseUrl + "FCKeditor/"; }
Then, when the form is submitted, it checks the variable again, because another editor may have sent a newsletter in the meantime. In that case it shows the panel with the wait message, but it doesn't hide the panel with the form, so that users don't lose their own newsletter's text. If no newsletter is currently being sent, it sends this new one and redirects the user to the page showing the progress:
protected void btnSend_Click(object sender, EventArgs e) { bool isSending = false; Newsletter.Lock.AcquireReaderLock(Timeout.Infinite); isSending = Newsletter.IsSending; Newsletter.Lock.ReleaseReaderLock(); if (isSending) { panWait.Visible = true; } else { int id = Newsletter.SendNewsletter(txtSubject.Text, txtPlainTextBody.Text, txtHtmlBody.Value); this.Response.Redirect("SendingNewsletter.aspx"); } }
Note that the call to Newsletter.SendNewsletter is very simple — it's not obvious that the newsletter is sent asynchronously from looking at the preceding code. This is intentional: We're hiding the details of the complex code to free the UI developer from needing to know the underlying details. Figure 7-4 is a screenshot of the page with some plain-text and HTML content in the textboxes.
When the page loads, and another newsletter is already being sent, it will look like Figure 7-5.
The markup of this page can be split up into two parts: The first includes the code for the progress bar and the status information (or a panel with a message if no newsletter is being sent), and the other has the JavaScript callback function called after ASP.NET performs the lightweight postback to the server, to retrieve information about the progress of the newsletter being sent. The progress bar is rendered as two nested DIVs: The outer DIV is as large as the page's content section, and the inner DIV's width will be set to the completed percentage retrieved from the server, and updated every couple of seconds. There's also another DIV that will contain the number of e-mails sent and the number of total subscribers, as textual information. Finally, there's another DIV that's initially hidden, containing an image representing an "OK hand"; this will be displayed when the completion percentage reaches 100%. All three DIVs are assigned a fixed ID, so that it will be easy to reference them from the JavaScript code by means of the window.document.getElementById DOM function. Here's the code for this first part:
<asp:Panel ID="panProgress" runat="server"> <div class="progressbarcontainer"> <div class="progressbar" id="progressbar"></div> </div> <br /><br /> <div id="progressdescription"></div> <br /><br /> <div style="text-align: center; display: none;"id="panelcomplete"> <img src="../Images/100ok.gif" width="70px" /></div> </asp:Panel> <asp:Panel ID="panNoNewsletter" runat="server" Visible="false"> <b>No newsletter is currently being sent.</b> </asp:Panel>
The script of the second page contains an UpdateProgress function called by JavaScript after the lightweight postback. Its input parameter will be a string formatted like this: "completed_percentage ;num_sent_mails;num_tot_mails". The function splits the string on the semicolon character and uses the three pieces of information to update the progress bar's width and a status message formatted as "{completed_percentage}% completed - {num_sent_mails} out of {num_total_mails} have been sent.".
At the end of the function, if the percentage is 100%, it shows the DIV containing the image; otherwise, it sets up a timer that will call the CallUpdateProgress function after two seconds. The CallUpdate Progress function performs another lightweight postback to retrieve updated progress information from the server; it does this by means of the ASP.NET-generated WebForm_DoCallback function. The call to WebForm_DoCallback is not here, but generated on the server through the GetCallbackEvent Reference method of the page's ClientScript object property. Therefore, in the CallUpdate Progress function there's just a Literal control, which will be populated at runtime with the JavaScript call created on the server. Here's the final part of the .aspx page:
<script type="text/javascript"> function CallUpdateProgress() { <asp:Literal runat="server" ID="lblScriptName" />; } function UpdateProgress(result, context) { // result is a semicolon-separated list of values, so split it var params = result.split(";"); var percentage = params[0]; var sentMails = params[1]; var totalMails = params[2]; if (totalMails < 0) totalMails = '???'; // update progressbar's width and description text var progBar = window.document.getElementById('progressbar'); progBar.style.width = percentage + '%'; var descr = window.document.getElementById('progressdescription'); descr.innerHTML = '<b>' + percentage + '% completed</b> - ' + sentMails + ' out of ' + totalMails + ' e-mails have been sent.'; // if the current percentage is less than 100%, // recall the server callback method in 2 seconds if (percentage == '100') window.document.getElementById('panelcomplete').style.display = ''; else setTimeout('CallUpdateProgress()', 2000); } </script>
When the page loads, in the Page_Load event handler you check whether there's actually a newsletter being sent. If not, you just show a panel with a message saying so. Otherwise, you get a reference to the WebForm_DoCallback JavaScript function that performs the lightweight callback and which calls UpdateProgress when it gets the response from the server. This reference is used as Text for the Literal control defined inside the CallUpdateProgress JavaScript function described above. This makes the server-side callback, and the UpdateProgress function will be called every two seconds once the cycle has started.
However, to make it start the very first time, you must also call WebForm_DoCallback automatically when the form loads (instead of from the onclick or onchange client-side events of some control): To do this you just create a script that calls CallUpdateProgress (which in turns calls WebForm_DoCallback), and register it at the bottom of the form, by means of the ClientScriptManager's RegisterStartupScript method. Here's the code:
public partial class SendingNewsletter : BasePage, ICallbackEventHandler { protected void Page_Load(object sender, EventArgs e) { bool isSending = false; Newsletter.Lock.AcquireReaderLock(Timeout.Infinite); isSending = Newsletter.IsSending; Newsletter.Lock.ReleaseReaderLock(); if (!this.IsPostBack && !isSending) { panNoNewsletter.Visible = true; panProgress.Visible = false; } else { string callbackRef = this.ClientScript.GetCallbackEventReference( this, "", "UpdateProgress", "null"); lblScriptName.Text = callbackRef; this.ClientScript.RegisterStartupScript(this.GetType(), "StartUpdateProgress", @"<script type=""text/javascript"">CallUpdateProgress();</script>"); } }
The rest of the code-behind class is the implementation of the ICallbackEventHandler interface. In the RaiseCallbackEvent method, you create a semicolon-delimited string containing the three progress values needed to do the partial page update, and save it in a local field that is returned to the client by the GetCallbackResult method:
string callbackResult = ""; public string GetCallbackResult() { return callbackResult; } public void RaiseCallbackEvent(string eventArgument) { Newsletter.Lock.AcquireReaderLock(Timeout.Infinite); callbackResult = Newsletter.PercentageCompleted.ToString("N0") + ";" + Newsletter.SentMails.ToString() + ";" + Newsletter.TotalMails.ToString(); Newsletter.Lock.ReleaseReaderLock(); } }
Figure 7-6 shows the progress bar and the status information displayed on the page at runtime.
Figure 7-7 is a screenshot of the HTML newsletter represented in Figure 7-4 after it arrives in the subscriber's mailbox and is opened with Outlook Express.
This page uses a GridView control to list archived newsletters retrieved by a companion ObjectDataSource control by means of the Newsletter.GetNewsletters BLL method. The grid displays the newsletter's subject and data in the first column, and a Delete button in a second column. The second column will be visible only to editors and administrators, of course. The subject in the first column is rendered as a link that points to the ShowNewsletter.aspx page, with the newsletter's ID passed on the querystring. Following is the page's markup code:
<asp:GridView ID="gvwNewsletters" runat="server" AutoGenerateColumns="False" DataSourceID="objNewsletters" Width="100%" DataKeyNames="ID" OnRowCreated="gvwNewsletters_RowCreated" ShowHeader="false"> <Columns> <asp:TemplateField> <ItemTemplate> <img src="images/arrowr2.gif" alt="" style="vertical-align: middle; border-width: 0px;" /> <asp:HyperLink runat="server" ID="lnkNewsletter" Text='<%# Eval("Subject") %>' NavigateUrl='<%# "ShowNewsletter.aspx?ID="+ Eval("ID") %>' /> <small>(sent on <%# Eval("AddedDate", "{0:d}") %>)</small> </ItemTemplate> </asp:TemplateField> <asp:CommandField ButtonType="Image" DeleteImageUrl="~/Images/Delete.gif" DeleteText="Delete newsletter" ShowDeleteButton="True"> <ItemStyle HorizontalAlign="Center" Width="20px" /> </asp:CommandField> </Columns> <EmptyDataTemplate><b>No newsletters to show</b></EmptyDataTemplate> </asp:GridView> <asp:ObjectDataSource ID="objNewsletters" runat="server" TypeName="MB.TheBeerHouse.BLL.Newsletters.Newsletter" SelectMethod="GetNewsletters" DeleteMethod="DeleteNewsletter"> <DeleteParameters> <asp:Parameter Name="id" Type="Int32" /> </DeleteParameters> <SelectParameters> <asp:Parameter Name="toDate" Type="DateTime" /> </SelectParameters> </asp:ObjectDataSource>
Remember that the GetNewsletters business method takes a DateTime parameter as an input and returns only those newsletters that were sent prior to that date. The value for this parameter must be set from the code-behind class, early in the page's life cycle, such as in the Page_Init event. It assumes different values according to the current user: If the user belongs to the Administrators or Editors roles, the parameter is set to the current date and time, because those users are always allowed to see all newsletters, even if one was just sent a minute ago. Otherwise, for all other users, the parameter is set to the current date and time minus the number of days indicated by the hideFromArchiveInterval attribute of the <newsletters> custom configuration element. Here's the code:
protected void Page_Init(object sender, EventArgs e) { DateTime toDate = DateTime.Now; if (!this.User.Identity.IsAuthenticated || (!this.User.IsInRole("Administrators") && !this.User.IsInRole("Editors"))) { toDate = toDate.Subtract( new TimeSpan(Globals.Settings.Newsletters.HideFromArchiveInterval, 0, 0, 0)); } objNewsletters.SelectParameters["toDate"].DefaultValue = toDate.ToString("f"); }
Note that the DateTime value is converted to string (all ObjectDataSource parameters are set as string, and they will be parsed internally into the proper type expected by the SelectMethod being called) with the "f" format, which includes full date and time information, instead of just the date. This is required because if you only include the date part, newsletters sent on that date but after 00:00 will not be included in the results.
In the Page_Load event handler, you check whether the user is anonymous, in which case it redirects the user to the AccessDenied.aspx page if the configuration archiveIsPublic setting is false. If the user can access the page, the execution goes ahead, and you check whether the user is an editor or an administrator. If not, the grid's second column with the Delete button is hidden:
protected void Page_Load(object sender, EventArgs e) { if (!this.User.Identity.IsAuthenticated && !Globals.Settings.Newsletters.ArchiveIsPublic) this.RequestLogin(); gvwNewsletters.Columns[1].Visible = (this.User.Identity.IsAuthenticated && (this.User.IsInRole("Administrators") || this.User.IsInRole("Editors"))); }
Figure 7-8 is a screenshot of this page as seen by an editor.
This is the last page of the module, and also the simplest one. It takes the ID of a newsletter on the querystring, loads that newsletter by means of the Newsletter.GetNewsletterByID method, and displays its subject, plain-text and HTML body, in three Literal controls. The bodies may be very long, though, and to avoid having a very long page that requires a lot of scrolling, the Literals for the two body versions are declared inside <div> containers with a fixed height and a vertical scrolling bar. Here's the simple markup body:
<small><b>Plain-text Body:</b></small> <div style="border: dashed 1px black; overflow: auto; width: 98%; height: 300px; padding: 5px;"> <asp:Literal runat="server" ID="lblPlaintextBody" /> </div> <p></p> <small><b>HTML Body:</b></small> <div style="border: dashed 1px black; overflow: auto; width: 98%; height: 300px; padding: 5px;"> <asp:Literal runat="server" ID="lblHtmlBody" /> </div>
In the Page_Load event handler, you first confirm that the current user has the right to see this newsletter: You use the same constraints described for the ArchivedNewsletter.aspx page, and the date of the specific newsletter being sent must not be more recent than the current date minus the number of days specified in the configuration. Then you display the data on the page: Note that the plain-text body is encoded, so that if it contains special characters such as < and > they are not interpreted as HTML tags:
protected void Page_Load(object sender, EventArgs e) { if (!this.User.Identity.IsAuthenticated && !Globals.Settings.Newsletters.ArchiveIsPublic) this.RequestLogin(); // load the newsletter with the ID passed on the querystring Newsletter newsletter = Newsletter.GetNewsletterByID( int.Parse(this.Request.QueryString["ID"])); // check that the newsletter can be viewed, according to the number of days // that must pass before it is published in the archive int days = ((TimeSpan)(DateTime.Now - newsletter.AddedDate)).Days; if (Globals.Settings.Newsletters.HideFromArchiveInterval > days && (!this.User.Identity.IsAuthenticated || (!this.User.IsInRole("Administrators") && !this.User.IsInRole("Editors")))) this.RequestLogin(); // show the newsletter's data this.Title += newsletter.Subject; lblSubject.Text = newsletter.Subject; lblPlaintextBody.Text = HttpUtility.HtmlEncode( newsletter.PlainTextBody).Replace(" ", " ").Replace( "\t", " ").Replace("\n", "<br/>");; lblHtmlBody.Text = newsletter.HtmlBody; }
Figure 7-9 shows the newsletter sent earlier, and now archived.
The NewsletterBox control (located in ~/Controls/NewsletterBox.ascx) displays a different output depending on whether the current user is anonymous. It uses a LoginView control, first introduced in Chapter 4, to do this through its AnonymousTemplate and LoggedInTemplate sections. In the first case, it invites the user to fill a textbox with her e-mail address and to press the OK button to subscribe. In the latter case it just shows a link to the UserProfile.aspx page where the existing member can change her subscription type. In both cases a link to the archive page is displayed also. When the anonymous user clicks the OK button, there is no postback; instead, the button's client-side OnClick event is handled, and the browser is redirected to the Register.aspx page, with the current content of the e-mail textbox passed on the querystring. Here's the whole markup code and the companion JavaScript code:
<asp:LoginView ID="LoginView1" runat="server"> <AnonymousTemplate> <b>Register to the site for free</b>, and subscribe to the newsletter. Every month you will receive new articles and special content not available elsewhere on the site, right into your e-mail box!<br /><br /> <input type="text" id="NewsletterEmail" value="E-mail here" onfocus="javascript:this.value = '';" style="width: 140px;" /> <input type="button" value="OK" onclick="javascript:SubscribeToNewsletter();"/> </AnonymousTemplate> <LoggedInTemplate> You can change your subscription(plain-text, HTML or no newsletter) from your <asp:HyperLink runat="server" ID="lnkProfile" NavigateUrl="~/EditProfile.aspx" Text="profile" /> page. Click the link below to read the newsletters run in the past. </LoggedInTemplate> </asp:LoginView> <p></p> <asp:HyperLink runat="server" ID="lnkArchive" NavigateUrl="~/ArchivedNewsletters.aspx" Text="Archived Newsletters" /> <script type="text/javascript"> function SubscribeToNewsletter() { var email = window.document.getElementById('NewsletterEmail').value; window.document.location.href='Register.aspx?Email=' + email; } </script>
In the Register.aspx page you have to modify the declaration of the Email textbox so that its Text property is bound to a field declared in the code-behind class. This is the updated declaration:
<asp:TextBox runat="server" ID="Email" Width="100%" Text='<%# Email %>' />
In the Page_Load event handler you check whether an Email parameter was passed on the querystring (it's not there if the user reaches the page directly with the link at the top of the page's layout, and not through the NewsletterBox control). If it is found, the Email field is set to its value, and you call the DataBind method of the CreateUserWizard control. This will, in turn, call DataBind for all its child controls, including the Email textbox:
protected string Email = ""; protected void Page_Load(object sender, EventArgs e) { if (!this.IsPostBack && !string.IsNullOrEmpty(this.Request.QueryString["Email"])) { Email = this.Request.QueryString["Email"]; CreateUserWizard1.DataBind(); } }
Note that you can't call the DataBind method of the Email textbox directly, because that control is declared inside a Template section, and as such will be dynamically created at runtime.
Now that the user control is complete, you reference it at the top of the master.template master page, and create a new instance of it in the right-hand column (see Figure 7-10):
<%@ Register Src="Controls/NewsletterBox.ascx" TagName="NewsletterBox" TagPrefix="mb" %> ... <mb:NewsletterBox id="NewsletterBox1" runat="server" />
Figure 7-11 shows just the NewsletterBox as shown by an authenticated member.