diff --git a/src/NzbDrone.Core.Test/Datastore/Migration/049_email_multiple_addressesFixture.cs b/src/NzbDrone.Core.Test/Datastore/Migration/049_email_multiple_addressesFixture.cs new file mode 100644 index 000000000..4cad9f2ef --- /dev/null +++ b/src/NzbDrone.Core.Test/Datastore/Migration/049_email_multiple_addressesFixture.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Datastore.Migration; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.Datastore.Migration +{ + [TestFixture] + public class email_multiple_addressesFixture : MigrationTest + { + [Test] + public void should_convert_to_list_on_email_lists() + { + var db = WithMigrationTestDb(c => + { + c.Insert.IntoTable("Notifications").Row(new + { + OnGrab = true, + OnReleaseImport = true, + OnUpgrade = true, + OnDownloadFailure = true, + OnImportFailure = true, + OnTrackRetag = true, + OnHealthIssue = true, + IncludeHealthWarnings = true, + OnRename = true, + Name = "Gmail Lidarr", + Implementation = "Email", + Tags = "[]", + Settings = new EmailSettings48 + { + Server = "smtp.gmail.com", + Port = 563, + To = "lidarr@lidarr.audio" + }.ToJson(), + ConfigContract = "EmailSettings" + }); + }); + + var items = db.Query("SELECT * FROM Notifications"); + + items.Should().HaveCount(1); + items.First().Implementation.Should().Be("Email"); + items.First().ConfigContract.Should().Be("EmailSettings"); + items.First().Settings.To.Count().Should().Be(1); + } + } + + public class ProviderDefinition166 + { + public int Id { get; set; } + public string Implementation { get; set; } + public string ConfigContract { get; set; } + public EmailSettings49 Settings { get; set; } + public string Name { get; set; } + public bool OnGrab { get; set; } + public bool OnReleaseImport { get; set; } + public bool OnUpgrade { get; set; } + public bool OnRename { get; set; } + public bool OnDownloadFailure { get; set; } + public bool OnImportFailure { get; set; } + public bool OnTrackRetag { get; set; } + public bool OnHealthIssue { get; set; } + public bool IncludeHealthWarnings { get; set; } + public List Tags { get; set; } + } + + public class EmailSettings48 + { + public string Server { get; set; } + public int Port { get; set; } + public bool Ssl { get; set; } + public string Username { get; set; } + public string Password { get; set; } + public string From { get; set; } + public string To { get; set; } + } + + public class EmailSettings49 + { + public string Server { get; set; } + public int Port { get; set; } + public bool Ssl { get; set; } + public string Username { get; set; } + public string Password { get; set; } + public string From { get; set; } + public IEnumerable To { get; set; } + } +} diff --git a/src/NzbDrone.Core.Test/NotificationTests/EmailTests/EmailSettingsValidatorFixture.cs b/src/NzbDrone.Core.Test/NotificationTests/EmailTests/EmailSettingsValidatorFixture.cs new file mode 100644 index 000000000..299927a45 --- /dev/null +++ b/src/NzbDrone.Core.Test/NotificationTests/EmailTests/EmailSettingsValidatorFixture.cs @@ -0,0 +1,103 @@ +using System; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Notifications.Email; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.NotificationTests.EmailTests +{ + [TestFixture] + public class EmailSettingsValidatorFixture : CoreTest + { + private EmailSettings _emailSettings; + private TestValidator _validator; + + [SetUp] + public void Setup() + { + _validator = new TestValidator + { + v => v.RuleFor(s => s).SetValidator(Subject) + }; + + _emailSettings = Builder.CreateNew() + .With(s => s.Server = "someserver") + .With(s => s.Port = 567) + .With(s => s.From = "lidarr@lidarr.audio") + .With(s => s.To = new string[] { "lidarr@lidarr.audio" }) + .Build(); + } + + [Test] + public void should_be_valid_if_all_settings_valid() + { + _validator.Validate(_emailSettings).IsValid.Should().BeTrue(); + } + + [Test] + public void should_not_be_valid_if_port_is_out_of_range() + { + _emailSettings.Port = 900000; + + _validator.Validate(_emailSettings).IsValid.Should().BeFalse(); + } + + [Test] + public void should_not_be_valid_if_server_is_empty() + { + _emailSettings.Server = ""; + + _validator.Validate(_emailSettings).IsValid.Should().BeFalse(); + } + + [Test] + public void should_not_be_valid_if_from_is_empty() + { + _emailSettings.From = ""; + + _validator.Validate(_emailSettings).IsValid.Should().BeFalse(); + } + + [TestCase("lidarr")] + [TestCase("lidarr@lidarr")] + [TestCase("lidarr.audio")] + public void should_not_be_valid_if_to_is_invalid(string email) + { + _emailSettings.To = new string[] { email }; + + _validator.Validate(_emailSettings).IsValid.Should().BeFalse(); + } + + [TestCase("lidarr")] + [TestCase("lidarr@lidarr")] + [TestCase("lidarr.audio")] + public void should_not_be_valid_if_cc_is_invalid(string email) + { + _emailSettings.CC = new string[] { email }; + + _validator.Validate(_emailSettings).IsValid.Should().BeFalse(); + } + + [TestCase("lidarr")] + [TestCase("lidarr@lidarr")] + [TestCase("lidarr.audio")] + public void should_not_be_valid_if_bcc_is_invalid(string email) + { + _emailSettings.Bcc = new string[] { email }; + + _validator.Validate(_emailSettings).IsValid.Should().BeFalse(); + } + + [Test] + public void should_not_be_valid_if_to_bcc_cc_are_all_empty() + { + _emailSettings.To = Array.Empty(); + _emailSettings.CC = Array.Empty(); + _emailSettings.Bcc = Array.Empty(); + + _validator.Validate(_emailSettings).IsValid.Should().BeFalse(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/047_update_notifiarr.cs b/src/NzbDrone.Core/Datastore/Migration/047_update_notifiarr.cs index 3753fc533..15d145df1 100644 --- a/src/NzbDrone.Core/Datastore/Migration/047_update_notifiarr.cs +++ b/src/NzbDrone.Core/Datastore/Migration/047_update_notifiarr.cs @@ -1,5 +1,4 @@ using FluentMigrator; -using Newtonsoft.Json.Linq; using NzbDrone.Core.Datastore.Migration.Framework; namespace NzbDrone.Core.Datastore.Migration diff --git a/src/NzbDrone.Core/Datastore/Migration/049_email_multiple_addresses.cs b/src/NzbDrone.Core/Datastore/Migration/049_email_multiple_addresses.cs new file mode 100644 index 000000000..6aff1c93a --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/049_email_multiple_addresses.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Text.Json; +using Dapper; +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(049)] + public class email_multiple_addresses : NzbDroneMigrationBase + { + private readonly JsonSerializerOptions _serializerSettings; + + public email_multiple_addresses() + { + _serializerSettings = new JsonSerializerOptions + { + AllowTrailingCommas = true, + IgnoreNullValues = false, + PropertyNameCaseInsensitive = true, + DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + } + + protected override void MainDbUpgrade() + { + Execute.WithConnection(ChangeEmailAddressType); + } + + private void ChangeEmailAddressType(IDbConnection conn, IDbTransaction tran) + { + var rows = conn.Query($"SELECT Id, Settings FROM Notifications WHERE Implementation = 'Email'"); + + var corrected = new List(); + + foreach (var row in rows) + { + var settings = JsonSerializer.Deserialize(row.Settings, _serializerSettings); + + var newSettings = new EmailSettings49 + { + Server = settings.Server, + Port = settings.Port, + Ssl = settings.Ssl, + Username = settings.Username, + Password = settings.Password, + From = settings.From, + To = new string[] { settings.To }, + CC = Array.Empty(), + Bcc = Array.Empty() + }; + + corrected.Add(new ProviderDefinition166 + { + Id = row.Id, + Settings = JsonSerializer.Serialize(newSettings, _serializerSettings) + }); + } + + var updateSql = "UPDATE Notifications SET Settings = @Settings WHERE Id = @Id"; + conn.Execute(updateSql, corrected, transaction: tran); + } + + private class ProviderDefinition166 : ModelBase + { + public string Settings { get; set; } + } + + private class EmailSettings48 + { + public string Server { get; set; } + public int Port { get; set; } + public bool Ssl { get; set; } + public string Username { get; set; } + public string Password { get; set; } + public string From { get; set; } + public string To { get; set; } + } + + private class EmailSettings49 + { + public string Server { get; set; } + public int Port { get; set; } + public bool Ssl { get; set; } + public string Username { get; set; } + public string Password { get; set; } + public string From { get; set; } + public string[] To { get; set; } + public string[] CC { get; set; } + public string[] Bcc { get; set; } + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Email/Email.cs b/src/NzbDrone.Core/Notifications/Email/Email.cs index d967962cd..fe32372cf 100644 --- a/src/NzbDrone.Core/Notifications/Email/Email.cs +++ b/src/NzbDrone.Core/Notifications/Email/Email.cs @@ -1,18 +1,24 @@ +using System; using System.Collections.Generic; +using System.Linq; using FluentValidation.Results; +using MailKit.Net.Smtp; +using MailKit.Security; +using MimeKit; +using NLog; using NzbDrone.Common.Extensions; namespace NzbDrone.Core.Notifications.Email { public class Email : NotificationBase { - private readonly IEmailService _emailService; + private readonly Logger _logger; public override string Name => "Email"; - public Email(IEmailService emailService) + public Email(Logger logger) { - _emailService = emailService; + _logger = logger; } public override string Link => null; @@ -21,38 +27,143 @@ namespace NzbDrone.Core.Notifications.Email { var body = $"{grabMessage.Message} sent to queue."; - _emailService.SendEmail(Settings, ALBUM_GRABBED_TITLE_BRANDED, body); + SendEmail(Settings, ALBUM_GRABBED_TITLE_BRANDED, body); } public override void OnReleaseImport(AlbumDownloadMessage message) { var body = $"{message.Message} Downloaded and sorted."; - _emailService.SendEmail(Settings, ALBUM_DOWNLOADED_TITLE_BRANDED, body); + SendEmail(Settings, ALBUM_DOWNLOADED_TITLE_BRANDED, body); } public override void OnHealthIssue(HealthCheck.HealthCheck message) { - _emailService.SendEmail(Settings, HEALTH_ISSUE_TITLE_BRANDED, message.Message); + SendEmail(Settings, HEALTH_ISSUE_TITLE_BRANDED, message.Message); } public override void OnDownloadFailure(DownloadFailedMessage message) { - _emailService.SendEmail(Settings, DOWNLOAD_FAILURE_TITLE_BRANDED, message.Message); + SendEmail(Settings, DOWNLOAD_FAILURE_TITLE_BRANDED, message.Message); } public override void OnImportFailure(AlbumDownloadMessage message) { - _emailService.SendEmail(Settings, IMPORT_FAILURE_TITLE_BRANDED, message.Message); + SendEmail(Settings, IMPORT_FAILURE_TITLE_BRANDED, message.Message); } public override ValidationResult Test() { var failures = new List(); - failures.AddIfNotNull(_emailService.Test(Settings)); + failures.AddIfNotNull(Test(Settings)); return new ValidationResult(failures); } + + public ValidationFailure Test(EmailSettings settings) + { + const string body = "Success! You have properly configured your email notification settings"; + + try + { + SendEmail(settings, "Radarr - Test Notification", body); + } + catch (Exception ex) + { + _logger.Error(ex, "Unable to send test email"); + return new ValidationFailure("Server", "Unable to send test email"); + } + + return null; + } + + private void SendEmail(EmailSettings settings, string subject, string body, bool htmlBody = false) + { + var email = new MimeMessage(); + + email.From.Add(ParseAddress("From", settings.From)); + email.To.AddRange(settings.To.Select(x => ParseAddress("To", x))); + email.Cc.AddRange(settings.CC.Select(x => ParseAddress("CC", x))); + email.Bcc.AddRange(settings.Bcc.Select(x => ParseAddress("BCC", x))); + + email.Subject = subject; + email.Body = new TextPart(htmlBody ? "html" : "plain") + { + Text = body + }; + + _logger.Debug("Sending email Subject: {0}", subject); + + try + { + Send(email, settings); + _logger.Debug("Email sent. Subject: {0}", subject); + } + catch (Exception ex) + { + _logger.Error("Error sending email. Subject: {0}", email.Subject); + _logger.Debug(ex, ex.Message); + throw; + } + + _logger.Debug("Finished sending email. Subject: {0}", email.Subject); + } + + private void Send(MimeMessage email, EmailSettings settings) + { + using (var client = new SmtpClient()) + { + client.Timeout = 10000; + + var serverOption = SecureSocketOptions.Auto; + + if (settings.RequireEncryption) + { + if (settings.Port == 465) + { + serverOption = SecureSocketOptions.SslOnConnect; + } + else + { + serverOption = SecureSocketOptions.StartTls; + } + } + + _logger.Debug("Connecting to mail server"); + + client.Connect(settings.Server, settings.Port, serverOption); + + if (!string.IsNullOrWhiteSpace(settings.Username)) + { + _logger.Debug("Authenticating to mail server"); + + client.Authenticate(settings.Username, settings.Password); + } + + _logger.Debug("Sending to mail server"); + + client.Send(email); + + _logger.Debug("Sent to mail server, disconnecting"); + + client.Disconnect(true); + + _logger.Debug("Disconnecting from mail server"); + } + } + + private MailboxAddress ParseAddress(string type, string address) + { + try + { + return MailboxAddress.Parse(address); + } + catch (Exception ex) + { + _logger.Error(ex, "{0} email address '{1}' invalid", type, address); + throw; + } + } } } diff --git a/src/NzbDrone.Core/Notifications/Email/EmailService.cs b/src/NzbDrone.Core/Notifications/Email/EmailService.cs deleted file mode 100644 index b0cfb1067..000000000 --- a/src/NzbDrone.Core/Notifications/Email/EmailService.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System; -using System.Linq; -using FluentValidation.Results; -using MailKit.Net.Smtp; -using MailKit.Security; -using MimeKit; -using NLog; - -namespace NzbDrone.Core.Notifications.Email -{ - public interface IEmailService - { - void SendEmail(EmailSettings settings, string subject, string body, bool htmlBody = false); - ValidationFailure Test(EmailSettings settings); - } - - public class EmailService : IEmailService - { - private readonly Logger _logger; - - public EmailService(Logger logger) - { - _logger = logger; - } - - public void SendEmail(EmailSettings settings, string subject, string body, bool htmlBody = false) - { - var email = new MimeMessage(); - email.From.Add(MailboxAddress.Parse(settings.From)); - - email.To.Add(MailboxAddress.Parse(settings.To)); - - email.Subject = subject; - email.Body = new TextPart(htmlBody ? "html" : "plain") - { - Text = body - }; - - try - { - Send(email, settings); - } - catch (Exception ex) - { - _logger.Error("Error sending email. Subject: {0}", email.Subject); - _logger.Debug(ex, ex.Message); - throw; - } - } - - private void Send(MimeMessage email, EmailSettings settings) - { - using (var client = new SmtpClient()) - { - var serverOption = SecureSocketOptions.Auto; - - if (settings.RequireEncryption) - { - if (settings.Port == 465) - { - serverOption = SecureSocketOptions.SslOnConnect; - } - else - { - serverOption = SecureSocketOptions.StartTls; - } - } - - client.Connect(settings.Server, settings.Port, serverOption); - - if (!string.IsNullOrWhiteSpace(settings.Username)) - { - client.Authenticate(settings.Username, settings.Password); - } - - client.Send(email); - client.Disconnect(true); - } - } - - public ValidationFailure Test(EmailSettings settings) - { - const string body = "Success! You have properly configured your email notification settings"; - - try - { - SendEmail(settings, "Lidarr - Test Notification", body); - } - catch (Exception ex) - { - _logger.Error(ex, "Unable to send test email"); - return new ValidationFailure("Server", "Unable to send test email"); - } - - return null; - } - } -} diff --git a/src/NzbDrone.Core/Notifications/Email/EmailSettings.cs b/src/NzbDrone.Core/Notifications/Email/EmailSettings.cs index 0ee52dafe..1cc716c5d 100644 --- a/src/NzbDrone.Core/Notifications/Email/EmailSettings.cs +++ b/src/NzbDrone.Core/Notifications/Email/EmailSettings.cs @@ -1,4 +1,7 @@ -using FluentValidation; +using System; +using System.Collections.Generic; +using System.Linq; +using FluentValidation; using NzbDrone.Core.Annotations; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; @@ -12,7 +15,14 @@ namespace NzbDrone.Core.Notifications.Email RuleFor(c => c.Server).NotEmpty(); RuleFor(c => c.Port).InclusiveBetween(1, 65535); RuleFor(c => c.From).NotEmpty(); - RuleFor(c => c.To).NotEmpty(); + RuleForEach(c => c.To).EmailAddress(); + RuleForEach(c => c.CC).EmailAddress(); + RuleForEach(c => c.Bcc).EmailAddress(); + + // Only require one of three send fields to be set + RuleFor(c => c.To).NotEmpty().Unless(c => c.Bcc.Any() || c.CC.Any()); + RuleFor(c => c.CC).NotEmpty().Unless(c => c.To.Any() || c.Bcc.Any()); + RuleFor(c => c.Bcc).NotEmpty().Unless(c => c.To.Any() || c.CC.Any()); } } @@ -22,7 +32,12 @@ namespace NzbDrone.Core.Notifications.Email public EmailSettings() { + Server = "smtp.gmail.com"; Port = 587; + + To = Array.Empty(); + CC = Array.Empty(); + Bcc = Array.Empty(); } [FieldDefinition(0, Label = "Server", HelpText = "Hostname or IP of Email server")] @@ -43,8 +58,14 @@ namespace NzbDrone.Core.Notifications.Email [FieldDefinition(5, Label = "From Address")] public string From { get; set; } - [FieldDefinition(6, Label = "Recipient Address")] - public string To { get; set; } + [FieldDefinition(6, Label = "Recipient Address(es)", HelpText = "Comma separated list of email recipients")] + public IEnumerable To { get; set; } + + [FieldDefinition(7, Label = "CC Address(es)", HelpText = "Comma separated list of email cc recipients", Advanced = true)] + public IEnumerable CC { get; set; } + + [FieldDefinition(8, Label = "BCC Address(es)", HelpText = "Comma separated list of email bcc recipients", Advanced = true)] + public IEnumerable Bcc { get; set; } public NzbDroneValidationResult Validate() {