diff --git a/Common/Notifications/Notification.cs b/Common/Notifications/Notification.cs index 2b6ee34a4a87..3b762fbacc36 100644 --- a/Common/Notifications/Notification.cs +++ b/Common/Notifications/Notification.cs @@ -114,8 +114,10 @@ public class NotificationEmail : Notification public Dictionary Headers { get; set; } /// - /// Send to address: + /// Send to address. If null, the email is sent to all members in the project. + /// Always serialized, even when null: the cloud API requires the key to be present /// + [JsonProperty(NullValueHandling = NullValueHandling.Include)] public string Address { get; set; } /// @@ -138,7 +140,8 @@ public class NotificationEmail : Notification /// /// Default constructor for sending an email notification /// - /// Address to send to, if null will default to users email. Will throw if invalid + /// Address to send to. If null, the email is sent to all members in the project. + /// Will throw if invalid /// /// Subject of the email. Will set to if null /// Message body of the email. Will set to if null @@ -146,12 +149,22 @@ public class NotificationEmail : Notification /// Optional email headers to use public NotificationEmail(string address, string subject = "", string message = "", string data = "", Dictionary headers = null) { - if (!Validate.EmailAddress(address)) + if (address == null) { - throw new ArgumentException(Messages.NotificationEmail.InvalidEmailAddress(address)); + // sent to all members in the project + Address = null; + } + else + { + address = address.Trim(); + if (!Validate.EmailAddress(address)) + { + throw new ArgumentException(Messages.NotificationEmail.InvalidEmailAddress(address)); + } + + Address = address; } - Address = address; Data = data ?? string.Empty; Message = message ?? string.Empty; Subject = subject ?? string.Empty; diff --git a/Common/Notifications/NotificationJsonConverter.cs b/Common/Notifications/NotificationJsonConverter.cs index 3fea9567fe00..19a9292cf276 100644 --- a/Common/Notifications/NotificationJsonConverter.cs +++ b/Common/Notifications/NotificationJsonConverter.cs @@ -65,7 +65,10 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist var address = jObject.GetValue("Address", StringComparison.InvariantCultureIgnoreCase); var headers= jObject.GetValue("Headers", StringComparison.InvariantCultureIgnoreCase); - return new NotificationEmail(address?.ToString(), token.ToString(), message?.ToString(), data?.ToString(), headers?.ToObject>()); + // a null address means the email is sent to all members in the project + var addressValue = address == null || address.Type == JTokenType.Null ? null : address.ToString(); + + return new NotificationEmail(addressValue, token.ToString(), message?.ToString(), data?.ToString(), headers?.ToObject>()); } else if (jObject.TryGetValue("Address", StringComparison.InvariantCultureIgnoreCase, out token)) { diff --git a/Common/Notifications/NotificationManager.cs b/Common/Notifications/NotificationManager.cs index e3559d8a869c..1f2026d6fc28 100644 --- a/Common/Notifications/NotificationManager.cs +++ b/Common/Notifications/NotificationManager.cs @@ -42,11 +42,12 @@ public NotificationManager(bool liveMode) /// /// Send an email to the address specified for live trading notifications. + /// If the address is null, the email is sent to all members in the project. /// /// Subject of the email /// Message body, up to 10kb /// Data attachment (optional) - /// Email address to send to, if null will default to users email + /// Email address to send to. If null, the email is sent to all members in the project /// Optional email headers to use public bool Email(string address, string subject, string message, string data, PyObject headers) { @@ -55,11 +56,12 @@ public bool Email(string address, string subject, string message, string data, P /// /// Send an email to the address specified for live trading notifications. + /// If the address is null, the email is sent to all members in the project. /// /// Subject of the email /// Message body, up to 10kb /// Data attachment (optional) - /// Email address to send to, if null will default to users email + /// Email address to send to. If null, the email is sent to all members in the project /// Optional email headers to use public bool Email(string address, string subject, string message, string data = "", Dictionary headers = null) { diff --git a/Tests/Common/Notifications/NotificationEmailLiveTests.cs b/Tests/Common/Notifications/NotificationEmailLiveTests.cs new file mode 100644 index 000000000000..e7bf45f7c949 --- /dev/null +++ b/Tests/Common/Notifications/NotificationEmailLiveTests.cs @@ -0,0 +1,95 @@ +/* + * QUANTCONNECT.COM - Democratizing Finance, Empowering Individuals. + * Lean Algorithmic Trading Engine v2.0. Copyright 2014 QuantConnect Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using QuantConnect.Configuration; +using QuantConnect.Notifications; +using QuantConnect.Tests.API; + +namespace QuantConnect.Tests.Common.Notifications +{ + /// + /// Live test: enqueues an email through Notify.Email with a null address and sends it + /// through the QuantConnect cloud API to verify it is emailed to all members in the project. + /// Requires "job-user-id", "api-access-token" and "notifications-endpoint" in the config. + /// + [TestFixture, Explicit("Sends a real email through the QuantConnect cloud API")] + public class NotificationEmailLiveTests + { + // Create a project on QC Cloud with more than one member and set its ID here: + // every member of the project should receive the test email + private const int ProjectId = 34485046; + + [Test] + public void NotifyEmailWithNullAddress_SendsToAllProjectMembers() + { + // enqueue the email like an algorithm would: self.notify.email(None, ...) + var notify = new NotificationManager(liveMode: true); + Assert.IsTrue(notify.Email(null, $"Notify.Email All Members Test - Project {ProjectId}", + $"Sent by NotificationEmailLiveTests at {DateTime.UtcNow:u} with a null address.")); + + // dequeue and send, like the live result handler hands notifications to the messaging handler + Assert.IsTrue(notify.Messages.TryDequeue(out var notification)); + var email = notification as NotificationEmail; + Assert.IsNotNull(email); + Assert.IsNull(email.Address); + + Assert.IsTrue(Send(notification)); + } + + private static bool Send(Notification notification) + { + ApiTestBase.ReloadConfiguration(); + var endpoint = Config.Get("notifications-endpoint"); + if (string.IsNullOrEmpty(endpoint)) + { + Assert.Ignore("The 'notifications-endpoint' configuration is not set"); + } + var userId = Globals.UserId.ToStringInvariant(); + var apiToken = Globals.UserToken; + + var serialized = JsonConvert.SerializeObject(notification, + new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.All }); + StringAssert.Contains("\"Address\":null", serialized); + + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToStringInvariant(); + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{apiToken}:{timestamp}"))) + .ToLowerInvariant(); + + using var client = new HttpClient(); + client.DefaultRequestHeaders.Add("Timestamp", timestamp); + client.DefaultRequestHeaders.Add("Authorization", + "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{userId}:{hash}"))); + + using var response = client.PostAsync(endpoint, + new FormUrlEncodedContent(new Dictionary + { + { "projectId", ProjectId.ToStringInvariant() }, + { "notification", serialized } + })).Result; + var body = response.Content.ReadAsStringAsync().Result; + + Assert.IsTrue(response.IsSuccessStatusCode, body); + return JObject.Parse(body).Value("success"); + } + } +} diff --git a/Tests/Common/Notifications/NotificationEmailTests.cs b/Tests/Common/Notifications/NotificationEmailTests.cs index 18011e545c0c..1d685fa13012 100644 --- a/Tests/Common/Notifications/NotificationEmailTests.cs +++ b/Tests/Common/Notifications/NotificationEmailTests.cs @@ -46,6 +46,30 @@ public void Constructor_SetsNullMessage_ToEmptyString() Assert.AreEqual(string.Empty, email.Message); } + [Test] + public void Constructor_SetsNullAddress_ToNull_ToSendToAllProjectMembers() + { + // a null address means the email will be sent to all members in the project + var email = new NotificationEmail(null, "subject", "message", "data"); + Assert.IsNull(email.Address); + } + + [TestCase("")] + [TestCase(" ")] + public void Constructor_ThrowsArgumentException_WhenAddressIsEmpty(string address) + { + // only a null address is sent to all members in the project + Assert.Throws(() => new NotificationEmail(address, "subject", "message", "data")); + } + + [TestCase("jones@proseware.com,david.jones@proseware.com")] + [TestCase("jones@proseware.com, david.jones@proseware.com")] + public void Constructor_ThrowsArgumentException_WhenAddressIsCommaSeparated(string address) + { + // multiple addresses are not supported + Assert.Throws(() => new NotificationEmail(address, "subject", "message", "data")); + } + [Test] [TestCase("js@contoso.中国", true)] [TestCase("j@proseware.com9", true)] diff --git a/Tests/Common/Notifications/NotificationJsonConverterTests.cs b/Tests/Common/Notifications/NotificationJsonConverterTests.cs index 7cfd160eee6c..25a527237af1 100644 --- a/Tests/Common/Notifications/NotificationJsonConverterTests.cs +++ b/Tests/Common/Notifications/NotificationJsonConverterTests.cs @@ -47,6 +47,24 @@ public void EmailRoundTrip(bool nullFields) Assert.AreEqual(expected.Headers, result.Headers); } + [Test] + public void EmailWithoutAddressRoundTrip() + { + // a null address means the email will be sent to all members in the project + var expected = new NotificationEmail(null, "subjectP", "message", "dataContent"); + + // the cloud API requires the Address key to be present, null means all members in the project + var serialized = JsonConvert.SerializeObject(expected); + StringAssert.Contains("\"Address\":null", serialized); + + var result = (NotificationEmail)JsonConvert.DeserializeObject(serialized); + + Assert.IsNull(result.Address); + Assert.AreEqual(expected.Subject, result.Subject); + Assert.AreEqual(expected.Data, result.Data); + Assert.AreEqual(expected.Message, result.Message); + } + [TestCase(true)] [TestCase(false)] public void SmsRoundTrip(bool nullFields) diff --git a/Tests/Common/Notifications/NotificationManagerTests.cs b/Tests/Common/Notifications/NotificationManagerTests.cs index d4424ab2b14b..76dc6cfe57ad 100644 --- a/Tests/Common/Notifications/NotificationManagerTests.cs +++ b/Tests/Common/Notifications/NotificationManagerTests.cs @@ -51,6 +51,19 @@ public void Email_AddsNotificationEmail_ToMessages_WhenLiveModeIsTrue() } } + [Test] + public void Email_WithoutAddress_AddsNotificationEmail_ForAllProjectMembers() + { + Assert.AreEqual(_liveMode, _notify.Email(null, "subject", "message", "data")); + Assert.AreEqual(_liveMode ? 1 : 0, _notify.Messages.Count); + if (_liveMode) + { + var email = _notify.Messages.Single() as NotificationEmail; + Assert.IsNotNull(email); + Assert.IsNull(email.Address); + } + } + [Test] public void Sms_AddsNotificationSms_ToMessages_WhenLiveModeIsTrue() { @@ -106,6 +119,32 @@ public void FtpAddsNotificationToMessagesWhenLiveModeIsTrueFromStringContents() } } + [Test] + public void PythonEmailWithNoneAddress_SendsToAllProjectMembers() + { + using (Py.GIL()) + { + var test = PyModule.FromString("testModule", + @" +from AlgorithmImports import * + +def email_none(notifier): + return notifier.email(None, 'subject', 'message', 'data')"); + + dynamic function = test.GetAttr("email_none"); + bool result = function(_notify); + + Assert.AreEqual(_liveMode, result); + Assert.AreEqual(_liveMode ? 1 : 0, _notify.Messages.Count); + if (_liveMode) + { + var email = _notify.Messages.Single() as NotificationEmail; + Assert.IsNotNull(email); + Assert.IsNull(email.Address); + } + } + } + [TestCase("email")] [TestCase("web")] public void PythonOverloads(string notificationType)