File: System\Net\Mime\SmtpDateTime.cs
Web Access
Project: src\src\libraries\System.Net.Mail\src\System.Net.Mail.csproj (System.Net.Mail)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
 
namespace System.Net.Mime
{
    #region RFC2822 date time string format description
    // Format of Date Time string as described by RFC 2822 section 4.3 which obsoletes
    // some field formats that were allowed under RFC 822
 
    // date-time       =       [ day-of-week "," ] date FWS time [CFWS]
    // day-of-week     =       ([FWS] day-name) / obs-day-of-week
    // day-name        =       "Mon" / "Tue" / "Wed" / "Thu" / "Fri" / "Sat" / "Sun"
    // date            =       day month year
    // year            =       4*DIGIT / obs-year
    // month           =       (FWS month-name FWS) / obs-month
    // month-name      =       "Jan" / "Feb" / "Mar" / "Apr" / "May" / "Jun" / "Jul" / "Aug" /
    //                         "Sep" / "Oct" / "Nov" / "Dec"
    // day             =       ([FWS] 1*2DIGIT) / obs-day
    // time            =       time-of-day FWS zone
    // time-of-day     =       hour ":" minute [ ":" second ]
    // hour            =       2DIGIT / obs-hour
    // minute          =       2DIGIT / obs-minute
    // second          =       2DIGIT / obs-second
    // zone            =       (( "+" / "-" ) 4DIGIT) / obs-zone
    #endregion
 
    // stores a Date and a Time Zone.  These are parsed and formatted according to the
    // rules in RFC 2822 section 3.3.
    // This class is immutable
    internal sealed class SmtpDateTime
    {
        #region constants
 
        // use this when a time zone is unknown or is not supplied
        internal const string UnknownTimeZoneDefaultOffset = "-0000";
        internal const string UtcDefaultTimeZoneOffset = "+0000";
        internal const int OffsetLength = 5;
 
        // range for absolute value of minutes.  it is not necessary to include a max value for hours since
        // the two-digit value that is parsed can't exceed the max value of hours, which is 99
        internal const int MaxMinuteValue = 59;
 
        #endregion
 
        #region static fields
 
        // array of all possible date time values
        // these do NOT include the timezone
        // if a string matches any one of these it will be parsed correctly
        internal static readonly string[] s_validDateTimeFormats = new string[]
        {
            "ddd, dd MMM yyyy HH:mm:ss", // with day of week
            "dd MMM yyyy HH:mm:ss",      // without day of week
            "ddd, dd MMM yyyy HH:mm",    // with day of week and without seconds
            "dd MMM yyyy HH:mm"          // without day of week and without seconds
        };
 
        internal static readonly Dictionary<string, TimeSpan> s_timeZoneOffsetLookup = new Dictionary<string, TimeSpan>()
        {
            // all well-known short hand time zone values and their semantic equivalents
            { "UT", TimeSpan.Zero },           // +0000
            { "GMT", TimeSpan.Zero },          // +0000
            { "EDT", new TimeSpan(-4, 0, 0) }, // -0400
            { "EST", new TimeSpan(-5, 0, 0) }, // -0500
            { "CDT", new TimeSpan(-5, 0, 0) }, // -0500
            { "CST", new TimeSpan(-6, 0, 0) }, // -0600
            { "MDT", new TimeSpan(-6, 0, 0) }, // -0600
            { "MST", new TimeSpan(-7, 0, 0) }, // -0700
            { "PDT", new TimeSpan(-7, 0, 0) }, // -0700
            { "PST", new TimeSpan(-8, 0, 0) }, // -0800
        };
 
        // a TimeSpan must be between these two values in order for it to be within the range allowed
        // by RFC 2822
        internal const long TimeSpanMaxTicks = TimeSpan.TicksPerHour * 99 + TimeSpan.TicksPerMinute * 59;
 
        // allowed max values for each digit.  min value is always 0
        internal const int OffsetMaxValue = 9959;
 
        #endregion
 
        #region private fields
 
        private readonly DateTime _date;
        private readonly TimeSpan _timeZone;
 
        // true if the time zone is unspecified i.e. -0000
        // the time zone will usually be specified
        private readonly bool _unknownTimeZone;
 
        #endregion
 
        #region constructors
 
        internal SmtpDateTime(DateTime value)
        {
            _date = value;
 
            switch (value.Kind)
            {
                case DateTimeKind.Local:
                    // GetUtcOffset takes local time zone information into account e.g. daylight savings time
                    TimeSpan localTimeZone = TimeZoneInfo.Local.GetUtcOffset(value);
                    _timeZone = ValidateAndGetSanitizedTimeSpan(localTimeZone);
                    break;
 
                case DateTimeKind.Unspecified:
                    _unknownTimeZone = true;
                    break;
 
                case DateTimeKind.Utc:
                    _timeZone = TimeSpan.Zero;
                    break;
            }
        }
 
        internal SmtpDateTime(string value)
        {
            string timeZoneOffset;
            _date = ParseValue(value, out timeZoneOffset);
 
            if (!TryParseTimeZoneString(timeZoneOffset, out _timeZone))
            {
                // time zone is unknown
                _unknownTimeZone = true;
            }
        }
 
        #endregion
 
        #region internal properties
 
        internal DateTime Date
        {
            get
            {
                if (_unknownTimeZone)
                {
                    return DateTime.SpecifyKind(_date, DateTimeKind.Unspecified);
                }
                else
                {
                    // DateTimeOffset will convert the value of this.date to the time as
                    // specified in this.timeZone
                    DateTimeOffset offset = new DateTimeOffset(_date, _timeZone);
                    return offset.LocalDateTime;
                }
            }
        }
 
        #endregion
 
        #region internals
 
        // outputs the RFC 2822 formatted date string including time zone
        public override string ToString()
        {
            TimeSpan timeZone = _timeZone;
            Debug.Assert(timeZone.Seconds == 0, "Span had seconds value");
            Debug.Assert(timeZone.Milliseconds == 0, "Span had milliseconds value");
 
            return _unknownTimeZone || timeZone.Ticks == 0 ?
                string.Create(CultureInfo.InvariantCulture, $"{_date:ddd, dd MMM yyyy HH:mm:ss} {(_unknownTimeZone ? UnknownTimeZoneDefaultOffset : UtcDefaultTimeZoneOffset)}") :
                string.Create(CultureInfo.InvariantCulture, $"{_date:ddd, dd MMM yyyy HH:mm:ss} {(timeZone.Ticks > 0 ? '+' : '-')}{timeZone:hhmm}");
        }
 
        // returns true if the offset is of the form [+|-]dddd and
        // within the range 0000 to 9959
        internal static void ValidateAndGetTimeZoneOffsetValues(string offset, out bool positive, out int hours, out int minutes)
        {
            Debug.Assert(!string.IsNullOrEmpty(offset), "violation of precondition: offset must not be null or empty");
            Debug.Assert(offset != UnknownTimeZoneDefaultOffset, "Violation of precondition: do not pass an unknown offset");
            Debug.Assert(offset.StartsWith('-') || offset.StartsWith('+'), "offset initial character was not a + or -");
 
            if (offset.Length != OffsetLength)
            {
                throw new FormatException(SR.MailDateInvalidFormat);
            }
 
            positive = offset.StartsWith('+');
 
            // TryParse will parse in base 10 by default.  do not allow any styles of input beyond the default
            // which is numeric values only
            if (!int.TryParse(offset.AsSpan(1, 2), NumberStyles.None, CultureInfo.InvariantCulture, out hours))
            {
                throw new FormatException(SR.MailDateInvalidFormat);
            }
 
            if (!int.TryParse(offset.AsSpan(3, 2), NumberStyles.None, CultureInfo.InvariantCulture, out minutes))
            {
                throw new FormatException(SR.MailDateInvalidFormat);
            }
 
            // we only explicitly validate the minutes.  they must be below 59
            // the hours are implicitly validated as a number formed from a string of length
            // 2 can only be <= 99
            if (minutes > MaxMinuteValue)
            {
                throw new FormatException(SR.MailDateInvalidFormat);
            }
        }
 
        // returns true if the time zone short hand is all alphabetical characters
        internal static void ValidateTimeZoneShortHandValue(string value)
        {
            // time zones can't be empty
            Debug.Assert(!string.IsNullOrEmpty(value), "violation of precondition: offset must not be null or empty");
 
            // time zones must all be alphabetical characters
            for (int i = 0; i < value.Length; i++)
            {
                if (!char.IsLetter(value, i))
                {
                    throw new FormatException(SR.Format(SR.MailHeaderFieldInvalidCharacter, value));
                }
            }
        }
 
        // parses the date and time zone
        // postconditions:
        // return value is valid DateTime representation of the Date portion of data
        // timeZone is the portion of data which should contain the time zone data
        // timeZone is NOT evaluated by ParseValue
        internal static DateTime ParseValue(string data, out string timeZone)
        {
            // check that there is something to parse
            if (string.IsNullOrEmpty(data))
            {
                throw new FormatException(SR.MailDateInvalidFormat);
            }
 
            // find the first occurrence of ':'
            // this tells us where the separator between hour and minute are
            int indexOfHourSeparator = data.IndexOf(':');
 
            // no ':' means invalid value
            if (indexOfHourSeparator == -1)
            {
                throw new FormatException(SR.Format(SR.MailHeaderFieldInvalidCharacter, data));
            }
 
            // now we know where hours and minutes are separated.  The first whitespace after
            // that MUST be the separator between the time portion and the timezone portion
            // timezone may have additional spaces, characters, or comments after it but
            // this is ok since we'll parse that whole section later
            int indexOfTimeZoneSeparator = data.AsSpan(indexOfHourSeparator).IndexOfAny(' ', '\t');
            if (indexOfTimeZoneSeparator < 0)
            {
                throw new FormatException(SR.Format(SR.MailHeaderFieldInvalidCharacter, data));
            }
            indexOfTimeZoneSeparator += indexOfHourSeparator;
 
            // extract the time portion and remove all leading and trailing whitespace
            ReadOnlySpan<char> date = data.AsSpan(0, indexOfTimeZoneSeparator).Trim();
 
            // attempt to parse the DateTime component.
            DateTime dateValue;
            if (!DateTime.TryParseExact(date, s_validDateTimeFormats, CultureInfo.InvariantCulture, DateTimeStyles.AllowWhiteSpaces, out dateValue))
            {
                throw new FormatException(SR.MailDateInvalidFormat);
            }
 
            // kind property will be Unspecified since no timezone info was in the date string
            Debug.Assert(dateValue.Kind == DateTimeKind.Unspecified);
 
            // extract the second half of the string. This will start with at least one whitespace character.
            // Trim the string to remove these characters.
            ReadOnlySpan<char> timeZoneSpan = data.AsSpan(indexOfTimeZoneSeparator).Trim();
 
            // find, if any, the first whitespace character after the timezone.
            // These will be CFWS and must be ignored. Remove them.
            int endOfTimeZoneOffset = timeZoneSpan.IndexOfAny(' ', '\t');
            if (endOfTimeZoneOffset >= 0)
            {
                timeZoneSpan = timeZoneSpan.Slice(0, endOfTimeZoneOffset);
            }
 
            if (timeZoneSpan.IsEmpty)
            {
                throw new FormatException(SR.MailDateInvalidFormat);
            }
 
            timeZone = timeZoneSpan.ToString();
 
            return dateValue;
        }
 
        // if this returns true, timeZone is the correct TimeSpan representation of the input
        // if it returns false then the time zone is unknown and so timeZone must be ignored
        internal static bool TryParseTimeZoneString(string timeZoneString, out TimeSpan timeZone)
        {
            // see if the zone is the special unspecified case, a numeric offset, or a shorthand string
            if (timeZoneString == UnknownTimeZoneDefaultOffset)
            {
                // The inputed time zone is the special value "unknown", -0000
                timeZone = TimeSpan.Zero;
                return false;
            }
            else if ((timeZoneString[0] == '+' || timeZoneString[0] == '-'))
            {
                bool positive;
                int hours;
                int minutes;
 
                ValidateAndGetTimeZoneOffsetValues(timeZoneString, out positive, out hours, out minutes);
 
                // Apply the negative sign, if applicable, to whichever of hours or minutes is NOT 0.
                if (!positive)
                {
                    if (hours != 0)
                    {
                        hours *= -1;
                    }
                    else if (minutes != 0)
                    {
                        minutes *= -1;
                    }
                }
 
                timeZone = new TimeSpan(hours, minutes, 0);
                return true;
            }
            else
            {
                // not an offset so ensure that it contains no invalid characters
                ValidateTimeZoneShortHandValue(timeZoneString);
 
                // check if the shorthand value has a semantically equivalent offset
                return s_timeZoneOffsetLookup.TryGetValue(timeZoneString, out timeZone);
            }
        }
 
        internal static TimeSpan ValidateAndGetSanitizedTimeSpan(TimeSpan span)
        {
            // sanitize the time span by removing the seconds and milliseconds.  Days are not handled here
            TimeSpan sanitizedTimeSpan = new TimeSpan(span.Days, span.Hours, span.Minutes, 0, 0);
 
            // validate range of time span
            if (Math.Abs(sanitizedTimeSpan.Ticks) > TimeSpanMaxTicks)
            {
                throw new FormatException(SR.MailDateInvalidFormat);
            }
 
            return sanitizedTimeSpan;
        }
 
        #endregion
    }
}