Overview

Many business websites show their email addresses and phone numbers so their customers can contact them. In this lesson we will create wrapper classes around ‘mailto’ and ‘tel’ HTML links. Those classes will allow you to read and generate those links with ease.

Introduction

If you need an introduction to mailto and tel links please check this and this.

Base

To avoid duplication, we will start by laying out our base class.
  1. public abstract class WebLink {
  2. /// <summary>
  3. /// Link prefix. Examples are: 'mailto:' and 'tel:'
  4. /// </summary>
  5. public abstract string Prefix { get; }
  6. /// <summary>
  7. /// Clears instance fields.
  8. /// </summary>
  9. public abstract void ClearFields();
  10. /// <summary>
  11. /// Loads link input into relevant fields.
  12. /// </summary>
  13. public virtual void ReadLink(string link) {
  14. if (link == null)
  15. throw new ArgumentNullException("link");
  16. if (link.ToLower().StartsWith(Prefix.ToLower()) == false)
  17. throw new FormatException("Invalid link.");
  18. }
  19. /// <summary>
  20. /// Generates link from instance fields.
  21. /// </summary>
  22. public virtual string GenerateLink(bool includePrefix) {
  23. var str = string.Empty;
  24. if (includePrefix)
  25. str += Prefix;
  26. return str;
  27. }
  28. /// <summary>
  29. /// Can be used to exclude prefix from a link string.
  30. /// </summary>
  31. protected string ExcludePrefix(string link) {
  32. link = link.Trim();
  33. if (link.ToLower().StartsWith(Prefix.ToLower()))
  34. link = link.Substring(Prefix.Length).Trim();
  35. return link;
  36. }
  37. public override string ToString() {
  38. return GenerateLink(true);
  39. }
  40. }
The code is self-explanatory. Every child class will have to fill its link prefix, add some code to clear its fields, and some other code to read and write links.

mailto

Now we are going to inherit from the base class to create the mailto handler,
  1. public class MailWebLink : WebLink {
  2. #region Prefix
  3. protected static string LinkPrefix { get { return "mailto:"; } }
  4. public override string Prefix => LinkPrefix;
  5. #endregion
  6. #region Delimiters
  7. protected static readonly char[] MailDelimiters = new char[] { '?' };
  8. protected static readonly char[] RecipientDelimiters = new char[] { ',', ';' };
  9. protected static readonly char[] ParamDelimiters = new char[] { '&' };
  10. protected static readonly char[] ParamValueDelimiters = new char[] { '=' };
  11. #endregion
  12. #region Field Names
  13. protected static readonly string ToField = "to";
  14. protected static readonly string CcField = "cc";
  15. protected static readonly string BccField = "bcc";
  16. protected static readonly string SubjectField = "subject";
  17. protected static readonly string BodyField = "body";
  18. #endregion
  19. #region Fields
  20. public string[] To { get; set; }
  21. public string[] Cc { get; set; }
  22. public string[] Bcc { get; set; }
  23. public string Subject { get; set; }
  24. public string Body { get; set; }
  25. #endregion
  26. public MailWebLink() {
  27. }
  28. public MailWebLink(string link) {
  29. ReadLink(link);
  30. }
  31. public static bool CanHandle(string link) {
  32. return link.ToLower().Trim().StartsWith(LinkPrefix);
  33. }
  34. #region Link Loading
  35. public override void ClearFields() {
  36. To = Cc = Bcc = null;
  37. Subject = Body = null;
  38. }
  39. public override void ReadLink(string link) {
  40. base.ReadLink(link);
  41. try {
  42. ClearFields();
  43. // Exclude prefix if necessary
  44. link = ExcludePrefix(link);
  45. // Get mail 'To' Field
  46. string tmpVal = null;
  47. int idx = -1;
  48. idx = link.IndexOfAny(MailDelimiters);
  49. if (idx > -1)
  50. tmpVal = link.Substring(0, idx);
  51. else
  52. tmpVal = link;
  53. this.To = LoadRecipients(tmpVal).ToArray();
  54. if (idx == -1)
  55. return;
  56. link = link.Substring(idx + 1);
  57. // Handle rest of fields
  58. var parameters = GetParameters(link, true);
  59. foreach (var par in parameters) {
  60. if (par.Key == ToField) // overrides the above code
  61. this.To = LoadRecipients(par.Value).ToArray();
  62. else if (par.Key == CcField)
  63. this.Cc = LoadRecipients(par.Value).ToArray();
  64. else if (par.Key == BccField)
  65. this.Bcc = LoadRecipients(par.Value).ToArray();
  66. else if (par.Key == SubjectField)
  67. this.Subject = par.Value;
  68. else if (par.Key == BodyField)
  69. this.Body = par.Value;
  70. }
  71. } catch {
  72. throw new FormatException();
  73. }
  74. }
  75. /// <summary>
  76. /// Splits a mail string into a list of mail addresses.
  77. /// </summary>
  78. protected virtual IEnumerable<string> LoadRecipients(string val) {
  79. var items = val.Split(RecipientDelimiters, StringSplitOptions.RemoveEmptyEntries);
  80. return items.Select(s => s.Trim().ToLower()).Distinct();
  81. }
  82. /// <summary>
  83. /// Splits a parameter string into a list of parameters (kay and value)
  84. /// </summary>
  85. /// <param name="skipEmpty">Whether to skip empty parameters.</param>
  86. protected virtual IEnumerable<KeyValuePair<string, string>> GetParameters(string val, bool skipEmpty = true) {
  87. var items = val.Split(ParamDelimiters, StringSplitOptions.RemoveEmptyEntries);
  88. foreach (var itm in items) {
  89. string key = string.Empty;
  90. string value = string.Empty;
  91. var delimiterIdx = itm.IndexOfAny(ParamValueDelimiters);
  92. if (delimiterIdx == -1)
  93. continue;
  94. key = itm.Substring(0, delimiterIdx).ToLower();
  95. value = itm.Substring(delimiterIdx + 1);
  96. value = UnscapeParamValue(value);
  97. if (key.Length == 0)
  98. continue;
  99. if (skipEmpty && value.Length == 0)
  100. continue;
  101. yield return new KeyValuePair<string, string>(key, value);
  102. }
  103. }
  104. #endregion
  105. #region Link Generation
  106. public virtual string GetLink() { return GenerateLink(true); }
  107. public override string GenerateLink(bool includePrefix) {
  108. string str = base.GenerateLink(includePrefix);
  109. if (this.To != null && this.To.Length > 0) {
  110. str += GetRecipientString(this.To);
  111. }
  112. str += MailDelimiters.First();
  113. if (this.Cc != null && this.Cc.Length > 0) {
  114. str += GetParameterString(CcField, GetRecipientString(this.Cc), false);
  115. str += ParamDelimiters.First();
  116. }
  117. if (this.Bcc != null && this.Bcc.Length > 0) {
  118. str += GetParameterString(BccField, GetRecipientString(this.Bcc), false);
  119. str += ParamDelimiters.First();
  120. }
  121. if (this.Subject != null && this.Subject.Length > 0) {
  122. str += GetParameterString(SubjectField, this.Subject, true);
  123. str += ParamDelimiters.First();
  124. }
  125. if (this.Body != null && this.Body.Length > 0) {
  126. str += GetParameterString(BodyField, this.Body, true);
  127. str += ParamDelimiters.First();
  128. }
  129. str = str.TrimEnd(MailDelimiters.Concat(ParamDelimiters).ToArray());
  130. return str;
  131. }
  132. /// <summary>
  133. /// Joins a list of mail addresses into a string
  134. /// </summary>
  135. protected virtual string GetRecipientString(string[] recipients) {
  136. return string.Join(RecipientDelimiters.First().ToString(), recipients);
  137. }
  138. /// <summary>
  139. /// Joins a parameter (key and value) into a string
  140. /// </summary>
  141. /// <param name="escapeValue">Whether to escape value.</param>
  142. protected virtual string GetParameterString(string key, string value, bool escapeValue) {
  143. return string.Format("{0}{1}{2}",
  144. key,
  145. ParamValueDelimiters.First(),
  146. escapeValue ? EscapeParamValue(value) : value);
  147. }
  148. #endregion
  149. #region Helpers
  150. protected static readonly Dictionary<string, string> CustomUnescapeCharacters =
  151. new Dictionary<string, string>() { { "+", " " } };
  152. private static string EscapeParamValue(string value) {
  153. return Uri.EscapeDataString(value);
  154. }
  155. private static string UnscapeParamValue(string value) {
  156. foreach (var customChar in CustomUnescapeCharacters) {
  157. if (value.Contains(customChar.Key))
  158. value = value.Replace(customChar.Key, customChar.Value);
  159. }
  160. return Uri.UnescapeDataString(value);
  161. }
  162. #endregion
  163. }
The code is fairly simple. One thing to note is that Uri.UnescapeDataString cannot convert ‘+’ to a space. That’s why we added a dictionary of custom un-escape characters.
Now here’s a list of input to test back and forth:
  1. mailto:[email protected]
  2. mailto:[email protected]?subject=Important!&body=Hi.
  3. mailto:[email protected][email protected],[email protected],[email protected]&[email protected]
  4. mailto:[email protected][email protected]&[email protected]&subject=The%20subject%20of%20the%20email&body=The%20body%20of%20the%20email

tel

The tel handler is more straightforward than mailto,
  1. public class TelephoneWebLink : WebLink {
  2. #region Prefix
  3. protected static string LinkPrefix { get { return "tel:"; } }
  4. public override string Prefix => LinkPrefix;
  5. #endregion
  6. #region Delimiters
  7. protected static readonly char ExtensionDelimiter = 'p';
  8. #endregion
  9. #region Fields
  10. public string Number { get; set; }
  11. public string Extension { get; set; }
  12. #endregion
  13. public TelephoneWebLink() {
  14. }
  15. public TelephoneWebLink(string link) {
  16. ReadLink(link);
  17. }
  18. public static bool CanHandle(string link) {
  19. return link.ToLower().Trim().StartsWith(LinkPrefix);
  20. }
  21. public override void ClearFields() {
  22. Number = null;
  23. Extension = null;
  24. }
  25. public override void ReadLink(string link) {
  26. base.ReadLink(link);
  27. try {
  28. ClearFields();
  29. // Exclude prefix if necessary
  30. link = ExcludePrefix(link).Trim();
  31. Number = string.Empty;
  32. Extension = string.Empty;
  33. bool foundExtension = false;
  34. int idx = 0;
  35. foreach (var c in link) {
  36. if (idx == 0 && c == '+')
  37. Number += "+";
  38. if (c == ExtensionDelimiter)
  39. foundExtension = true;
  40. else if (char.IsDigit(c)) {
  41. if (foundExtension == false)
  42. Number += c.ToString();
  43. else
  44. Extension += c.ToString();
  45. }
  46. idx++;
  47. }
  48. } catch {
  49. throw new FormatException();
  50. }
  51. }
  52. public override string GenerateLink(bool includePrefix) {
  53. var str = base.GenerateLink(includePrefix);
  54. if (Number != null)
  55. str += Number.ToString();
  56. if (Extension != null && Extension.Length > 0)
  57. str += ExtensionDelimiter.ToString() + Extension;
  58. return str;
  59. }
  60. }
And here’s a list to test:
  1. tel:+20123456789
  2. tel:+20123456789p113

What’s next?

You can use the same mechanism for any other special link. Adrian Ber wrote a very useful blog post about those links. Moreover, in a future post we will see how this mechanism can be integrated into Android WebView to allow your application to respond to mailto and tel links.
Many refactoring patterns and fixes can be applied to the code. Please feel free to comment with your updated code.