diff --git a/Source/StrongGrid.Benchmark/StrongGrid.Benchmark.csproj b/Source/StrongGrid.Benchmark/StrongGrid.Benchmark.csproj index 09f8e8d7..0a4aee00 100644 --- a/Source/StrongGrid.Benchmark/StrongGrid.Benchmark.csproj +++ b/Source/StrongGrid.Benchmark/StrongGrid.Benchmark.csproj @@ -6,7 +6,7 @@ - + diff --git a/Source/StrongGrid.IntegrationTests/StrongGrid.IntegrationTests.csproj b/Source/StrongGrid.IntegrationTests/StrongGrid.IntegrationTests.csproj index 34298f18..8e6184a7 100644 --- a/Source/StrongGrid.IntegrationTests/StrongGrid.IntegrationTests.csproj +++ b/Source/StrongGrid.IntegrationTests/StrongGrid.IntegrationTests.csproj @@ -15,7 +15,7 @@ - + diff --git a/Source/StrongGrid.UnitTests/StrongGrid.UnitTests.csproj b/Source/StrongGrid.UnitTests/StrongGrid.UnitTests.csproj index a8abfdb5..9a8f5aa9 100644 --- a/Source/StrongGrid.UnitTests/StrongGrid.UnitTests.csproj +++ b/Source/StrongGrid.UnitTests/StrongGrid.UnitTests.csproj @@ -11,8 +11,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/Source/StrongGrid/Extensions/Internal.cs b/Source/StrongGrid/Extensions/Internal.cs index a541fab9..d55453e3 100644 --- a/Source/StrongGrid/Extensions/Internal.cs +++ b/Source/StrongGrid/Extensions/Internal.cs @@ -1,4 +1,3 @@ -using HttpMultipartParser; using Pathoschild.Http.Client; using StrongGrid.Json; using StrongGrid.Models; @@ -19,6 +18,7 @@ using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; +using static StrongGrid.Utilities.DiagnosticHandler; namespace StrongGrid { @@ -33,7 +33,7 @@ internal enum UnixTimePrecision Milliseconds = 1 } - private static readonly DateTime EPOCH = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime EPOCH = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); /// /// Converts a 'unix time', which is expressed as the number of seconds (or milliseconds) since @@ -117,9 +117,13 @@ internal static async Task ReadAsStringAsync(this HttpContent httpConten if (httpContent != null) { +#if NET7_0_OR_GREATER + var contentStream = await httpContent.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); +#else var contentStream = await httpContent.ReadAsStreamAsync().ConfigureAwait(false); +#endif - if (encoding == null) encoding = httpContent.GetEncoding(Encoding.UTF8); + encoding ??= httpContent.GetEncoding(Encoding.UTF8); // This is important: we must make a copy of the response stream otherwise we would get an // exception on subsequent attempts to read the content of the stream @@ -130,7 +134,11 @@ internal static async Task ReadAsStringAsync(this HttpContent httpConten ms.Position = 0; using (var sr = new StreamReader(ms, encoding)) { +#if NET7_0_OR_GREATER + content = await sr.ReadToEndAsync(cancellationToken).ConfigureAwait(false); +#else content = await sr.ReadToEndAsync().ConfigureAwait(false); +#endif } // It's important to rewind the stream @@ -184,19 +192,6 @@ internal static Encoding GetEncoding(this HttpContent content, Encoding defaultE return encoding; } - /// - /// Returns the value of a parameter or the default value if it doesn't exist. - /// - /// The parser. - /// The name of the parameter. - /// The default value. - /// The value of the parameter. - internal static string GetParameterValue(this MultipartFormDataParser parser, string name, string defaultValue) - { - if (parser.HasParameter(name)) return parser.GetParameterValue(name); - else return defaultValue; - } - /// Asynchronously retrieve the JSON encoded response body and convert it to an object of the desired type. /// The response model to deserialize into. /// The response. @@ -581,14 +576,14 @@ internal static IEnumerable> ParseQuerystring(this return querystringParameters; } - internal static (WeakReference RequestReference, string Diagnostic, long RequestTimeStamp, long ResponseTimestamp) GetDiagnosticInfo(this IResponse response) + internal static DiagnosticInfo GetDiagnosticInfo(this IResponse response) { var diagnosticId = response.Message.RequestMessage.Headers.GetValue(DiagnosticHandler.DIAGNOSTIC_ID_HEADER_NAME); - DiagnosticHandler.DiagnosticsInfo.TryGetValue(diagnosticId, out (WeakReference RequestReference, string Diagnostic, long RequestTimeStamp, long ResponseTimestamp) diagnosticInfo); + DiagnosticHandler.DiagnosticsInfo.TryGetValue(diagnosticId, out DiagnosticInfo diagnosticInfo); return diagnosticInfo; } - internal static async Task<(bool, string)> GetErrorMessageAsync(this HttpResponseMessage message) + internal static async Task<(bool IsError, string Message)> GetErrorMessageAsync(this HttpResponseMessage message) { // Default error message var errorMessage = $"{(int)message.StatusCode}: {message.ReasonPhrase}"; diff --git a/Source/StrongGrid/Extensions/Public.cs b/Source/StrongGrid/Extensions/Public.cs index 0a1989f3..cb43a312 100644 --- a/Source/StrongGrid/Extensions/Public.cs +++ b/Source/StrongGrid/Extensions/Public.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Reflection; using System.Threading; using System.Threading.Tasks; @@ -1276,7 +1277,7 @@ public static async Task DownloadExportFilesAsync(this IContacts contacts, strin var destinationPath = Path.Combine(destinationFolder, exportFile.FileName); using (Stream output = File.OpenWrite(destinationPath)) { - exportFile.Stream.CopyTo(output); + await exportFile.Stream.CopyToAsync(output).ConfigureAwait(false); } } } @@ -1313,7 +1314,7 @@ public static async Task DownloadCsvAsync(this IEmailActivities emailActivities, { using (Stream output = File.OpenWrite(destinationPath)) { - responseStream.CopyTo(output); + await responseStream.CopyToAsync(output).ConfigureAwait(false); } } } @@ -1334,5 +1335,106 @@ public static Task AddAddressToUnsubscribeGroupAsync(this ISuppressions suppress { return suppressions.AddAddressesToUnsubscribeGroupAsync(groupId, new[] { email }, onBehalfOf, cancellationToken); } + + /// + /// Generate a new API Key for billing. + /// + /// The ApiKeys resource. + /// The name. + /// The user to impersonate. + /// Cancellation token. + /// + /// The . + /// + public static Task CreateWithBillingPermissionsAsync(this IApiKeys apiKeys, string name, string onBehalfOf = null, CancellationToken cancellationToken = default) + { + var scopes = new[] + { + "billing.delete", + "billing.read", + "billing.update" + }; + + return apiKeys.CreateAsync(name, scopes, onBehalfOf, cancellationToken); + } + + /// + /// Generate a new API Key with the same permissions that have been granted to you. + /// + /// The ApiKeys resource. + /// The name. + /// The user to impersonate. + /// Cancellation token. + /// + /// The . + /// + /// + /// If you specify an API Key when instanciating the , the new API Key will inherit the permissions of that API Key. + /// If you specify a username and password when instanciating the , the new API Key will inherit the permissions of that user. + /// + public static async Task CreateWithAllPermissionsAsync(this IApiKeys apiKeys, string name, string onBehalfOf = null, CancellationToken cancellationToken = default) + { + var privateField = apiKeys.GetType().GetField("_client", BindingFlags.NonPublic | BindingFlags.Instance); + if (privateField == null) throw new ArgumentException("Unable to find the HttpClient in the resource.", nameof(apiKeys)); + var client = (Pathoschild.Http.Client.IClient)privateField.GetValue(apiKeys); + + var scopes = await client.GetCurrentScopes(true, cancellationToken).ConfigureAwait(false); + var superApiKey = await apiKeys.CreateAsync(name, scopes, onBehalfOf, cancellationToken).ConfigureAwait(false); + return superApiKey; + } + + /// + /// Generate a new API Key with the same "read" permissions that have ben granted to you. + /// + /// The ApiKeys resource. + /// The name. + /// The user to impersonate. + /// Cancellation token. + /// + /// The . + /// + /// + /// If you specify an API Key when instanciating the , the new API Key will inherit the "read" permissions of that API Key. + /// If you specify a username and password when instanciating the , the new API Key will inherit the "read" permissions of that user. + /// + public static async Task CreateWithReadOnlyPermissionsAsync(this IApiKeys apiKeys, string name, string onBehalfOf = null, CancellationToken cancellationToken = default) + { + var privateField = apiKeys.GetType().GetField("_client", BindingFlags.NonPublic | BindingFlags.Instance); + if (privateField == null) throw new ArgumentException("Unable to find the HttpClient in the resource.", nameof(apiKeys)); + var client = (Pathoschild.Http.Client.IClient)privateField.GetValue(apiKeys); + + var scopes = await client.GetCurrentScopes(true, cancellationToken).ConfigureAwait(false); + scopes = scopes.Where(s => s.EndsWith(".read", System.StringComparison.OrdinalIgnoreCase)).ToArray(); + + var readOnlyApiKey = await apiKeys.CreateAsync(name, scopes, onBehalfOf, cancellationToken).ConfigureAwait(false); + return readOnlyApiKey; + } + + /// + /// Send a teammate invitation via email with the same "read" permissions that have been granted to you. + /// A teammate invite will expire after 7 days, but you may resend the invite at any time + /// to reset the expiration date. + /// + /// The teammates resource. + /// The email address of the teammate. + /// The cancellation token. + /// + /// The async task. + /// + /// + /// Essentials, Legacy Lite, and Free Trial users may create up to one teammate per account. + /// There is not a teammate limit for Pro and higher plans. + /// + public static async Task InviteTeammateWithReadOnlyPrivilegesAsync(this ITeammates teammates, string email, CancellationToken cancellationToken = default) + { + var privateField = teammates.GetType().GetField("_client", BindingFlags.NonPublic | BindingFlags.Instance); + if (privateField == null) throw new ArgumentException("Unable to find the HttpClient in the resource.", nameof(teammates)); + var client = (Pathoschild.Http.Client.IClient)privateField.GetValue(teammates); + + var scopes = await client.GetCurrentScopes(true, cancellationToken).ConfigureAwait(true); + scopes = scopes.Where(s => s.EndsWith(".read", StringComparison.OrdinalIgnoreCase)).ToArray(); + + return await teammates.InviteTeammateAsync(email, scopes, cancellationToken).ConfigureAwait(false); + } } } diff --git a/Source/StrongGrid/Json/EventConverter.cs b/Source/StrongGrid/Json/EventConverter.cs index a34e0c5d..4e09321a 100644 --- a/Source/StrongGrid/Json/EventConverter.cs +++ b/Source/StrongGrid/Json/EventConverter.cs @@ -17,6 +17,7 @@ internal class EventConverter : BaseJsonConverter { "asm_group_id", "attempt", + "bounce_classification", "category", "cert_err", "email", diff --git a/Source/StrongGrid/Models/BounceClassification.cs b/Source/StrongGrid/Models/BounceClassification.cs new file mode 100644 index 00000000..c64fe911 --- /dev/null +++ b/Source/StrongGrid/Models/BounceClassification.cs @@ -0,0 +1,55 @@ +using StrongGrid.Json; +using System.Runtime.Serialization; +using System.Text.Json.Serialization; + +namespace StrongGrid.Models +{ + /// + /// Enumeration to indicate the type of SMTP failure. + /// + [JsonConverter(typeof(StringEnumConverter))] + public enum BounceClassification + { + /// + /// Unclassified. + /// + [EnumMember(Value = "Unclassified")] + Unclassified, + + /// + /// Invalid Addres. + /// + [EnumMember(Value = "Invalid Address")] + InvalidAddress, + + /// + /// Technical. + /// + [EnumMember(Value = "Technical")] + Technical, + + /// + /// Content. + /// + [EnumMember(Value = "Content")] + Content, + + /// + /// Reputation. + /// + [EnumMember(Value = "Reputation")] + Reputation, + + /// + /// Frequency or volume. + /// + [EnumMember(Value = "Frequency or Volume Too High")] + FrequencyOrVolume, + + /// + /// Mailbox Unavailable. + /// + [EnumMember(Value = "Mailbox Unavailable")] + MailboxUnavailable, + } +} diff --git a/Source/StrongGrid/Models/Webhooks/BouncedEvent.cs b/Source/StrongGrid/Models/Webhooks/BouncedEvent.cs index 9a661f94..17547a40 100644 --- a/Source/StrongGrid/Models/Webhooks/BouncedEvent.cs +++ b/Source/StrongGrid/Models/Webhooks/BouncedEvent.cs @@ -76,5 +76,15 @@ public BouncedEvent() /// [JsonPropertyName("type")] public BounceType Type { get; set; } + + /// + /// Gets or sets a value indicating the type of SMTP failure. + /// + /// The SMTP failure classification. + /// + /// See SendGrid' Bounce Classifications documentation to understand each classification. + /// + [JsonPropertyName("bounce_classification")] + public BounceClassification Classification { get; set; } } } diff --git a/Source/StrongGrid/Resources/ApiKeys.cs b/Source/StrongGrid/Resources/ApiKeys.cs index 8b277c71..74db75ba 100644 --- a/Source/StrongGrid/Resources/ApiKeys.cs +++ b/Source/StrongGrid/Resources/ApiKeys.cs @@ -143,69 +143,6 @@ public Task UpdateAsync(string keyId, string name, Parameter(); } - /// - /// Generate a new API Key for billing. - /// - /// The name. - /// The user to impersonate. - /// Cancellation token. - /// - /// The . - /// - public Task CreateWithBillingPermissionsAsync(string name, string onBehalfOf = null, CancellationToken cancellationToken = default) - { - var scopes = new[] - { - "billing.delete", - "billing.read", - "billing.update" - }; - - return this.CreateAsync(name, scopes, onBehalfOf, cancellationToken); - } - - /// - /// Generate a new API Key with the same permissions that have been granted to you. - /// - /// The name. - /// The user to impersonate. - /// Cancellation token. - /// - /// The . - /// - /// - /// If you specify an API Key when instanciating the , the new API Key will inherit the permissions of that API Key. - /// If you specify a username and password when instanciating the , the new API Key will inherit the permissions of that user. - /// - public async Task CreateWithAllPermissionsAsync(string name, string onBehalfOf = null, CancellationToken cancellationToken = default) - { - var scopes = await _client.GetCurrentScopes(true, cancellationToken).ConfigureAwait(false); - var superApiKey = await this.CreateAsync(name, scopes, onBehalfOf, cancellationToken).ConfigureAwait(false); - return superApiKey; - } - - /// - /// Generate a new API Key with the same "read" permissions that have ben granted to you. - /// - /// The name. - /// The user to impersonate. - /// Cancellation token. - /// - /// The . - /// - /// - /// If you specify an API Key when instanciating the , the new API Key will inherit the "read" permissions of that API Key. - /// If you specify a username and password when instanciating the , the new API Key will inherit the "read" permissions of that user. - /// - public async Task CreateWithReadOnlyPermissionsAsync(string name, string onBehalfOf = null, CancellationToken cancellationToken = default) - { - var scopes = await _client.GetCurrentScopes(true, cancellationToken).ConfigureAwait(false); - scopes = scopes.Where(s => s.EndsWith(".read", System.StringComparison.OrdinalIgnoreCase)).ToArray(); - - var readOnlyApiKey = await this.CreateAsync(name, scopes, onBehalfOf, cancellationToken).ConfigureAwait(false); - return readOnlyApiKey; - } - private static StrongGridJsonObject ConvertToJson(string name, Parameter> scopes) { var result = new StrongGridJsonObject(); diff --git a/Source/StrongGrid/Resources/IApiKeys.cs b/Source/StrongGrid/Resources/IApiKeys.cs index c8537c7c..2cd74d95 100644 --- a/Source/StrongGrid/Resources/IApiKeys.cs +++ b/Source/StrongGrid/Resources/IApiKeys.cs @@ -74,46 +74,5 @@ public interface IApiKeys /// Cancellation token. /// The . Task UpdateAsync(string keyId, string name, Parameter> scopes = default, string onBehalfOf = null, CancellationToken cancellationToken = default); - - /// - /// Generate a new API Key for billing. - /// - /// The name. - /// The user to impersonate. - /// Cancellation token. - /// - /// The . - /// - Task CreateWithBillingPermissionsAsync(string name, string onBehalfOf = null, CancellationToken cancellationToken = default); - - /// - /// Generate a new API Key with all permissions. - /// - /// The name. - /// The user to impersonate. - /// Cancellation token. - /// - /// The . - /// - /// - /// If you specify an API Key when instanciating the , the new API Key will inherit the permissions of that API Key. - /// If you specify a username and password when instanciating the , the new API Key will inherit the permissions of that user. - /// - Task CreateWithAllPermissionsAsync(string name, string onBehalfOf = null, CancellationToken cancellationToken = default); - - /// - /// Generate a new API Key with the same "read" permissions that have ben granted to you. - /// - /// The name. - /// The user to impersonate. - /// Cancellation token. - /// - /// The . - /// - /// - /// If you specify an API Key when instanciating the , the new API Key will inherit the "read" permissions of that API Key. - /// If you specify a username and password when instanciating the , the new API Key will inherit the "read" permissions of that user. - /// - Task CreateWithReadOnlyPermissionsAsync(string name, string onBehalfOf = null, CancellationToken cancellationToken = default); } } diff --git a/Source/StrongGrid/Resources/ITeammates.cs b/Source/StrongGrid/Resources/ITeammates.cs index c5479d68..a18386a9 100644 --- a/Source/StrongGrid/Resources/ITeammates.cs +++ b/Source/StrongGrid/Resources/ITeammates.cs @@ -103,22 +103,6 @@ public interface ITeammates /// Task InviteTeammateAsync(string email, IEnumerable scopes, CancellationToken cancellationToken = default); - /// - /// Send a teammate invitation via email with the same "read" permissions that have been granted to you. - /// A teammate invite will expire after 7 days, but you may resend the invite at any time - /// to reset the expiration date. - /// - /// The email address of the teammate. - /// The cancellation token. - /// - /// The async task. - /// - /// - /// Essentials, Legacy Lite, and Free Trial users may create up to one teammate per account. - /// There is not a teammate limit for Pro and higher plans. - /// - Task InviteTeammateWithReadOnlyPrivilegesAsync(string email, CancellationToken cancellationToken = default); - /// /// Send a teammate invitation via email with admin permissions. /// A teammate invite will expire after 7 days, but you may resend the invite at any time diff --git a/Source/StrongGrid/Resources/Teammates.cs b/Source/StrongGrid/Resources/Teammates.cs index 41e4d735..0cb4df21 100644 --- a/Source/StrongGrid/Resources/Teammates.cs +++ b/Source/StrongGrid/Resources/Teammates.cs @@ -3,7 +3,6 @@ using StrongGrid.Models; using System; using System.Collections.Generic; -using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -168,52 +167,7 @@ public Task DeleteInvitationAsync(string token, CancellationToken cancellationTo /// public Task InviteTeammateAsync(string email, IEnumerable scopes, CancellationToken cancellationToken = default) { - if (string.IsNullOrEmpty(email)) throw new ArgumentNullException(nameof(email)); - - var data = new StrongGridJsonObject(); - data.AddProperty("email", email); - data.AddProperty("scopes", scopes); - data.AddProperty("is_admin", false); - - return _client - .PostAsync(_endpoint) - .WithJsonBody(data) - .WithCancellationToken(cancellationToken) - .AsObject(); - } - - /// - /// Send a teammate invitation via email with the same "read" permissions that have been granted to you. - /// A teammate invite will expire after 7 days, but you may resend the invite at any time - /// to reset the expiration date. - /// - /// The email address of the teammate. - /// The cancellation token. - /// - /// The async task. - /// - /// - /// Essentials, Legacy Lite, and Free Trial users may create up to one teammate per account. - /// There is not a teammate limit for Pro and higher plans. - /// - public async Task InviteTeammateWithReadOnlyPrivilegesAsync(string email, CancellationToken cancellationToken = default) - { - if (string.IsNullOrEmpty(email)) throw new ArgumentNullException(nameof(email)); - - var scopes = await _client.GetCurrentScopes(true, cancellationToken).ConfigureAwait(true); - scopes = scopes.Where(s => s.EndsWith(".read", System.StringComparison.OrdinalIgnoreCase)).ToArray(); - - var data = new StrongGridJsonObject(); - data.AddProperty("email", email); - data.AddProperty("scopes", scopes); - data.AddProperty("is_admin", false); - - return await _client - .PostAsync(_endpoint) - .WithJsonBody(data) - .WithCancellationToken(cancellationToken) - .AsObject() - .ConfigureAwait(false); + return InviteAsync(email, scopes, false, cancellationToken); } /// @@ -232,17 +186,7 @@ public async Task InviteTeammateWithReadOnlyPrivilegesAsync( /// public Task InviteTeammateAsAdminAsync(string email, CancellationToken cancellationToken = default) { - if (string.IsNullOrEmpty(email)) throw new ArgumentNullException(nameof(email)); - - var data = new StrongGridJsonObject(); - data.AddProperty("email", email); - data.AddProperty("is_admin", true); - - return _client - .PostAsync(_endpoint) - .WithJsonBody(data) - .WithCancellationToken(cancellationToken) - .AsObject(); + return InviteAsync(email, null, true, cancellationToken); } /// @@ -317,5 +261,21 @@ public Task DeleteTeammateAsync(string username, CancellationToken cancellationT .WithCancellationToken(cancellationToken) .AsMessage(); } + + private Task InviteAsync(string email, IEnumerable scopes, bool isAdmin, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(email)) throw new ArgumentNullException(nameof(email)); + + var data = new StrongGridJsonObject(); + data.AddProperty("email", email); + data.AddProperty("scopes", scopes); + data.AddProperty("is_admin", isAdmin); + + return _client + .PostAsync(_endpoint) + .WithJsonBody(data) + .WithCancellationToken(cancellationToken) + .AsObject(); + } } } diff --git a/Source/StrongGrid/StrongGrid.csproj b/Source/StrongGrid/StrongGrid.csproj index f4ac6916..62b0756f 100644 --- a/Source/StrongGrid/StrongGrid.csproj +++ b/Source/StrongGrid/StrongGrid.csproj @@ -36,15 +36,15 @@ - + - + - + diff --git a/Source/StrongGrid/Utilities/DiagnosticHandler.cs b/Source/StrongGrid/Utilities/DiagnosticHandler.cs index c7732f60..f29a2d3a 100644 --- a/Source/StrongGrid/Utilities/DiagnosticHandler.cs +++ b/Source/StrongGrid/Utilities/DiagnosticHandler.cs @@ -18,6 +18,25 @@ namespace StrongGrid.Utilities /// internal class DiagnosticHandler : IHttpFilter { + internal class DiagnosticInfo + { + public WeakReference RequestReference { get; set; } + + public string Diagnostic { get; set; } + + public long RequestTimestamp { get; set; } + + public long ResponseTimestamp { get; set; } + + public DiagnosticInfo(WeakReference requestReference, string diagnostic, long requestTimestamp, long responseTimestamp) + { + RequestReference = requestReference; + Diagnostic = diagnostic; + RequestTimestamp = requestTimestamp; + ResponseTimestamp = responseTimestamp; + } + } + #region FIELDS internal const string DIAGNOSTIC_ID_HEADER_NAME = "StrongGrid-Diagnostic-Id"; @@ -29,7 +48,7 @@ internal class DiagnosticHandler : IHttpFilter #region PROPERTIES - internal static ConcurrentDictionary RequestReference, string Diagnostic, long RequestTimestamp, long ResponseTimeStamp)> DiagnosticsInfo { get; } = new ConcurrentDictionary, string, long, long)>(); + internal static ConcurrentDictionary DiagnosticsInfo { get; } = new(); #endregion @@ -64,7 +83,7 @@ public void OnRequest(IRequest request) LogContent(diagnostic, httpRequest.Content); // Add the diagnostic info to our cache - DiagnosticsInfo.TryAdd(diagnosticId, (new WeakReference(request.Message), diagnostic.ToString(), Stopwatch.GetTimestamp(), long.MinValue)); + DiagnosticsInfo.TryAdd(diagnosticId, new DiagnosticInfo(new WeakReference(request.Message), diagnostic.ToString(), Stopwatch.GetTimestamp(), long.MinValue)); } /// Method invoked just after the HTTP response is received. This method can modify the incoming HTTP response. @@ -76,7 +95,7 @@ public void OnResponse(IResponse response, bool httpErrorAsException) var httpResponse = response.Message; var diagnosticId = response.Message.RequestMessage.Headers.GetValue(DIAGNOSTIC_ID_HEADER_NAME); - if (DiagnosticsInfo.TryGetValue(diagnosticId, out (WeakReference RequestReference, string Diagnostic, long RequestTimestamp, long ResponseTimestamp) diagnosticInfo)) + if (DiagnosticsInfo.TryGetValue(diagnosticId, out DiagnosticInfo diagnosticInfo)) { var updatedDiagnostic = new StringBuilder(diagnosticInfo.Diagnostic); try @@ -118,8 +137,8 @@ public void OnResponse(IResponse response, bool httpErrorAsException) DiagnosticsInfo.TryUpdate( diagnosticId, - (diagnosticInfo.RequestReference, updatedDiagnostic.ToString(), diagnosticInfo.RequestTimestamp, responseTimestamp), - (diagnosticInfo.RequestReference, diagnosticInfo.Diagnostic, diagnosticInfo.RequestTimestamp, diagnosticInfo.ResponseTimestamp)); + new DiagnosticInfo(diagnosticInfo.RequestReference, updatedDiagnostic.ToString(), diagnosticInfo.RequestTimestamp, responseTimestamp), + diagnosticInfo); } } @@ -130,7 +149,7 @@ public void OnResponse(IResponse response, bool httpErrorAsException) #region PRIVATE METHODS - private void LogHeaders(StringBuilder diagnostic, HttpHeaders httpHeaders) + private static void LogHeaders(StringBuilder diagnostic, HttpHeaders httpHeaders) { if (httpHeaders != null) { @@ -148,7 +167,7 @@ private void LogHeaders(StringBuilder diagnostic, HttpHeaders httpHeaders) } } - private void LogContent(StringBuilder diagnostic, HttpContent httpContent) + private static void LogContent(StringBuilder diagnostic, HttpContent httpContent) { if (httpContent == null) { @@ -172,14 +191,14 @@ private void LogContent(StringBuilder diagnostic, HttpContent httpContent) } } - private void Cleanup() + private static void Cleanup() { try { // Remove diagnostic information for requests that have been garbage collected foreach (string key in DiagnosticHandler.DiagnosticsInfo.Keys.ToArray()) { - if (DiagnosticHandler.DiagnosticsInfo.TryGetValue(key, out (WeakReference RequestReference, string Diagnostic, long RequestTimeStamp, long ResponseTimestamp) diagnosticInfo)) + if (DiagnosticHandler.DiagnosticsInfo.TryGetValue(key, out DiagnosticInfo diagnosticInfo)) { if (!diagnosticInfo.RequestReference.TryGetTarget(out HttpRequestMessage request)) { diff --git a/Source/StrongGrid/Utilities/SendGridMultipartFormDataParser.cs b/Source/StrongGrid/Utilities/SendGridMultipartFormDataParser.cs new file mode 100644 index 00000000..5a46c351 --- /dev/null +++ b/Source/StrongGrid/Utilities/SendGridMultipartFormDataParser.cs @@ -0,0 +1,94 @@ +using HttpMultipartParser; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +namespace StrongGrid.Utilities +{ + internal class SendGridMultipartFormDataParser : IMultipartFormDataParser + { + private readonly List _files; + private readonly List _parameters; + private readonly List> _charsets; + + public IReadOnlyList Files => _files.AsReadOnly(); + + public IReadOnlyList Parameters => _parameters.AsReadOnly(); + + public IReadOnlyList> Charsets => _charsets.AsReadOnly(); + + private SendGridMultipartFormDataParser() + { + _files = new List(); + _parameters = new List(); + _charsets = new List>(); + } + + public static SendGridMultipartFormDataParser Parse(Stream stream) + { + var parser = MultipartFormBinaryDataParser.Parse(stream, Encoding.UTF8); + return ConvertToSendGridParser(parser); + } + + public static async Task ParseAsync(Stream stream) + { + var parser = await MultipartFormBinaryDataParser.ParseAsync(stream, Encoding.UTF8).ConfigureAwait(false); + return ConvertToSendGridParser(parser); + } + + public string GetParameterValue(string name, string defaultValue) + { + var parameter = _parameters.FirstOrDefault(p => p.Name == name); + return parameter?.Data ?? defaultValue; + } + + private static SendGridMultipartFormDataParser ConvertToSendGridParser(MultipartFormBinaryDataParser parser) + { + var charsetsParameter = parser.Parameters.FirstOrDefault(p => p.Name.Equals("charsets", StringComparison.OrdinalIgnoreCase)); + var charsets = JsonDocument.Parse(charsetsParameter?.ToString(Encoding.UTF8) ?? "{}") + .RootElement.EnumerateObject() + .Select(p => new KeyValuePair(p.Name, p.Value.GetString())) + .ToArray(); + + var encodings = charsets.ToDictionary(p => p.Key, p => GetEncodingFromName(p.Value)); + + var sendGridParser = new SendGridMultipartFormDataParser(); + foreach (var parameter in parser.Parameters) + { + // Get the encoding specified by SendGrid for this parameter + encodings.TryGetValue(parameter.Name, out Encoding encoding); + encoding ??= Encoding.UTF8; + + sendGridParser._parameters.Add(new ParameterPart(parameter.Name, parameter.ToString(encoding))); + } + + sendGridParser._files.AddRange(parser.Files); + sendGridParser._charsets.AddRange(charsets); + + return sendGridParser; + } + + private static Encoding GetEncodingFromName(string encodingName) + { + try + { + return Encoding.GetEncoding(encodingName); + } + catch (ArgumentException) + { + // ArgumentException is thrown when an "unusual" code page was used to encode a section of the email + // For example: {"to":"UTF-8","subject":"UTF-8","from":"UTF-8","text":"iso-8859-10"} + // We can see that 'iso-8859-10' was used to encode the "Text" but this encoding is not supported in + // .net (neither dotnet full nor dotnet core). Therefore we fallback on UTF-8. This is obviously not + // perfect because UTF-8 may or may not be able to handle all the encoded characters, but it's better + // than simply erroring out. + // See https://github.com/Jericho/StrongGrid/issues/341 for discussion. + return Encoding.UTF8; + } + } + } +} diff --git a/Source/StrongGrid/Warmup/WarmupEngine.cs b/Source/StrongGrid/Warmup/WarmupEngine.cs index 2b82ff1c..450a0f05 100644 --- a/Source/StrongGrid/Warmup/WarmupEngine.cs +++ b/Source/StrongGrid/Warmup/WarmupEngine.cs @@ -62,7 +62,8 @@ public Task PrepareWithNewIpAddressesAsync(int count, string[] subusers, Cancell var result = _client.IpAddresses.AddAsync(count, subusers, false, cancellationToken).Result; var ipAddresses = result.IpAddresses.Select(r => r.Address).ToArray(); return ipAddresses; - }, cancellationToken); + }, + cancellationToken); } /// diff --git a/Source/StrongGrid/WebhookParser.cs b/Source/StrongGrid/WebhookParser.cs index b1350d21..ea9a5e7d 100644 --- a/Source/StrongGrid/WebhookParser.cs +++ b/Source/StrongGrid/WebhookParser.cs @@ -1,4 +1,3 @@ -using HttpMultipartParser; using StrongGrid.Json; using StrongGrid.Models.Webhooks; using StrongGrid.Utilities; @@ -164,51 +163,18 @@ public async Task ParseInboundEmailWebhookAsync(Stream stream) // Therefore, we must make a copy of the stream if it doesn't allow changing the position if (!stream.CanSeek) { - using (var ms = Utils.MemoryStreamManager.GetStream()) - { - await stream.CopyToAsync(ms).ConfigureAwait(false); - return await ParseInboundEmailWebhookAsync(ms).ConfigureAwait(false); - } + using var ms = Utils.MemoryStreamManager.GetStream(); + await stream.CopyToAsync(ms).ConfigureAwait(false); + return await ParseInboundEmailWebhookAsync(ms).ConfigureAwait(false); } // It's important to rewind the stream stream.Position = 0; // Asynchronously parse the multipart content received from SendGrid - var parser = await MultipartFormDataParser.ParseAsync(stream, Encoding.UTF8).ConfigureAwait(false); - - // Convert the 'charset' from a string into array of KeyValuePair - var charsets = JsonDocument.Parse(parser.GetParameterValue("charsets", "{}")) - .RootElement.EnumerateObject() - .Select(prop => new KeyValuePair(prop.Name, prop.Value.GetString())) - .ToArray(); - - // Create a dictionary of parsers, one parser for each desired encoding. - // This is necessary because MultipartFormDataParser can only handle one - // encoding and SendGrid can use different encodings for parameters such - // as "from", "to", "text" and "html". - var encodedParsers = charsets - .Select(c => c.Value) - .Select(GetEncodingFromName) - .Distinct() - .Where(encoding => !encoding.Equals(Encoding.UTF8)) - .Select(async encoding => - { - stream.Position = 0; // It's important to rewind the stream - return new - { - Encoding = encoding, - Parser = await MultipartFormDataParser.ParseAsync(stream, encoding).ConfigureAwait(false) - }; - }) - .Select(r => r.Result) - .Union(new[] - { - new { Encoding = Encoding.UTF8, Parser = parser } - }) - .ToDictionary(ep => ep.Encoding, ep => ep.Parser); + var parser = await SendGridMultipartFormDataParser.ParseAsync(stream).ConfigureAwait(false); - return ParseInboundEmail(encodedParsers, charsets); + return ParseInboundEmail(parser); } /// @@ -223,103 +189,26 @@ public InboundEmail ParseInboundEmailWebhook(Stream stream) // Therefore, we must make a copy of the stream if it doesn't allow changing the position if (!stream.CanSeek) { - using (var ms = Utils.MemoryStreamManager.GetStream()) - { - stream.CopyTo(ms); - return ParseInboundEmailWebhook(ms); - } + using var ms = Utils.MemoryStreamManager.GetStream(); + stream.CopyTo(ms); + return ParseInboundEmailWebhook(ms); } // It's important to rewind the stream stream.Position = 0; // Parse the multipart content received from SendGrid - var parser = MultipartFormDataParser.Parse(stream, Encoding.UTF8); - - // Convert the 'charset' from a string into array of KeyValuePair - var charsets = JsonDocument.Parse(parser.GetParameterValue("charsets", "{}")) - .RootElement.EnumerateObject() - .Select(prop => new KeyValuePair(prop.Name, prop.Value.GetString())) - .ToArray(); - - // Create a dictionary of parsers, one parser for each desired encoding. - // This is necessary because MultipartFormDataParser can only handle one - // encoding and SendGrid can use different encodings for parameters such - // as "from", "to", "text" and "html". - var encodedParsers = charsets - .Select(c => c.Value) - .Select(GetEncodingFromName) - .Distinct() - .Where(encoding => !encoding.Equals(Encoding.UTF8)) - .Select(encoding => - { - stream.Position = 0; // It's important to rewind the stream - return new - { - Encoding = encoding, - Parser = MultipartFormDataParser.Parse(stream, encoding) - }; - }) - .Union(new[] - { - new { Encoding = Encoding.UTF8, Parser = parser } - }) - .ToDictionary(ep => ep.Encoding, ep => ep.Parser); + var parser = SendGridMultipartFormDataParser.Parse(stream); - return ParseInboundEmail(encodedParsers, charsets); + return ParseInboundEmail(parser); } #endregion #region PRIVATE METHODS - private static Encoding GetEncoding(string parameterName, IEnumerable> charsets) - { - var encoding = charsets.Where(c => c.Key == parameterName); - if (!encoding.Any()) return Encoding.UTF8; - - var encodingName = encoding.First().Value; - return GetEncodingFromName(encodingName); - } - - private static Encoding GetEncodingFromName(string encodingName) + private static InboundEmail ParseInboundEmail(SendGridMultipartFormDataParser parser) { - try - { - return Encoding.GetEncoding(encodingName); - } - catch (ArgumentException) - { - // ArgumentException is thrown when an "unusual" code page was used to encode a section of the email - // For example: {"to":"UTF-8","subject":"UTF-8","from":"UTF-8","text":"iso-8859-10"} - // We can see that 'iso-8859-10' was used to encode the "Text" but this encoding is not supported in - // .net (neither dotnet full nor dotnet core). Therefore we fallback on UTF-8. This is obviously not - // perfect because UTF-8 may or may not be able to handle all the encoded characters, but it's better - // than simply erroring out. - // See https://github.com/Jericho/StrongGrid/issues/341 for discussion. - return Encoding.UTF8; - } - } - - private static MultipartFormDataParser GetEncodedParser(string parameterName, IEnumerable> charsets, IDictionary encodedParsers) - { - var encoding = GetEncoding(parameterName, charsets); - var parser = encodedParsers[encoding]; - return parser; - } - - private static string GetEncodedValue(string parameterName, IEnumerable> charsets, IDictionary encodedParsers, string defaultValue = null) - { - var parser = GetEncodedParser(parameterName, charsets, encodedParsers); - var value = parser.GetParameterValue(parameterName, defaultValue); - return value; - } - - private static InboundEmail ParseInboundEmail(IDictionary encodedParsers, KeyValuePair[] charsets) - { - // Get the default UTF8 parser - var parser = encodedParsers.Single(p => p.Key.Equals(Encoding.UTF8)).Value; - // Convert the 'headers' from a string into array of KeyValuePair var headers = parser .GetParameterValue("headers", string.Empty) @@ -358,33 +247,33 @@ private static InboundEmail ParseInboundEmail(IDictionary(parser.GetParameterValue("envelope", "{}"), JsonFormatter.DeserializerOptions); // Convert the 'from' from a string into an email address - var rawFrom = GetEncodedValue("from", charsets, encodedParsers, string.Empty); + var rawFrom = parser.GetParameterValue("from", string.Empty); var from = MailAddressParser.ParseEmailAddress(rawFrom); // Convert the 'to' from a string into an array of email addresses - var rawTo = GetEncodedValue("to", charsets, encodedParsers, string.Empty); + var rawTo = parser.GetParameterValue("to", string.Empty); var to = MailAddressParser.ParseEmailAddresses(rawTo); // Convert the 'cc' from a string into an array of email addresses - var rawCc = GetEncodedValue("cc", charsets, encodedParsers, string.Empty); + var rawCc = parser.GetParameterValue("cc", string.Empty); var cc = MailAddressParser.ParseEmailAddresses(rawCc); // Arrange the InboundEmail var inboundEmail = new InboundEmail { Attachments = attachments, - Charsets = charsets, - Dkim = GetEncodedValue("dkim", charsets, encodedParsers, null), + Charsets = parser.Charsets.ToArray(), + Dkim = parser.GetParameterValue("dkim", null), Envelope = envelope, From = from, Headers = headers, - Html = GetEncodedValue("html", charsets, encodedParsers, null), - SenderIp = GetEncodedValue("sender_ip", charsets, encodedParsers, null), - SpamReport = GetEncodedValue("spam_report", charsets, encodedParsers, null), - SpamScore = GetEncodedValue("spam_score", charsets, encodedParsers, null), - Spf = GetEncodedValue("SPF", charsets, encodedParsers, null), - Subject = GetEncodedValue("subject", charsets, encodedParsers, null), - Text = GetEncodedValue("text", charsets, encodedParsers, null), + Html = parser.GetParameterValue("html", null), + SenderIp = parser.GetParameterValue("sender_ip", null), + SpamReport = parser.GetParameterValue("spam_report", null), + SpamScore = parser.GetParameterValue("spam_score", null), + Spf = parser.GetParameterValue("SPF", null), + Subject = parser.GetParameterValue("subject", null), + Text = parser.GetParameterValue("text", null), To = to, Cc = cc, RawEmail = rawEmail diff --git a/build.cake b/build.cake index 766b3a4c..18022cd1 100644 --- a/build.cake +++ b/build.cake @@ -2,13 +2,13 @@ #tool dotnet:?package=GitVersion.Tool&version=5.11.1 #tool dotnet:?package=coveralls.net&version=4.0.1 #tool nuget:?package=GitReleaseManager&version=0.13.0 -#tool nuget:?package=ReportGenerator&version=5.1.11 +#tool nuget:?package=ReportGenerator&version=5.1.13 #tool nuget:?package=xunit.runner.console&version=2.4.2 #tool nuget:?package=Codecov&version=1.13.0 // Install addins. #addin nuget:?package=Cake.Coveralls&version=1.1.0 -#addin nuget:?package=Cake.Git&version=2.0.0 +#addin nuget:?package=Cake.Git&version=3.0.0 #addin nuget:?package=Cake.Codecov&version=1.0.1 @@ -297,7 +297,7 @@ Task("Run-Code-Coverage") Task("Upload-Coverage-Result-Coveralls") .IsDependentOn("Run-Code-Coverage") - .OnError(exception => Information($"ONERROR: Failed to upload coverage result to Coveralls: {exception.Message}")) + .OnError(exception => Information($"ONERROR: Failed to upload coverage result to Coveralls: {exception.Message}")) .Does(() => { //CoverallsNet(new FilePath($"{codeCoverageDir}coverage.{DefaultFramework}.xml"), CoverallsNetReportType.OpenCover, new CoverallsNetSettings() @@ -308,7 +308,7 @@ Task("Upload-Coverage-Result-Coveralls") Task("Upload-Coverage-Result-Codecov") .IsDependentOn("Run-Code-Coverage") - .OnError(exception => Information($"ONERROR: Failed to upload coverage result to Codecov: {exception.Message}")) + .OnError(exception => Information($"ONERROR: Failed to upload coverage result to Codecov: {exception.Message}")) .Does(() => { //Codecov($"{codeCoverageDir}coverage.{DefaultFramework}.xml", codecovToken);