Add mailing features and update notification system

- Updated `MailingsController` with new endpoints for name availability and next available name.
- Modified existing methods to accept optional date parameters.
- Introduced `TestMailingDto` for testing mailings with email addresses.
- Updated `IMailingRepository` and `IMailingService` interfaces for new methods.
- Enhanced `MailingRepository` and `MailingService` to support new logic for testing and sending emails.
- Updated `appsettings.json` with new SendGrid settings and default unsubscribe URL.
- Modified `MailingEdit` component to fetch next available name and handle test mailings.
- Added search filters for start and end dates in `CompletedMailings` component.
- Included `react-toastify` for notifications in `package.json` and `package-lock.json`.
- Updated `App` component to include `ToastContainer` for displaying notifications.
This commit is contained in:
David Headrick 2025-03-30 08:32:57 -05:00
parent 035a2e1dae
commit 26abe9e028
14 changed files with 461 additions and 59 deletions

View File

@ -29,20 +29,27 @@ namespace Surge365.MassEmailReact.API.Controllers
public async Task<IActionResult> CheckNameAvailable([FromQuery] int? id, [FromQuery][Required] string name) public async Task<IActionResult> CheckNameAvailable([FromQuery] int? id, [FromQuery][Required] string name)
{ {
var available = await _mailingService.NameIsAvailableAsync(id, name); var available = await _mailingService.NameIsAvailableAsync(id, name);
return Ok(available); return Ok(new { available });
}
[HttpGet("nextavailablename")]
public async Task<IActionResult> GetNextAvailableName([FromQuery] int? id, [FromQuery][Required] string name)
{
var nextAvailableName = await _mailingService.GetNextAvailableNameAsync(id, name);
return Ok(new { name = nextAvailableName });
} }
[HttpGet("status/{statusCode}")] [HttpGet("status/{statusCode}")]
public async Task<IActionResult> GetByStatus(string statusCode) public async Task<IActionResult> 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); return Ok(mailings);
} }
[HttpGet("status/{statusCode}/stats")] [HttpGet("status/{statusCode}/stats")]
public async Task<IActionResult> GetStatisticsByStatus(string statusCode) public async Task<IActionResult> 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); return Ok(mailings);
} }
@ -67,6 +74,12 @@ namespace Surge365.MassEmailReact.API.Controllers
if (mailingUpdateDto.Id != null && mailingUpdateDto.Id > 0) if (mailingUpdateDto.Id != null && mailingUpdateDto.Id > 0)
return BadRequest("Id must be null or 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); var mailingId = await _mailingService.CreateAsync(mailingUpdateDto);
if (mailingId == null) if (mailingId == null)
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to create mailing."); return StatusCode(StatusCodes.Status500InternalServerError, "Failed to create mailing.");
@ -109,5 +122,18 @@ namespace Surge365.MassEmailReact.API.Controllers
var updatedMailing = await _mailingService.GetByIdAsync(id); var updatedMailing = await _mailingService.GetByIdAsync(id);
return Ok(updatedMailing); return Ok(updatedMailing);
} }
[HttpPost("test")]
public async Task<IActionResult> 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();
}
} }
} }

View File

@ -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 "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", "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"
}

View File

@ -0,0 +1,10 @@
using System;
namespace Surge365.MassEmailReact.Domain.Entities
{
public class TestMailingDto
{
public MailingUpdateDto? Mailing { get; set; }
public List<string> EmailAddresses { get; set; } = new List<string>();
}
}

View File

@ -8,11 +8,11 @@ namespace Surge365.MassEmailReact.Application.Interfaces
{ {
Task<Mailing?> GetByIdAsync(int id); Task<Mailing?> GetByIdAsync(int id);
Task<List<Mailing>> GetAllAsync(bool activeOnly = true); Task<List<Mailing>> GetAllAsync(bool activeOnly = true);
Task<List<Mailing>> GetByStatusAsync(string code); Task<List<Mailing>> GetByStatusAsync(string code, string? startDate, string? endDate);
Task<List<MailingStatistic>> GetStatisticsByStatusAsync(string code); Task<List<MailingStatistic>> GetStatisticsByStatusAsync(string code, string? startDate, string? endDate);
Task<MailingStatistic?> GetStatisticByIdAsync(int id); Task<MailingStatistic?> GetStatisticByIdAsync(int id);
Task<bool> NameIsAvailableAsync(int? id, string name); Task<bool> NameIsAvailableAsync(int? id, string name);
Task<string> GetNextAvailableNameAsync(int? id, string name);
Task<int?> CreateAsync(Mailing mailing); Task<int?> CreateAsync(Mailing mailing);
Task<bool> UpdateAsync(Mailing mailing); Task<bool> UpdateAsync(Mailing mailing);
Task<bool> CancelMailingAsync(int id); Task<bool> CancelMailingAsync(int id);

View File

@ -9,13 +9,15 @@ namespace Surge365.MassEmailReact.Application.Interfaces
Task<Mailing?> GetByIdAsync(int id); Task<Mailing?> GetByIdAsync(int id);
Task<List<Mailing>> GetAllAsync(bool activeOnly = true); Task<List<Mailing>> GetAllAsync(bool activeOnly = true);
Task<List<Mailing>> GetByStatusAsync(string code); Task<List<Mailing>> GetByStatusAsync(string code, string? startDate, string? endDate);
Task<List<MailingStatistic>> GetStatisticsByStatusAsync(string code); Task<List<MailingStatistic>> GetStatisticsByStatusAsync(string code, string? startDate, string? endDate);
Task<MailingStatistic?> GetStatisticByIdAsync(int id); Task<MailingStatistic?> GetStatisticByIdAsync(int id);
Task<bool> NameIsAvailableAsync(int? id, string name); Task<bool> NameIsAvailableAsync(int? id, string name);
Task<string> GetNextAvailableNameAsync(int? id, string name);
Task<int?> CreateAsync(MailingUpdateDto mailingDto); Task<int?> CreateAsync(MailingUpdateDto mailingDto);
Task<bool> UpdateAsync(MailingUpdateDto mailingDto); Task<bool> UpdateAsync(MailingUpdateDto mailingDto);
Task<bool> CancelMailingAsync(int id); Task<bool> CancelMailingAsync(int id);
Task<bool> TestMailing(MailingUpdateDto mailingUpdateDto, List<string> emails);
} }
} }

View File

@ -44,19 +44,19 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
using SqlConnection conn = new SqlConnection(ConnectionString); using SqlConnection conn = new SqlConnection(ConnectionString);
return (await conn.QueryAsync<Mailing>("mem_get_blast_all", new { active_only = activeOnly }, commandType: CommandType.StoredProcedure)).ToList(); return (await conn.QueryAsync<Mailing>("mem_get_blast_all", new { active_only = activeOnly }, commandType: CommandType.StoredProcedure)).ToList();
} }
public async Task<List<Mailing>> GetByStatusAsync(string code) public async Task<List<Mailing>> GetByStatusAsync(string code, string? startDate, string? endDate)
{ {
ArgumentNullException.ThrowIfNull(ConnectionString); ArgumentNullException.ThrowIfNull(ConnectionString);
using SqlConnection conn = new SqlConnection(ConnectionString); using SqlConnection conn = new SqlConnection(ConnectionString);
return (await conn.QueryAsync<Mailing>("mem_get_blast_by_status", new { blast_status_code = code }, commandType: CommandType.StoredProcedure)).ToList(); return (await conn.QueryAsync<Mailing>("mem_get_blast_by_status", new { blast_status_code = code, start_date = startDate, end_date = endDate }, commandType: CommandType.StoredProcedure)).ToList();
} }
public async Task<List<MailingStatistic>> GetStatisticsByStatusAsync(string code) public async Task<List<MailingStatistic>> GetStatisticsByStatusAsync(string code, string? startDate, string? endDate)
{ {
ArgumentNullException.ThrowIfNull(ConnectionString); ArgumentNullException.ThrowIfNull(ConnectionString);
using SqlConnection conn = new SqlConnection(ConnectionString); using SqlConnection conn = new SqlConnection(ConnectionString);
return (await conn.QueryAsync<MailingStatistic>("mem_get_blast_statistic_by_status", new { blast_status_code = code }, commandType: CommandType.StoredProcedure)).ToList(); return (await conn.QueryAsync<MailingStatistic>("mem_get_blast_statistic_by_status", new { blast_status_code = code, start_date = startDate, end_date = endDate }, commandType: CommandType.StoredProcedure)).ToList();
} }
public async Task<MailingStatistic?> GetStatisticByIdAsync(int id) public async Task<MailingStatistic?> GetStatisticByIdAsync(int id)
{ {
@ -75,11 +75,24 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
parameters.Add("@blast_name", name, DbType.String); parameters.Add("@blast_name", name, DbType.String);
parameters.Add("@available", dbType: DbType.Boolean, direction: ParameterDirection.Output); 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<bool>("@available"); return parameters.Get<bool>("@available");
} }
public async Task<string> 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<string>("@next_blast_name");
}
public async Task<int?> CreateAsync(Mailing mailing) public async Task<int?> CreateAsync(Mailing mailing)
{ {

View File

@ -1,20 +1,66 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Surge365.MassEmailReact.Application.DTOs;
using Surge365.MassEmailReact.Application.Interfaces; using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Domain.Entities; using Surge365.MassEmailReact.Domain.Entities;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Data;
using System.Net.Mail;
using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using SendGrid;
using SendGrid.Helpers.Mail;
namespace Surge365.MassEmailReact.Infrastructure.Services namespace Surge365.MassEmailReact.Infrastructure.Services
{ {
public class MailingService : IMailingService public class MailingService : IMailingService
{ {
private readonly ITargetService _targetService;
private readonly ITemplateService _templateService;
private readonly IEmailDomainService _emailDomainService;
private readonly IMailingRepository _mailingRepository; private readonly IMailingRepository _mailingRepository;
private readonly IConfiguration _config; private readonly IConfiguration _config;
private string DefaultUnsubscribeUrl
public MailingService(IMailingRepository mailingRepository, IConfiguration config) {
get
{
return _config["DefaultUnsubscribeUrl"] ?? "";
}
}
private bool SendGridTestMode
{
get
{
return _config.GetValue<bool>("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<int>("SendGrid_Port");
}
}
public MailingService(IMailingRepository mailingRepository, ITargetService targetService, ITemplateService templateService, IEmailDomainService emailDomainService, IConfiguration config)
{ {
_mailingRepository = mailingRepository; _mailingRepository = mailingRepository;
_targetService = targetService;
_templateService = templateService;
_emailDomainService = emailDomainService;
_config = config; _config = config;
} }
@ -27,13 +73,13 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
{ {
return await _mailingRepository.GetAllAsync(activeOnly); return await _mailingRepository.GetAllAsync(activeOnly);
} }
public async Task<List<Mailing>> GetByStatusAsync(string statusCode) public async Task<List<Mailing>> GetByStatusAsync(string statusCode, string? startDate, string? endDate)
{ {
return await _mailingRepository.GetByStatusAsync(statusCode); return await _mailingRepository.GetByStatusAsync(statusCode, startDate, endDate);
} }
public async Task<List<MailingStatistic>> GetStatisticsByStatusAsync(string code) public async Task<List<MailingStatistic>> GetStatisticsByStatusAsync(string code, string? startDate, string? endDate)
{ {
return await _mailingRepository.GetStatisticsByStatusAsync(code); return await _mailingRepository.GetStatisticsByStatusAsync(code, startDate, endDate);
} }
public async Task<MailingStatistic?> GetStatisticByIdAsync(int id) public async Task<MailingStatistic?> GetStatisticByIdAsync(int id)
{ {
@ -43,7 +89,10 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
{ {
return await _mailingRepository.NameIsAvailableAsync(id, name); return await _mailingRepository.NameIsAvailableAsync(id, name);
} }
public async Task<string> GetNextAvailableNameAsync(int? id, string name)
{
return await _mailingRepository.GetNextAvailableNameAsync(id, name);
}
public async Task<int?> CreateAsync(MailingUpdateDto mailingDto) public async Task<int?> CreateAsync(MailingUpdateDto mailingDto)
{ {
ArgumentNullException.ThrowIfNull(mailingDto, nameof(mailingDto)); ArgumentNullException.ThrowIfNull(mailingDto, nameof(mailingDto));
@ -92,5 +141,164 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
{ {
return await _mailingRepository.CancelMailingAsync(id); return await _mailingRepository.CancelMailingAsync(id);
} }
public async Task<bool> TestMailing(MailingUpdateDto mailing, List<string> 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<bool> 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<string>(), 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<bool> SendEmailLocal(EmailDomain domain, string toAddress, string toName, string htmlBody, string subject, string fromName, string fromAddress, string replyToAddress, List<string> 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<string, string> uniqueIdentifiers = new Dictionary<string, string>();
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<string> categories, Dictionary<string, string> 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;
}
}
} }
} }

View File

@ -15,9 +15,11 @@
<PackageReference Include="Dapper.FluentMap" Version="2.0.0" /> <PackageReference Include="Dapper.FluentMap" Version="2.0.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.1" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.2" /> <PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.2" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.2" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.2" /> <PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.2" />
<PackageReference Include="SendGrid" Version="9.29.3" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.5.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.5.0" />
</ItemGroup> </ItemGroup>

View File

@ -27,6 +27,7 @@
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"react-icons": "^5.3.0", "react-icons": "^5.3.0",
"react-router-dom": "^7.0.1", "react-router-dom": "^7.0.1",
"react-toastify": "^11.0.5",
"yup": "^1.6.1" "yup": "^1.6.1"
}, },
"devDependencies": { "devDependencies": {
@ -4241,6 +4242,19 @@
"react-dom": ">=18" "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": { "node_modules/react-transition-group": {
"version": "4.4.5", "version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",

View File

@ -30,6 +30,7 @@
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"react-icons": "^5.3.0", "react-icons": "^5.3.0",
"react-router-dom": "^7.0.1", "react-router-dom": "^7.0.1",
"react-toastify": "^11.0.5",
"yup": "^1.6.1" "yup": "^1.6.1"
}, },
"devDependencies": { "devDependencies": {

View File

@ -34,6 +34,7 @@ import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import dayjs, { Dayjs } from 'dayjs'; // Import Dayjs for date handling import dayjs, { Dayjs } from 'dayjs'; // Import Dayjs for date handling
import utc from 'dayjs/plugin/utc'; // Import the UTC plugin import utc from 'dayjs/plugin/utc'; // Import the UTC plugin
import { toast } from "react-toastify";
dayjs.extend(utc); dayjs.extend(utc);
@ -142,7 +143,13 @@ const schema = yup.object().shape({
const nameIsAvailable = async (id: number, name: string) => { const nameIsAvailable = async (id: number, name: string) => {
const response = await fetch(`/api/mailings/available?${id > 0 ? "id=" + id + "&" : ""}name=${name}`); 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 = { const defaultMailing: Mailing = {
@ -184,36 +191,43 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
useEffect(() => { useEffect(() => {
if (open) { const initializeMailingEdit = async () => {
if (mailing) { if (open) {
mailing.scheduleDate = null; if (mailing) {
mailing.recurringTypeCode = null; mailing.scheduleDate = null;
mailing.recurringStartDate = 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); initializeMailingEdit();
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);
}
}
}, [open, mailing, reset, setupData.testEmailLists, setupData.targets, setupData.templates]); }, [open, mailing, reset, setupData.testEmailLists, setupData.targets, setupData.templates]);
const handleSave = async (formData: Mailing) => { const handleSave = async (formData: Mailing) => {
@ -261,7 +275,47 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
setEmails(newEmails); 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) => { const handleTestEmailListChange = (list: TestEmailList | null) => {

View File

@ -79,7 +79,7 @@ const TestEmailListEdit = ({ open, testEmailList, onClose, onSave }: TestEmailLi
}, [open, testEmailList, reset]); }, [open, testEmailList, reset]);
const handleSave = async (formData: TestEmailListForm) => { const handleSave = async (formData: TestEmailListForm) => {
const emails = formData.emails.split("\n"); const emails = formData.emails.split("\n").filter(email => email.trim() !== "");
const testEmailListDto = { const testEmailListDto = {
id: formData.id, id: formData.id,
name: formData.name, name: formData.name,

View File

@ -27,6 +27,8 @@ import { createTheme, ThemeProvider, Theme } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline'; import CssBaseline from '@mui/material/CssBaseline';
import ProtectedPageWrapper from '@/components/auth/ProtectedPageWrapper'; import ProtectedPageWrapper from '@/components/auth/ProtectedPageWrapper';
import { ToastContainer } from "react-toastify";
const PageWrapper = ProtectedPageWrapper; const PageWrapper = ProtectedPageWrapper;
//interface PageWrapperProps { //interface PageWrapperProps {
@ -86,6 +88,7 @@ const App = () => {
<AuthProvider> <AuthProvider>
<Router basename="/"> <Router basename="/">
<AuthCheck /> <AuthCheck />
<ToastContainer />
<Routes> <Routes>
<Route path="/" element={<Navigate to="/home" replace />} /> <Route path="/" element={<Navigate to="/home" replace />} />
<Route <Route

View File

@ -2,7 +2,8 @@
import VisibilityIcon from '@mui/icons-material/Visibility'; import VisibilityIcon from '@mui/icons-material/Visibility';
import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import RefreshIcon from '@mui/icons-material/Refresh'; import RefreshIcon from '@mui/icons-material/Refresh';
import { List, Card, CardContent, Typography, Box, useTheme, useMediaQuery, CircularProgress, IconButton } from '@mui/material'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { List, Card, CardContent, Typography, Box, useTheme, useMediaQuery, CircularProgress, IconButton, Collapse, TextField, Button } from '@mui/material';
import { DataGrid, GridColDef, GridRenderCellParams, GridToolbarContainer, GridToolbarQuickFilter, GridToolbarExport, GridToolbarDensitySelector, GridToolbarColumnsButton } from '@mui/x-data-grid'; import { DataGrid, GridColDef, GridRenderCellParams, GridToolbarContainer, GridToolbarQuickFilter, GridToolbarExport, GridToolbarDensitySelector, GridToolbarColumnsButton } from '@mui/x-data-grid';
import MailingStatistic from '@/types/mailingStatistic'; import MailingStatistic from '@/types/mailingStatistic';
import Mailing from '@/types/mailing'; import Mailing from '@/types/mailing';
@ -19,6 +20,13 @@ function CompletedMailings() {
const [selectedMailing, setSelectedMailing] = useState<Mailing | null>(null); const [selectedMailing, setSelectedMailing] = useState<Mailing | null>(null);
const [viewOpen, setViewOpen] = useState<boolean>(false); const [viewOpen, setViewOpen] = useState<boolean>(false);
const [editOpen, setEditOpen] = useState<boolean>(false); const [editOpen, setEditOpen] = useState<boolean>(false);
const [searchOpen, setSearchOpen] = useState<boolean>(false);
const defaultStartDate = new Date();
defaultStartDate.setMonth(defaultStartDate.getMonth() - 3);
const [startDate, setStartDate] = useState<Date | null>(defaultStartDate);
const [endDate, setEndDate] = useState<Date | null>(null);
const columns: GridColDef<MailingStatistic>[] = [ const columns: GridColDef<MailingStatistic>[] = [
{ {
@ -57,7 +65,29 @@ function CompletedMailings() {
const fetchMailingStats = async () => { const fetchMailingStats = async () => {
setMailingsLoading(true); setMailingsLoading(true);
try { 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(); const statsData = await response.json();
if (statsData) { if (statsData) {
setMailingStats(statsData); setMailingStats(statsData);
@ -116,6 +146,9 @@ function CompletedMailings() {
useEffect(() => { useEffect(() => {
fetchMailingStats(); // Initial fetch fetchMailingStats(); // Initial fetch
}, []); }, []);
const handleSearch = () => {
fetchMailingStats();
};
return ( return (
<Box ref={gridContainerRef} sx={{ <Box ref={gridContainerRef} sx={{
@ -125,7 +158,38 @@ function CompletedMailings() {
duration: theme.transitions.duration.standard, duration: theme.transitions.duration.standard,
}) })
}}> }}>
<Box sx={{ position: 'absolute', inset: 0 }}> <Box sx={{ p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', cursor: 'pointer' }} onClick={() => setSearchOpen(!searchOpen)}>
<Typography variant="h6">Search Filters</Typography>
<ExpandMoreIcon sx={{
ml: 1,
transform: searchOpen ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.2s'
}} />
</Box>
<Collapse in={searchOpen}>
<Box sx={{ mt: 2, mb: 2, display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<TextField
label="Start Date"
type="date"
value={startDate ? startDate.toISOString().split('T')[0] : ''}
onChange={(e) => setStartDate(e.target.value ? new Date(e.target.value) : null)}
InputLabelProps={{ shrink: true }}
/>
<TextField
label="End Date"
type="date"
value={endDate ? endDate.toISOString().split('T')[0] : ''}
onChange={(e) => setEndDate(e.target.value ? new Date(e.target.value) : null)}
InputLabelProps={{ shrink: true }}
/>
<Button variant="contained" onClick={handleSearch}>
Search
</Button>
</Box>
</Collapse>
</Box>
<Box sx={{ position: 'absolute', inset: 0, top: searchOpen ? 140 : 60 }}>
{isMobile ? ( {isMobile ? (
<List> <List>
{mailingStats.map((stat) => ( {mailingStats.map((stat) => (