Creating ‘mailto’ and ‘tel’ Link Handlers in C#

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 in C#. 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:

  public abstract class WebLink {
    /// <summary&gt;
    /// Link prefix. Examples are: 'mailto:' and 'tel:'
    /// </summary&gt;
    public abstract string Prefix { get; }

    /// <summary&gt;
    /// Clears instance fields.
    /// </summary&gt;
    public abstract void ClearFields();

    /// <summary&gt;
    /// Loads link input into relevant fields.
    /// </summary&gt;
    public virtual void ReadLink(string link) {
      if (link == null)
        throw new ArgumentNullException("link");

      if (link.ToLower().StartsWith(Prefix.ToLower()) == false)
        throw new FormatException("Invalid link.");
    }

    /// <summary&gt;
    /// Generates link from instance fields.
    /// </summary&gt;
    public virtual string GenerateLink(bool includePrefix) {
      var str = string.Empty;

      if (includePrefix)
        str += Prefix;

      return str;
    }

    /// <summary&gt;
    /// Can be used to exclude prefix from a link string.
    /// </summary&gt;
    protected string ExcludePrefix(string link) {
      link = link.Trim();
      if (link.ToLower().StartsWith(Prefix.ToLower()))
        link = link.Substring(Prefix.Length).Trim();
      return link;
    }

    public override string ToString() {
      return GenerateLink(true);
    }
  }

The code is self-explanatory. Every child class will have to fill its link prefix, adds 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:

  public class MailWebLink : WebLink {
    #region Prefix
    protected static string LinkPrefix { get { return "mailto:"; } }
    public override string Prefix =&gt; LinkPrefix;
    #endregion

    #region Delimiters
    protected static readonly char[] MailDelimiters = new char[] { '?' };
    protected static readonly char[] RecipientDelimiters = new char[] { ',', ';' };
    protected static readonly char[] ParamDelimiters = new char[] { '&amp;' };
    protected static readonly char[] ParamValueDelimiters = new char[] { '=' };
    #endregion

    #region Field Names
    protected static readonly string ToField = "to";
    protected static readonly string CcField = "cc";
    protected static readonly string BccField = "bcc";
    protected static readonly string SubjectField = "subject";
    protected static readonly string BodyField = "body";
    #endregion


    #region Fields
    public string[] To { get; set; }
    public string[] Cc { get; set; }
    public string[] Bcc { get; set; }
    public string Subject { get; set; }
    public string Body { get; set; }
    #endregion

    public MailWebLink() {

    }
    public MailWebLink(string link) {
      ReadLink(link);
    }

    public static bool CanHandle(string link) {
      return link.ToLower().Trim().StartsWith(LinkPrefix);
    }

    #region Link Loading
    public override void ClearFields() {
      To = Cc = Bcc = null;
      Subject = Body = null;
    }

    public override void ReadLink(string link) {
      base.ReadLink(link);

      try {
        ClearFields();

        // Exclude prefix if necessary
        link = ExcludePrefix(link);

        // Get mail 'To' Field
        string tmpVal = null;
        int idx = -1;

        idx = link.IndexOfAny(MailDelimiters);

        if (idx &gt; -1)
          tmpVal = link.Substring(0, idx);
        else
          tmpVal = link;

        this.To = LoadRecipients(tmpVal).ToArray();

        if (idx == -1)
          return;

        link = link.Substring(idx + 1);

        // Handle rest of fields
        var parameters = GetParameters(link, true);
        foreach (var par in parameters) {
          if (par.Key == ToField) // overrides the above code
            this.To = LoadRecipients(par.Value).ToArray();
          else if (par.Key == CcField)
            this.Cc = LoadRecipients(par.Value).ToArray();
          else if (par.Key == BccField)
            this.Bcc = LoadRecipients(par.Value).ToArray();
          else if (par.Key == SubjectField)
            this.Subject = par.Value;
          else if (par.Key == BodyField)
            this.Body = par.Value;
        }
      } catch {
        throw new FormatException();
      }
    }

    /// <summary&gt;
    /// Splits a mail string into a list of mail addresses.
    /// </summary&gt;
    protected virtual IEnumerable<string&gt; LoadRecipients(string val) {
      var items = val.Split(RecipientDelimiters, StringSplitOptions.RemoveEmptyEntries);
      return items.Select(s =&gt; s.Trim().ToLower()).Distinct();
    }

    /// <summary&gt;
    /// Splits a parameter string into a list of parameters (kay and value)
    /// </summary&gt;
    /// <param name="skipEmpty"&gt;Whether to skip empty parameters.</param&gt;
    protected virtual IEnumerable<KeyValuePair<string, string&gt;&gt; GetParameters(string val, bool skipEmpty = true) {
      var items = val.Split(ParamDelimiters, StringSplitOptions.RemoveEmptyEntries);

      foreach (var itm in items) {
        string key = string.Empty;
        string value = string.Empty;

        var delimiterIdx = itm.IndexOfAny(ParamValueDelimiters);
        if (delimiterIdx == -1)
          continue;

        key = itm.Substring(0, delimiterIdx).ToLower();
        value = itm.Substring(delimiterIdx + 1);
        value = UnscapeParamValue(value);

        if (key.Length == 0)
          continue;

        if (skipEmpty &amp;&amp; value.Length == 0)
          continue;

        yield return new KeyValuePair<string, string&gt;(key, value);
      }
    }
    #endregion

    #region Link Generation

    public virtual string GetLink() { return GenerateLink(true); }

    public override string GenerateLink(bool includePrefix) {
      string str = base.GenerateLink(includePrefix);

      if (this.To != null &amp;&amp; this.To.Length &gt; 0) {
        str += GetRecipientString(this.To);
      }

      str += MailDelimiters.First();

      if (this.Cc != null &amp;&amp; this.Cc.Length &gt; 0) {
        str += GetParameterString(CcField, GetRecipientString(this.Cc), false);
        str += ParamDelimiters.First();
      }

      if (this.Bcc != null &amp;&amp; this.Bcc.Length &gt; 0) {
        str += GetParameterString(BccField, GetRecipientString(this.Bcc), false);
        str += ParamDelimiters.First();
      }

      if (this.Subject != null &amp;&amp; this.Subject.Length &gt; 0) {
        str += GetParameterString(SubjectField, this.Subject, true);
        str += ParamDelimiters.First();
      }

      if (this.Body != null &amp;&amp; this.Body.Length &gt; 0) {
        str += GetParameterString(BodyField, this.Body, true);
        str += ParamDelimiters.First();
      }

      str = str.TrimEnd(MailDelimiters.Concat(ParamDelimiters).ToArray());

      return str;
    }

    /// <summary&gt;
    /// Joins a list of mail addresses into a string
    /// </summary&gt;
    protected virtual string GetRecipientString(string[] recipients) {
      return string.Join(RecipientDelimiters.First().ToString(), recipients);
    }

    /// <summary&gt;
    /// Joins a parameter (key and value) into a string
    /// </summary&gt;
    /// <param name="escapeValue"&gt;Whether to escape value.</param&gt;
    protected virtual string GetParameterString(string key, string value, bool escapeValue) {
      return string.Format("{0}{1}{2}",
        key,
        ParamValueDelimiters.First(),
        escapeValue ? EscapeParamValue(value) : value);
    }

    #endregion

    #region Helpers
    protected static readonly Dictionary<string, string&gt; CustomUnescapeCharacters =
      new Dictionary<string, string&gt;() { { "+", " " } };

    private static string EscapeParamValue(string value) {
      return Uri.EscapeDataString(value);
    }

    private static string UnscapeParamValue(string value) {
      foreach (var customChar in CustomUnescapeCharacters) {
        if (value.Contains(customChar.Key))
          value = value.Replace(customChar.Key, customChar.Value);
      }

      return Uri.UnescapeDataString(value);
    }
    #endregion
  }

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:

mailto:someone@yoursite.com
mailto:someone@yoursite.com?subject=Important!&body=Hi.
mailto:someone@yoursite.com?cc=someoneelse@theirsite.com,another@thatsite.com,me@mysite.com&bcc=lastperson@theirsite.com
mailto:name1@yoursite.com?cc=name2@yoursite.com&bcc=name3@yoursite.com&subject=The%20subject%20of%20the%20email&body=The%20body%20of%20the%20email

tel

The tel handler is more straightforward than mailto:

  public class TelephoneWebLink : WebLink {
    #region Prefix
    protected static string LinkPrefix { get { return "tel:"; } }
    public override string Prefix => LinkPrefix;
    #endregion

    #region Delimiters
    protected static readonly char ExtensionDelimiter = 'p';
    #endregion

    #region Fields
    public string Number { get; set; }
    public string Extension { get; set; }
    #endregion


    public TelephoneWebLink() {

    }
    public TelephoneWebLink(string link) {
      ReadLink(link);
    }

    public static bool CanHandle(string link) {
      return link.ToLower().Trim().StartsWith(LinkPrefix);
    }

    public override void ClearFields() {
      Number = null;
      Extension = null;
    }

    public override void ReadLink(string link) {
      base.ReadLink(link);

      try {
        ClearFields();

        // Exclude prefix if necessary
        link = ExcludePrefix(link).Trim();

        Number = string.Empty;
        Extension = string.Empty;

        bool foundExtension = false;
        int idx = 0;
        foreach (var c in link) {
          if (idx == 0 && c == '+')
            Number += "+";
          if (c == ExtensionDelimiter)
            foundExtension = true;
          else if (char.IsDigit(c)) {
            if (foundExtension == false)
              Number += c.ToString();
            else
              Extension += c.ToString();
          }
          idx++;
        }

      } catch {
        throw new FormatException();
      }
    }

    public override string GenerateLink(bool includePrefix) {
      var str = base.GenerateLink(includePrefix);

      if (Number != null)
        str += Number.ToString();

      if (Extension != null && Extension.Length > 0)
        str += ExtensionDelimiter.ToString() + Extension;

      return str;
    }
  }

And here’s a list to test:

tel:+20123456789
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.