diff --git a/Surge365.MassEmailReact.API/Controllers/MailingsController.cs b/Surge365.MassEmailReact.API/Controllers/MailingsController.cs index 3e865b0..a7225ac 100644 --- a/Surge365.MassEmailReact.API/Controllers/MailingsController.cs +++ b/Surge365.MassEmailReact.API/Controllers/MailingsController.cs @@ -29,20 +29,27 @@ namespace Surge365.MassEmailReact.API.Controllers public async Task CheckNameAvailable([FromQuery] int? id, [FromQuery][Required] string name) { var available = await _mailingService.NameIsAvailableAsync(id, name); - return Ok(available); + return Ok(new { available }); + } + + [HttpGet("nextavailablename")] + public async Task GetNextAvailableName([FromQuery] int? id, [FromQuery][Required] string name) + { + var nextAvailableName = await _mailingService.GetNextAvailableNameAsync(id, name); + return Ok(new { name = nextAvailableName }); } [HttpGet("status/{statusCode}")] - public async Task GetByStatus(string statusCode) + public async Task GetByStatus(string statusCode, [FromQuery] string? startDate, [FromQuery] string? endDate) { - var mailings = await _mailingService.GetByStatusAsync(statusCode); + var mailings = await _mailingService.GetByStatusAsync(statusCode, startDate, endDate); return Ok(mailings); } [HttpGet("status/{statusCode}/stats")] - public async Task GetStatisticsByStatus(string statusCode) + public async Task GetStatisticsByStatus(string statusCode, [FromQuery] string? startDate, [FromQuery] string? endDate) { - var mailings = await _mailingService.GetStatisticsByStatusAsync(statusCode); + var mailings = await _mailingService.GetStatisticsByStatusAsync(statusCode, startDate, endDate); return Ok(mailings); } @@ -67,6 +74,12 @@ namespace Surge365.MassEmailReact.API.Controllers if (mailingUpdateDto.Id != null && mailingUpdateDto.Id > 0) return BadRequest("Id must be null or 0"); + if (mailingUpdateDto.ScheduleDate.HasValue && mailingUpdateDto.ScheduleDate.Value.Kind == DateTimeKind.Utc) + mailingUpdateDto.ScheduleDate = TimeZoneInfo.ConvertTimeFromUtc(mailingUpdateDto.ScheduleDate.Value, TimeZoneInfo.Local); + + if (mailingUpdateDto.RecurringStartDate.HasValue && mailingUpdateDto.RecurringStartDate.Value.Kind == DateTimeKind.Utc) + mailingUpdateDto.RecurringStartDate = TimeZoneInfo.ConvertTimeFromUtc(mailingUpdateDto.RecurringStartDate.Value, TimeZoneInfo.Local); + var mailingId = await _mailingService.CreateAsync(mailingUpdateDto); if (mailingId == null) return StatusCode(StatusCodes.Status500InternalServerError, "Failed to create mailing."); @@ -109,5 +122,18 @@ namespace Surge365.MassEmailReact.API.Controllers var updatedMailing = await _mailingService.GetByIdAsync(id); return Ok(updatedMailing); } + + [HttpPost("test")] + public async Task TestMailing([FromBody] TestMailingDto testMailingDto) + { + ArgumentNullException.ThrowIfNull(testMailingDto); + ArgumentNullException.ThrowIfNull(testMailingDto.EmailAddresses); + ArgumentNullException.ThrowIfNull(testMailingDto.Mailing); + + if (!await _mailingService.TestMailing(testMailingDto.Mailing, testMailingDto.EmailAddresses)) + return StatusCode(StatusCodes.Status500InternalServerError, "Failed to test mailing."); + + return Ok(); + } } } \ No newline at end of file diff --git a/Surge365.MassEmailReact.API/appsettings.json b/Surge365.MassEmailReact.API/appsettings.json index ba5de67..c0c5865 100644 --- a/Surge365.MassEmailReact.API/appsettings.json +++ b/Surge365.MassEmailReact.API/appsettings.json @@ -17,5 +17,10 @@ "MassEmail.ConnectionString": "data source=uat.surge365.com;initial catalog=MassEmail;User ID=ytb;Password=YTB()nl!n3;Encrypt=yes;TrustServerCertificate=yes;Connection Timeout=3;Application Name=##application_name##;" //TODO: Move this to development.json, on server should go somewhere secure. GET IT OUT OF GIT }, "TestTargetSql": "CREATE TABLE #columns\r\n(\r\n primary_key INT NOT NULL IDENTITY(1,1) PRIMARY KEY,\r\n name VARCHAR(255),\r\n data_type CHAR(1)\r\n)\r\nSELECT TOP 10 *\r\nINTO #list\r\nFROM ##database_name##..##view_name##\r\n##filter##\r\n\r\nDECLARE @row_count INT\r\nSELECT @row_count = COUNT(*)\r\nFROM ##database_name##..##view_name##\r\n##filter##\r\n\r\nDECLARE c_curs CURSOR FOR \r\nSELECT c.name AS column_name, t.name AS data_type\r\nFROM tempdb.sys.columns c\r\nINNER JOIN tempdb.sys.types t ON c.user_type_id = t.user_type_id\r\n AND t.name NOT IN ('text','ntext','image','binary','varbinary','image','cursor','timestamp','hierarchyid','sql_variant','xml','table')\r\nWHERE object_id = object_id('tempdb..#list') \r\n AND ((t.name IN ('char','varchar') AND c.max_length <= 255)\r\n OR (t.name IN ('nchar','nvarchar') AND c.max_length <= 510)\r\n OR (t.name NOT IN ('char','varchar','nchar','nvarchar')))\r\n \r\nOPEN c_curs\r\nDECLARE @column_name VARCHAR(255), @column_type VARCHAR(255)\r\n\r\nFETCH NEXT FROM c_curs INTO @column_name, @column_type\r\nWHILE(@@FETCH_STATUS = 0)\r\nBEGIN \r\n DECLARE @data_type CHAR(1) = 'S'\r\n IF(@column_type IN ('date','datetime','datetime2','datetimeoffset','smalldatetime','time'))\r\n BEGIN\r\n SET @data_type = 'D'\r\n END\r\n ELSE IF(@column_type IN ('bit'))\r\n BEGIN\r\n SET @data_type = 'B'\r\n END\r\n ELSE IF(@column_type IN ('bigint','numeric','smallint','decimal','smallmoney','int','tinyint','money','float','real'))\r\n BEGIN\r\n SET @data_type = 'N'\r\n END\r\n INSERT INTO #columns(name, data_type) VALUES(@column_name, @data_type)\r\n FETCH NEXT FROM c_curs INTO @column_name, @column_type\r\nEND\r\nCLOSE c_curs\r\nDEALLOCATE c_curs\r\nSELECT * FROM #columns ORDER BY primary_key\r\nSELECT * FROM #list\r\nSELECT @row_count AS row_count\r\nDROP TABLE #columns\r\nDROP TABLE #list", - "ConnectionStringTemplate": "data source=##server_name##,##port##;initial catalog=##database_name##;User ID=##username##;Password=##password##;persist security info=False;packet size=4096;" -} + "ConnectionStringTemplate": "data source=##server_name##,##port##;initial catalog=##database_name##;User ID=##username##;Password=##password##;persist security info=False;packet size=4096;", + "DefaultUnsubscribeUrl": "http://emailopentracking.surge365.com/unsubscribe.htm", + "SendGrid_TestMode": false, + "RegularExpression_Email": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "SendGrid_Url": "smtp.sendgrid.net", + "SendGrid_Port": "587" +} \ No newline at end of file diff --git a/Surge365.MassEmailReact.Application/DTOs/TestMailingDto.cs b/Surge365.MassEmailReact.Application/DTOs/TestMailingDto.cs new file mode 100644 index 0000000..ef8f56e --- /dev/null +++ b/Surge365.MassEmailReact.Application/DTOs/TestMailingDto.cs @@ -0,0 +1,10 @@ +using System; + +namespace Surge365.MassEmailReact.Domain.Entities +{ + public class TestMailingDto + { + public MailingUpdateDto? Mailing { get; set; } + public List EmailAddresses { get; set; } = new List(); + } +} \ No newline at end of file diff --git a/Surge365.MassEmailReact.Application/Interfaces/IMailingRepository.cs b/Surge365.MassEmailReact.Application/Interfaces/IMailingRepository.cs index a237fd4..382c997 100644 --- a/Surge365.MassEmailReact.Application/Interfaces/IMailingRepository.cs +++ b/Surge365.MassEmailReact.Application/Interfaces/IMailingRepository.cs @@ -8,11 +8,11 @@ namespace Surge365.MassEmailReact.Application.Interfaces { Task GetByIdAsync(int id); Task> GetAllAsync(bool activeOnly = true); - Task> GetByStatusAsync(string code); - Task> GetStatisticsByStatusAsync(string code); + Task> GetByStatusAsync(string code, string? startDate, string? endDate); + Task> GetStatisticsByStatusAsync(string code, string? startDate, string? endDate); Task GetStatisticByIdAsync(int id); Task NameIsAvailableAsync(int? id, string name); - + Task GetNextAvailableNameAsync(int? id, string name); Task CreateAsync(Mailing mailing); Task UpdateAsync(Mailing mailing); Task CancelMailingAsync(int id); diff --git a/Surge365.MassEmailReact.Application/Interfaces/IMailingService.cs b/Surge365.MassEmailReact.Application/Interfaces/IMailingService.cs index 592e01c..8277d0f 100644 --- a/Surge365.MassEmailReact.Application/Interfaces/IMailingService.cs +++ b/Surge365.MassEmailReact.Application/Interfaces/IMailingService.cs @@ -9,13 +9,15 @@ namespace Surge365.MassEmailReact.Application.Interfaces Task GetByIdAsync(int id); Task> GetAllAsync(bool activeOnly = true); - Task> GetByStatusAsync(string code); - Task> GetStatisticsByStatusAsync(string code); + Task> GetByStatusAsync(string code, string? startDate, string? endDate); + Task> GetStatisticsByStatusAsync(string code, string? startDate, string? endDate); Task GetStatisticByIdAsync(int id); Task NameIsAvailableAsync(int? id, string name); + Task GetNextAvailableNameAsync(int? id, string name); Task CreateAsync(MailingUpdateDto mailingDto); Task UpdateAsync(MailingUpdateDto mailingDto); Task CancelMailingAsync(int id); + Task TestMailing(MailingUpdateDto mailingUpdateDto, List emails); } } \ No newline at end of file diff --git a/Surge365.MassEmailReact.Infrastructure/Repositories/MailingRepository.cs b/Surge365.MassEmailReact.Infrastructure/Repositories/MailingRepository.cs index 8ba4a9a..8f6c1fa 100644 --- a/Surge365.MassEmailReact.Infrastructure/Repositories/MailingRepository.cs +++ b/Surge365.MassEmailReact.Infrastructure/Repositories/MailingRepository.cs @@ -44,19 +44,19 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories using SqlConnection conn = new SqlConnection(ConnectionString); return (await conn.QueryAsync("mem_get_blast_all", new { active_only = activeOnly }, commandType: CommandType.StoredProcedure)).ToList(); } - public async Task> GetByStatusAsync(string code) + public async Task> GetByStatusAsync(string code, string? startDate, string? endDate) { ArgumentNullException.ThrowIfNull(ConnectionString); using SqlConnection conn = new SqlConnection(ConnectionString); - return (await conn.QueryAsync("mem_get_blast_by_status", new { blast_status_code = code }, commandType: CommandType.StoredProcedure)).ToList(); + return (await conn.QueryAsync("mem_get_blast_by_status", new { blast_status_code = code, start_date = startDate, end_date = endDate }, commandType: CommandType.StoredProcedure)).ToList(); } - public async Task> GetStatisticsByStatusAsync(string code) + public async Task> GetStatisticsByStatusAsync(string code, string? startDate, string? endDate) { ArgumentNullException.ThrowIfNull(ConnectionString); using SqlConnection conn = new SqlConnection(ConnectionString); - return (await conn.QueryAsync("mem_get_blast_statistic_by_status", new { blast_status_code = code }, commandType: CommandType.StoredProcedure)).ToList(); + return (await conn.QueryAsync("mem_get_blast_statistic_by_status", new { blast_status_code = code, start_date = startDate, end_date = endDate }, commandType: CommandType.StoredProcedure)).ToList(); } public async Task GetStatisticByIdAsync(int id) { @@ -75,11 +75,24 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories parameters.Add("@blast_name", name, DbType.String); parameters.Add("@available", dbType: DbType.Boolean, direction: ParameterDirection.Output); - await conn.ExecuteAsync("mem_is_blast_name_available", parameters, commandType: CommandType.StoredProcedure); + await conn.ExecuteAsync("mem_is_blast_name_available2", parameters, commandType: CommandType.StoredProcedure); return parameters.Get("@available"); } + public async Task GetNextAvailableNameAsync(int? id, string name) + { + ArgumentNullException.ThrowIfNull(ConnectionString); + using var conn = new SqlConnection(ConnectionString); + var parameters = new DynamicParameters(); + parameters.Add("@blast_key", id, DbType.Int32); + parameters.Add("@blast_name", name, DbType.String); + parameters.Add("@next_blast_name", dbType: DbType.String, size:-1, direction: ParameterDirection.Output); + + await conn.ExecuteAsync("mem_get_next_available_blast_name", parameters, commandType: CommandType.StoredProcedure); + + return parameters.Get("@next_blast_name"); + } public async Task CreateAsync(Mailing mailing) { diff --git a/Surge365.MassEmailReact.Infrastructure/Services/MailingService.cs b/Surge365.MassEmailReact.Infrastructure/Services/MailingService.cs index 199ec8d..e442e02 100644 --- a/Surge365.MassEmailReact.Infrastructure/Services/MailingService.cs +++ b/Surge365.MassEmailReact.Infrastructure/Services/MailingService.cs @@ -1,20 +1,66 @@ using Microsoft.Extensions.Configuration; +using Surge365.MassEmailReact.Application.DTOs; using Surge365.MassEmailReact.Application.Interfaces; using Surge365.MassEmailReact.Domain.Entities; using System; using System.Collections.Generic; +using System.Data; +using System.Net.Mail; +using System.Text.RegularExpressions; using System.Threading.Tasks; +using SendGrid; +using SendGrid.Helpers.Mail; namespace Surge365.MassEmailReact.Infrastructure.Services { public class MailingService : IMailingService { + private readonly ITargetService _targetService; + private readonly ITemplateService _templateService; + private readonly IEmailDomainService _emailDomainService; private readonly IMailingRepository _mailingRepository; private readonly IConfiguration _config; - - public MailingService(IMailingRepository mailingRepository, IConfiguration config) + private string DefaultUnsubscribeUrl + { + get + { + return _config["DefaultUnsubscribeUrl"] ?? ""; + } + } + private bool SendGridTestMode + { + get + { + return _config.GetValue("SendGrid_TestMode"); + } + } + private string EmailRegex + { + get + { + return _config["RegularExpression_Email"] ?? ""; + } + } + private string SendGridUrl + { + get + { + return _config["SendGrid_Url"] ?? ""; + } + } + private int SendGridPort + { + get + { + return _config.GetValue("SendGrid_Port"); + } + } + public MailingService(IMailingRepository mailingRepository, ITargetService targetService, ITemplateService templateService, IEmailDomainService emailDomainService, IConfiguration config) { _mailingRepository = mailingRepository; + _targetService = targetService; + _templateService = templateService; + _emailDomainService = emailDomainService; _config = config; } @@ -27,13 +73,13 @@ namespace Surge365.MassEmailReact.Infrastructure.Services { return await _mailingRepository.GetAllAsync(activeOnly); } - public async Task> GetByStatusAsync(string statusCode) + public async Task> GetByStatusAsync(string statusCode, string? startDate, string? endDate) { - return await _mailingRepository.GetByStatusAsync(statusCode); + return await _mailingRepository.GetByStatusAsync(statusCode, startDate, endDate); } - public async Task> GetStatisticsByStatusAsync(string code) + public async Task> GetStatisticsByStatusAsync(string code, string? startDate, string? endDate) { - return await _mailingRepository.GetStatisticsByStatusAsync(code); + return await _mailingRepository.GetStatisticsByStatusAsync(code, startDate, endDate); } public async Task GetStatisticByIdAsync(int id) { @@ -43,7 +89,10 @@ namespace Surge365.MassEmailReact.Infrastructure.Services { return await _mailingRepository.NameIsAvailableAsync(id, name); } - + public async Task GetNextAvailableNameAsync(int? id, string name) + { + return await _mailingRepository.GetNextAvailableNameAsync(id, name); + } public async Task CreateAsync(MailingUpdateDto mailingDto) { ArgumentNullException.ThrowIfNull(mailingDto, nameof(mailingDto)); @@ -92,5 +141,164 @@ namespace Surge365.MassEmailReact.Infrastructure.Services { return await _mailingRepository.CancelMailingAsync(id); } + + public async Task TestMailing(MailingUpdateDto mailing, List emails) + { + ArgumentNullException.ThrowIfNull(mailing, nameof(mailing)); + ArgumentNullException.ThrowIfNull(emails, nameof(emails)); + if(emails.Count == 0) throw new Exception("No emails provided"); + + if (mailing.TargetId <= 0 || mailing.TemplateId <= 0) + return false; + + var targetSample = await _targetService.TestTargetAsync(mailing.TargetId); + if (targetSample == null || targetSample.Rows.Count == 0) + return false; + + var template = await _templateService.GetByIdAsync(mailing.TemplateId); + if (template == null) + return false; + + var domain = await _emailDomainService.GetByIdAsync(template.DomainId, true); + if (domain == null) + return false; + + foreach (string email in emails) + await SendTestEmailLocal(template, domain, targetSample, email); + + return true; + } + private async Task SendTestEmailLocal(Template template, EmailDomain emailDomain, TargetSample targetSample, string emailAddress) + { + string html = template.HtmlBody; + string tokenTemplate = "(?i)##{0}##"; + Regex r = new Regex(String.Format(tokenTemplate, "email_address"), RegexOptions.IgnoreCase); + html = r.Replace(html, emailAddress); + string subject = template.Subject; + string toName = ""; + foreach (string columnName in targetSample.Columns.Keys) + { + r = new Regex(String.Format(tokenTemplate, columnName), RegexOptions.IgnoreCase); + html = r.Replace(html, targetSample.Rows[0][columnName]); + subject = r.Replace(subject, targetSample.Rows[0][columnName]); + toName = r.Replace(toName, targetSample.Rows[0][columnName]); + } + html = RemoveOpenTag(html); + html = MergeWithUnsubscribe(html, template); + return await SendEmailLocal(emailDomain, emailAddress, toName, html, subject, template.FromName, template.FromEmail, template.ReplyToEmail, new List(), null); + } + + private string RemoveOpenTag(string html) + { + Regex r = new Regex("(?i)##opentag##", RegexOptions.IgnoreCase); + html = r.Replace(html, ""); + return html; + } + private string MergeWithUnsubscribe(string html, Template template) + { + string url = DefaultUnsubscribeUrl; + //if (template.UnsubscribeUrl != null) + //{ + // url = template.UnsubscribeUrl.Url; + //} + //else if (blast.BlastTemplate != null && blast.BlastTemplate.BlastUnsubscribeUrl != null) + //{ + // url = blast.BlastTemplate.BlastUnsubscribeUrl.Url; + //} + //else if (blast.BlastTemplate != null && blast.BlastTemplate.UnsubscribeUrl != null) + //{ + // url = blast.BlastTemplate.UnsubscribeUrl.Url; + //} + //else + //{ + // url = Utilities.DefaultUnsubscribeUrl; + //} + Regex r = new Regex("(?i)##unsubscribeurl##", RegexOptions.IgnoreCase); + html = r.Replace(html, url); + return html; + } + private async Task SendEmailLocal(EmailDomain domain, string toAddress, string toName, string htmlBody, string subject, string fromName, string fromAddress, string replyToAddress, List categories, int? blastEmailID) + { + try + { + if (!string.IsNullOrWhiteSpace(EmailRegex)) + { + Regex r = new Regex(EmailRegex); + if (!r.IsMatch(toAddress)) + return false; + } + + MailMessage m = new MailMessage(); + m.From = new MailAddress(fromAddress, fromName); + m.Subject = subject; + m.IsBodyHtml = true; + m.Body = htmlBody; + m.ReplyToList.Add(new MailAddress(replyToAddress)); + if (SendGridTestMode) + { + m.To.Add(new MailAddress("testemailtarget1@ytb.com", toName)); + } + else + { + m.To.Add(new MailAddress(toAddress, toName)); + } + Dictionary uniqueIdentifiers = new Dictionary(); + if (blastEmailID != null) + uniqueIdentifiers.Add("emailid", blastEmailID.Value.ToString()); + await SendEmailToSendGrid(domain, m, categories, uniqueIdentifiers); + return true; + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + } + return false; + } + private async Task SendEmailToSendGrid(EmailDomain domain, MailMessage msg, List categories, Dictionary uniqueIdentifiers) + { + try + { + if (msg.From == null) + return; + if (msg.To == null || msg.To.Count == 0) + return; + if(msg.ReplyToList == null || msg.ReplyToList.Count == 0) + return; + //string username = domain.Username; + string password = domain.Password; + //string url = SendGridUrl; + //int port = SendGridPort; + + var client = new SendGridClient(password); + var message = new SendGridMessage() { + From = new EmailAddress(msg.From.Address, msg.From.DisplayName), + Subject = msg.Subject, + HtmlContent = msg.Body, + ReplyTos = msg.ReplyToList.Select(x => new EmailAddress(x.Address, x.DisplayName)).ToList(), + }; + + message.AddTos(msg.To.Select(x => new EmailAddress(x.Address, x.DisplayName)).ToList()); + if (uniqueIdentifiers != null && uniqueIdentifiers.Keys.Count > 0) + message.AddCustomArgs(uniqueIdentifiers); + if (categories != null && categories.Count > 0) + message.AddCategories(categories); + + if (SendGridTestMode) + { + message.AddCustomArg("testmode", "true"); + } + var response = await client.SendEmailAsync(message); + if (response.StatusCode != System.Net.HttpStatusCode.Accepted) + { + var errorBody = await response.Body.ReadAsStringAsync(); + throw new Exception($"Failed to send email: {response.StatusCode} - {errorBody}"); + } + } + catch (Exception ex) + { + //TODO: Log error + throw; + } + } } } \ No newline at end of file diff --git a/Surge365.MassEmailReact.Infrastructure/Surge365.MassEmailReact.Infrastructure.csproj b/Surge365.MassEmailReact.Infrastructure/Surge365.MassEmailReact.Infrastructure.csproj index 2dc1328..cb943d0 100644 --- a/Surge365.MassEmailReact.Infrastructure/Surge365.MassEmailReact.Infrastructure.csproj +++ b/Surge365.MassEmailReact.Infrastructure/Surge365.MassEmailReact.Infrastructure.csproj @@ -15,9 +15,11 @@ + + diff --git a/Surge365.MassEmailReact.Web/package-lock.json b/Surge365.MassEmailReact.Web/package-lock.json index 23feeba..b9d3f00 100644 --- a/Surge365.MassEmailReact.Web/package-lock.json +++ b/Surge365.MassEmailReact.Web/package-lock.json @@ -27,6 +27,7 @@ "react-hook-form": "^7.54.2", "react-icons": "^5.3.0", "react-router-dom": "^7.0.1", + "react-toastify": "^11.0.5", "yup": "^1.6.1" }, "devDependencies": { @@ -4241,6 +4242,19 @@ "react-dom": ">=18" } }, + "node_modules/react-toastify": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.0.5.tgz", + "integrity": "sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", diff --git a/Surge365.MassEmailReact.Web/package.json b/Surge365.MassEmailReact.Web/package.json index f1972c2..f5b03fa 100644 --- a/Surge365.MassEmailReact.Web/package.json +++ b/Surge365.MassEmailReact.Web/package.json @@ -30,6 +30,7 @@ "react-hook-form": "^7.54.2", "react-icons": "^5.3.0", "react-router-dom": "^7.0.1", + "react-toastify": "^11.0.5", "yup": "^1.6.1" }, "devDependencies": { diff --git a/Surge365.MassEmailReact.Web/src/components/modals/MailingEdit.tsx b/Surge365.MassEmailReact.Web/src/components/modals/MailingEdit.tsx index 5774bc4..912e020 100644 --- a/Surge365.MassEmailReact.Web/src/components/modals/MailingEdit.tsx +++ b/Surge365.MassEmailReact.Web/src/components/modals/MailingEdit.tsx @@ -34,6 +34,7 @@ import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import dayjs, { Dayjs } from 'dayjs'; // Import Dayjs for date handling import utc from 'dayjs/plugin/utc'; // Import the UTC plugin +import { toast } from "react-toastify"; dayjs.extend(utc); @@ -142,7 +143,13 @@ const schema = yup.object().shape({ const nameIsAvailable = async (id: number, name: string) => { const response = await fetch(`/api/mailings/available?${id > 0 ? "id=" + id + "&" : ""}name=${name}`); - return await response.json(); + const data = await response.json(); + return data.available; +}; +const getNextAvailableName = async (id: number, name: string) => { + const response = await fetch(`/api/mailings/nextavailablename?${id > 0 ? "id=" + id + "&" : ""}name=${name}`); + const data = await response.json(); + return data.name; }; const defaultMailing: Mailing = { @@ -184,36 +191,43 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => { const [loading, setLoading] = useState(false); useEffect(() => { - if (open) { - if (mailing) { - mailing.scheduleDate = null; - mailing.recurringTypeCode = null; - mailing.recurringStartDate = null; + const initializeMailingEdit = async () => { + if (open) { + if (mailing) { + mailing.scheduleDate = null; + mailing.recurringTypeCode = null; + mailing.recurringStartDate = null; + if (mailing.id == 0) { + mailing.name = await getNextAvailableName(mailing.id, mailing.name); + } + } + setApproved(false); + setRecurring(false); + setScheduleForLater(false); + reset(mailing || defaultMailing, { keepDefaultValues: true }); + if (setupData.testEmailLists.length > 0) { + setTestEmailListId(setupData.testEmailLists[0].id); + setEmails(setupData.testEmailLists[0].emails); + } else { + setTestEmailListId(null); + setEmails([]); + } + if (mailing?.templateId) { + const template = setupData.templates.find(t => t.id === mailing.templateId) || null; + setCurrentTemplate(template); + } else { + setCurrentTemplate(null); + } + if (mailing?.targetId) { + const target = setupData.targets.find(t => t.id === mailing.targetId) || null; + setCurrentTarget(target); + } else { + setCurrentTarget(null); + } } - setApproved(false); - setRecurring(false); - setScheduleForLater(false); - reset(mailing || defaultMailing, { keepDefaultValues: true }); - if (setupData.testEmailLists.length > 0) { - setTestEmailListId(setupData.testEmailLists[0].id); - setEmails(setupData.testEmailLists[0].emails); - } else { - setTestEmailListId(null); - setEmails([]); - } - if (mailing?.templateId) { - const template = setupData.templates.find(t => t.id === mailing.templateId) || null; - setCurrentTemplate(template); - } else { - setCurrentTemplate(null); - } - if (mailing?.targetId) { - const target = setupData.targets.find(t => t.id === mailing.targetId) || null; - setCurrentTarget(target); - } else { - setCurrentTarget(null); - } - } + }; + + initializeMailingEdit(); }, [open, mailing, reset, setupData.testEmailLists, setupData.targets, setupData.templates]); const handleSave = async (formData: Mailing) => { @@ -261,7 +275,47 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => { setEmails(newEmails); }; - const handleTestMailing = () => { + const handleTestMailing = async () => { + setLoading(true); // Show loading state + + const isValid = await trigger(); + if (!isValid) { + console.log("Form is invalid, cannot test mailing"); + setLoading(false); + return; + } + + try { + // Get the current form data + const formData = control._formValues as Mailing; + + // Prepare the payload matching TestMailingDto + const testMailingPayload = { + mailing: formData, + emailAddresses: emails, + }; + + // Make the API call to /api/mailings/test + const response = await fetch("/api/mailings/test", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(testMailingPayload), + }); + + if (!response.ok) { + throw new Error("Failed to test mailing"); + } + + toast.success("Test mailing sent successfully"); + console.log("Test mailing sent successfully"); + } catch (error) { + console.error("Test mailing error:", error); + toast.error("Failed to send test mailing"); + } finally { + setLoading(false); // Reset loading state + } }; const handleTestEmailListChange = (list: TestEmailList | null) => { diff --git a/Surge365.MassEmailReact.Web/src/components/modals/TestEmailListEdit.tsx b/Surge365.MassEmailReact.Web/src/components/modals/TestEmailListEdit.tsx index 1a45538..8ae34f4 100644 --- a/Surge365.MassEmailReact.Web/src/components/modals/TestEmailListEdit.tsx +++ b/Surge365.MassEmailReact.Web/src/components/modals/TestEmailListEdit.tsx @@ -79,7 +79,7 @@ const TestEmailListEdit = ({ open, testEmailList, onClose, onSave }: TestEmailLi }, [open, testEmailList, reset]); const handleSave = async (formData: TestEmailListForm) => { - const emails = formData.emails.split("\n"); + const emails = formData.emails.split("\n").filter(email => email.trim() !== ""); const testEmailListDto = { id: formData.id, name: formData.name, diff --git a/Surge365.MassEmailReact.Web/src/components/pages/App.tsx b/Surge365.MassEmailReact.Web/src/components/pages/App.tsx index 7eace0a..fb9d23c 100644 --- a/Surge365.MassEmailReact.Web/src/components/pages/App.tsx +++ b/Surge365.MassEmailReact.Web/src/components/pages/App.tsx @@ -27,6 +27,8 @@ import { createTheme, ThemeProvider, Theme } from '@mui/material/styles'; import CssBaseline from '@mui/material/CssBaseline'; import ProtectedPageWrapper from '@/components/auth/ProtectedPageWrapper'; +import { ToastContainer } from "react-toastify"; + const PageWrapper = ProtectedPageWrapper; //interface PageWrapperProps { @@ -86,6 +88,7 @@ const App = () => { + } /> (null); const [viewOpen, setViewOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); + const [searchOpen, setSearchOpen] = useState(false); + + const defaultStartDate = new Date(); + defaultStartDate.setMonth(defaultStartDate.getMonth() - 3); + + const [startDate, setStartDate] = useState(defaultStartDate); + const [endDate, setEndDate] = useState(null); const columns: GridColDef[] = [ { @@ -57,7 +65,29 @@ function CompletedMailings() { const fetchMailingStats = async () => { setMailingsLoading(true); try { - const response = await fetch("/api/mailings/status/S/stats"); + let url = "/api/mailings/status/S/stats"; + // Add query parameters if dates are provided + const params = []; + if (startDate) { + const formattedStart = startDate.toLocaleDateString('en-US', { + month: '2-digit', + day: '2-digit', + year: 'numeric' + }).replace(/\//g, '-'); + params.push(`startDate=${formattedStart}`); + } + if (endDate) { + const formattedEnd = endDate.toLocaleDateString('en-US', { + month: '2-digit', + day: '2-digit', + year: 'numeric' + }).replace(/\//g, '-'); + params.push(`endDate=${formattedEnd}`); + } + if (params.length > 0) { + url += `?${params.join('&')}`; + } + const response = await fetch(url); const statsData = await response.json(); if (statsData) { setMailingStats(statsData); @@ -116,6 +146,9 @@ function CompletedMailings() { useEffect(() => { fetchMailingStats(); // Initial fetch }, []); + const handleSearch = () => { + fetchMailingStats(); + }; return ( - + + setSearchOpen(!searchOpen)}> + Search Filters + + + + + setStartDate(e.target.value ? new Date(e.target.value) : null)} + InputLabelProps={{ shrink: true }} + /> + setEndDate(e.target.value ? new Date(e.target.value) : null)} + InputLabelProps={{ shrink: true }} + /> + + + + + {isMobile ? ( {mailingStats.map((stat) => (