Implement RESTful APIs for email management features

Created controllers for managing bounced emails, email domains, templates, test email lists, and unsubscribe URLs. Modified the ServersController for improved routing. Added DTOs and repository interfaces for new functionalities. Updated SetupDataContext for state management and adjusted frontend components for user interaction. Updated package dependencies for new features.
This commit is contained in:
David Headrick 2025-03-03 19:15:16 -06:00
parent 6f00235702
commit 2bdb1a8de6
67 changed files with 4462 additions and 121 deletions

View File

@ -0,0 +1,74 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Domain.Entities;
using System.Net.Mail;
namespace Surge365.MassEmailReact.Server.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class BouncedEmailsController : ControllerBase
{
private readonly IBouncedEmailService _bouncedEmailService;
public BouncedEmailsController(IBouncedEmailService bouncedEmailService)
{
_bouncedEmailService = bouncedEmailService;
}
[HttpGet("GetAll")]
public async Task<IActionResult> GetAll()
{
var bouncedEmails = await _bouncedEmailService.GetAllAsync();
return Ok(bouncedEmails);
}
[HttpGet("{emailAddress}")]
public async Task<IActionResult> GetByEmail(string emailAddress)
{
var bouncedEmail = await _bouncedEmailService.GetByEmailAsync(emailAddress);
return bouncedEmail is not null ? Ok(bouncedEmail) : NotFound($"Bounced email with emailAddress '{emailAddress}' not found.");
}
[HttpPost()]
public async Task<IActionResult> CreateBouncedEmail([FromBody] BouncedEmailUpdateDto bouncedEmailUpdateDto)
{
var success = await _bouncedEmailService.CreateAsync(bouncedEmailUpdateDto);
if (!success)
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to create bounced email.");
var updatedBouncedEmail = await _bouncedEmailService.GetByEmailAsync(bouncedEmailUpdateDto.EmailAddress);
return Ok(updatedBouncedEmail);
}
[HttpPut("{emailAddress}")]
public async Task<IActionResult> UpdateBouncedEmail(string emailAddress, [FromBody] BouncedEmailUpdateDto bouncedEmailUpdateDto)
{
var existingBouncedEmail = await _bouncedEmailService.GetByEmailAsync(emailAddress);
if (existingBouncedEmail == null)
return NotFound($"Bounced email with emailAddress {emailAddress} not found");
var success = await _bouncedEmailService.UpdateAsync(emailAddress, bouncedEmailUpdateDto);
if (!success)
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to update bounced email.");
var updatedBouncedEmail = await _bouncedEmailService.GetByEmailAsync(bouncedEmailUpdateDto.EmailAddress);
return Ok(updatedBouncedEmail);
}
[HttpDelete("{emailAddress}")]
public async Task<IActionResult> DeleteBouncedEmail(string emailAddress)
{
ArgumentNullException.ThrowIfNullOrWhiteSpace(emailAddress);
var success = await _bouncedEmailService.DeleteAsync(emailAddress);
if (!success)
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to delete bounced email.");
return Ok();
}
}
}

View File

@ -0,0 +1,65 @@
using Microsoft.AspNetCore.Mvc;
using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Domain.Entities;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Server.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class EmailDomainsController : ControllerBase
{
private readonly IEmailDomainService _emailDomainService;
public EmailDomainsController(IEmailDomainService emailDomainService)
{
_emailDomainService = emailDomainService;
}
[HttpGet("GetAll")]
public async Task<IActionResult> GetAll([FromQuery] bool? activeOnly)
{
var emailDomains = await _emailDomainService.GetAllAsync(activeOnly == null || activeOnly.Value ? true : false);
return Ok(emailDomains);
}
[HttpGet("{id}")]
public async Task<IActionResult> GetByKey(int id)
{
var emailDomain = await _emailDomainService.GetByIdAsync(id);
return emailDomain is not null ? Ok(emailDomain) : NotFound($"EmailDomain with key '{id}' not found.");
}
[HttpPost()]
public async Task<IActionResult> CreateTarget(int id, [FromBody] EmailDomainUpdateDto emailDomainUpdateDto)
{
if (emailDomainUpdateDto.Id != null && emailDomainUpdateDto.Id > 0)
return BadRequest("Id must be null or 0");
var emailDomainId = await _emailDomainService.CreateAsync(emailDomainUpdateDto);
if (emailDomainId == null)
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to create email domain.");
var createdEmailDomain = await _emailDomainService.GetByIdAsync(emailDomainId.Value);
return Ok(createdEmailDomain);
}
[HttpPut("{id}")]
public async Task<IActionResult> UpdateTarget(int id, [FromBody] EmailDomainUpdateDto emailDomainUpdateDto)
{
if (id != emailDomainUpdateDto.Id)
return BadRequest("Id in URL does not match Id in request body");
var existingEmailDomain = await _emailDomainService.GetByIdAsync(id);
if (existingEmailDomain == null)
return NotFound($"EmailDomain with Id {id} not found");
var success = await _emailDomainService.UpdateAsync(emailDomainUpdateDto);
if (!success)
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to update email domain.");
var updatedEmailDomain = await _emailDomainService.GetByIdAsync(id);
return Ok(updatedEmailDomain);
}
}
}

View File

@ -28,7 +28,7 @@ namespace Surge365.MassEmailReact.Server.Controllers
return Ok(servers); return Ok(servers);
} }
[HttpGet("{key}")] [HttpGet("{id}")]
public async Task<IActionResult> GetByKey(int id, bool? returnPassword = null) public async Task<IActionResult> GetByKey(int id, bool? returnPassword = null)
{ {
bool returnPasswordValue = returnPassword == null || returnPassword.Value ? true : false; bool returnPasswordValue = returnPassword == null || returnPassword.Value ? true : false;

View File

@ -0,0 +1,65 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Domain.Entities;
namespace Surge365.MassEmailReact.Server.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class TemplatesController : ControllerBase
{
private readonly ITemplateService _templateService;
public TemplatesController(ITemplateService templateService)
{
_templateService = templateService;
}
[HttpGet("GetAll")]
public async Task<IActionResult> GetAll([FromQuery] bool? activeOnly)
{
var templates = await _templateService.GetAllAsync(activeOnly == null || activeOnly.Value ? true : false);
return Ok(templates);
}
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id)
{
var template = await _templateService.GetByIdAsync(id);
return template is not null ? Ok(template) : NotFound($"Template with id '{id}' not found.");
}
[HttpPost]
public async Task<IActionResult> CreateTemplate([FromBody] TemplateUpdateDto templateUpdateDto)
{
if (templateUpdateDto.Id != null && templateUpdateDto.Id > 0)
return BadRequest("Id must be null or 0");
var templateId = await _templateService.CreateAsync(templateUpdateDto);
if (templateId == null)
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to create template.");
var createdTemplate = await _templateService.GetByIdAsync(templateId.Value);
return Ok(createdTemplate);
}
[HttpPut("{id}")]
public async Task<IActionResult> UpdateTemplate(int id, [FromBody] TemplateUpdateDto templateUpdateDto)
{
if (id != templateUpdateDto.Id)
return BadRequest("Id in URL does not match Id in request body");
var existingTemplate = await _templateService.GetByIdAsync(id);
if (existingTemplate == null)
return NotFound($"Template with Id {id} not found");
var success = await _templateService.UpdateAsync(templateUpdateDto);
if (!success)
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to update template.");
var updatedTemplate = await _templateService.GetByIdAsync(id);
return Ok(updatedTemplate);
}
}
}

View File

@ -0,0 +1,65 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Domain.Entities;
namespace Surge365.MassEmailReact.Server.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class TestEmailListsController : ControllerBase
{
private readonly ITestEmailListService _testEmailListService;
public TestEmailListsController(ITestEmailListService testEmailListService)
{
_testEmailListService = testEmailListService;
}
[HttpGet("GetAll")]
public async Task<IActionResult> GetAll()
{
var testEmailLists = await _testEmailListService.GetAllAsync();
return Ok(testEmailLists);
}
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id)
{
var testEmailList = await _testEmailListService.GetByIdAsync(id);
return testEmailList is not null ? Ok(testEmailList) : NotFound($"Test email list with id '{id}' not found.");
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] TestEmailListUpdateDto testEmailListDto)
{
if (testEmailListDto.Id != null && testEmailListDto.Id > 0)
return BadRequest("Id must be null or 0");
var testEmailListId = await _testEmailListService.CreateAsync(testEmailListDto);
if (testEmailListId == null)
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to create test email list.");
var createdTestEmailList = await _testEmailListService.GetByIdAsync(testEmailListId.Value);
return Ok(createdTestEmailList);
}
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, [FromBody] TestEmailListUpdateDto testEmailListDto)
{
if (id != testEmailListDto.Id)
return BadRequest("Id in URL does not match Id in request body");
var existingTestEmailList = await _testEmailListService.GetByIdAsync(id);
if (existingTestEmailList == null)
return NotFound($"Test email list with Id {id} not found");
var success = await _testEmailListService.UpdateAsync(testEmailListDto);
if (!success)
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to update test email list.");
var updatedTestEmailList = await _testEmailListService.GetByIdAsync(id);
return Ok(updatedTestEmailList);
}
}
}

View File

@ -0,0 +1,66 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Surge365.MassEmailReact.Application.DTOs;
using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Domain.Entities;
namespace Surge365.MassEmailReact.Server.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class UnsubscribeUrlsController : ControllerBase
{
private readonly IUnsubscribeUrlService _unsubscribeUrlService;
public UnsubscribeUrlsController(IUnsubscribeUrlService unsubscribeUrlService)
{
_unsubscribeUrlService = unsubscribeUrlService;
}
[HttpGet("GetAll")]
public async Task<IActionResult> GetAll([FromQuery] bool? activeOnly)
{
var unsubscribeUrls = await _unsubscribeUrlService.GetAllAsync(activeOnly == null || activeOnly.Value ? true : false);
return Ok(unsubscribeUrls);
}
[HttpGet("{id}")]
public async Task<IActionResult> GetByKey(int id)
{
var unsubscribeUrl = await _unsubscribeUrlService.GetByIdAsync(id);
return unsubscribeUrl is not null ? Ok(unsubscribeUrl) : NotFound($"UnsubscribeUrl with key '{id}' not found.");
}
[HttpPost()]
public async Task<IActionResult> Create(int id, [FromBody] UnsubscribeUrlUpdateDto unsubscribeUrlUpdateDto)
{
if (unsubscribeUrlUpdateDto.Id != null && unsubscribeUrlUpdateDto.Id > 0)
return BadRequest("Id must be null or 0");
var unsubscribeUrlId = await _unsubscribeUrlService.CreateAsync(unsubscribeUrlUpdateDto);
if (unsubscribeUrlId == null)
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to create unsubscribe url.");
var createdUnsubscribeUrl = await _unsubscribeUrlService.GetByIdAsync(unsubscribeUrlId.Value);
return Ok(createdUnsubscribeUrl);
}
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, [FromBody] UnsubscribeUrlUpdateDto unsubscribeUrlUpdateDto)
{
if (id != unsubscribeUrlUpdateDto.Id)
return BadRequest("Id in URL does not match Id in request body");
var existingUnsubscribeUrl = await _unsubscribeUrlService.GetByIdAsync(id);
if (existingUnsubscribeUrl == null)
return NotFound($"UnsubscribeUrl with Id {id} not found");
var success = await _unsubscribeUrlService.UpdateAsync(unsubscribeUrlUpdateDto);
if (!success)
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to update unsubscribe url.");
var updatedUnsubscribeUrl = await _unsubscribeUrlService.GetByIdAsync(id);
return Ok(updatedUnsubscribeUrl);
}
}
}

View File

@ -18,6 +18,17 @@ builder.Services.AddScoped<ITargetService, TargetService>();
builder.Services.AddScoped<ITargetRepository, TargetRepository>(); builder.Services.AddScoped<ITargetRepository, TargetRepository>();
builder.Services.AddScoped<IServerService, ServerService>(); builder.Services.AddScoped<IServerService, ServerService>();
builder.Services.AddScoped<IServerRepository, ServerRepository>(); builder.Services.AddScoped<IServerRepository, ServerRepository>();
builder.Services.AddScoped<ITestEmailListService, TestEmailListService>();
builder.Services.AddScoped<ITestEmailListRepository, TestEmailListRepository>();
builder.Services.AddScoped<IBouncedEmailService, BouncedEmailService>();
builder.Services.AddScoped<IBouncedEmailRepository, BouncedEmailRepository>();
builder.Services.AddScoped<IUnsubscribeUrlService, UnsubscribeUrlService>();
builder.Services.AddScoped<IUnsubscribeUrlRepository, UnsubscribeUrlRepository>();
builder.Services.AddScoped<ITemplateService, TemplateService>();
builder.Services.AddScoped<ITemplateRepository, TemplateRepository>();
builder.Services.AddScoped<IEmailDomainService, EmailDomainService>();
builder.Services.AddScoped<IEmailDomainRepository, EmailDomainRepository>();
var app = builder.Build(); var app = builder.Build();
app.UseDefaultFiles(); app.UseDefaultFiles();

View File

@ -0,0 +1,10 @@
namespace Surge365.MassEmailReact.Domain.Entities
{
public class BouncedEmailUpdateDto
{
public int? Id { get; set; }
public string EmailAddress { get; set; } = "";
public bool Spam { get; set; } = false;
public bool Unsubscribe { get; set; } = false;
}
}

View File

@ -0,0 +1,15 @@
using System;
namespace Surge365.MassEmailReact.Domain.Entities
{
public class EmailDomainUpdateDto
{
public int? Id { get; set; }
public string Name { get; set; } = "";
public string EmailAddress { get; set; } = "";
public string Username { get; set; } = "";
public string Password { get; set; } = "";
public bool IsActive { get; set; } = true;
public int DisplayOrder { get; set; } = 0;
}
}

View File

@ -0,0 +1,20 @@
namespace Surge365.MassEmailReact.Domain.Entities
{
public class TemplateUpdateDto
{
public int? Id { get; set; }
public string Name { get; set; } = "";
public int DomainId { get; set; }
public string Description { get; set; } = "";
public string HtmlBody { get; set; } = "";
public string Subject { get; set; } = "";
public string ToName { get; set; } = "";
public string FromName { get; set; } = "";
public string FromEmail { get; set; } = "";
public string ReplyToEmail { get; set; } = "";
public bool ClickTracking { get; set; } = false;
public bool OpenTracking { get; set; } = false;
public string CategoryXml { get; set; } = "";
public bool IsActive { get; set; } = true;
}
}

View File

@ -0,0 +1,9 @@
namespace Surge365.MassEmailReact.Domain.Entities
{
public class TestEmailListUpdateDto
{
public int? Id { get; set; }
public string Name { get; set; } = "";
public List<string> Emails { get; set; } = new List<string>();
}
}

View File

@ -0,0 +1,11 @@
using System;
namespace Surge365.MassEmailReact.Application.DTOs
{
public class UnsubscribeUrlUpdateDto
{
public int? Id { get; set; }
public string Name { get; set; } = "";
public string Url { get; set; } = "";
}
}

View File

@ -0,0 +1,16 @@
using Surge365.MassEmailReact.Domain.Entities;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Application.Interfaces
{
public interface IBouncedEmailRepository
{
Task<BouncedEmail?> GetByEmailAsync(string emailAddress);
Task<List<BouncedEmail>> GetAllAsync();
Task<int?> CreateAsync(BouncedEmail bouncedEmail);
Task<bool> UpdateAsync(string originalEmailAddress, BouncedEmail bouncedEmail);
Task<bool> DeleteAsync(string emailAddress);
}
}

View File

@ -0,0 +1,13 @@
using Surge365.MassEmailReact.Domain.Entities;
namespace Surge365.MassEmailReact.Application.Interfaces
{
public interface IBouncedEmailService
{
Task<BouncedEmail?> GetByEmailAsync(string emailAddress);
Task<List<BouncedEmail>> GetAllAsync();
Task<bool> CreateAsync(BouncedEmailUpdateDto bouncedEmailDto);
Task<bool> UpdateAsync(string originalEmailAddress, BouncedEmailUpdateDto bouncedEmailDto);
Task<bool> DeleteAsync(string emailAddress);
}
}

View File

@ -0,0 +1,14 @@
using Surge365.MassEmailReact.Domain.Entities;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Application.Interfaces
{
public interface IEmailDomainRepository
{
Task<EmailDomain?> GetByIdAsync(int id);
Task<List<EmailDomain>> GetAllAsync(bool activeOnly = true);
Task<int?> CreateAsync(EmailDomain emailDomain);
Task<bool> UpdateAsync(EmailDomain emailDomain);
}
}

View File

@ -0,0 +1,14 @@
using Surge365.MassEmailReact.Domain.Entities;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Application.Interfaces
{
public interface IEmailDomainService
{
Task<EmailDomain?> GetByIdAsync(int id);
Task<List<EmailDomain>> GetAllAsync(bool activeOnly = true);
Task<int?> CreateAsync(EmailDomainUpdateDto emailDomainDto);
Task<bool> UpdateAsync(EmailDomainUpdateDto emailDomainDto);
}
}

View File

@ -0,0 +1,14 @@
using Surge365.MassEmailReact.Domain.Entities;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Application.Interfaces
{
public interface ITemplateRepository
{
Task<Template?> GetByIdAsync(int id);
Task<List<Template>> GetAllAsync(bool activeOnly = true);
Task<int?> CreateAsync(Template template);
Task<bool> UpdateAsync(Template template);
}
}

View File

@ -0,0 +1,14 @@
using Surge365.MassEmailReact.Domain.Entities;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Application.Interfaces
{
public interface ITemplateService
{
Task<Template?> GetByIdAsync(int id);
Task<List<Template>> GetAllAsync(bool activeOnly = true);
Task<int?> CreateAsync(TemplateUpdateDto templateDto);
Task<bool> UpdateAsync(TemplateUpdateDto templateDto);
}
}

View File

@ -0,0 +1,14 @@
using Surge365.MassEmailReact.Domain.Entities;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Application.Interfaces
{
public interface ITestEmailListRepository
{
Task<TestEmailList?> GetByIdAsync(int id);
Task<List<TestEmailList>> GetAllAsync();
Task<int?> CreateAsync(TestEmailList testEmailList);
Task<bool> UpdateAsync(TestEmailList testEmailList);
}
}

View File

@ -0,0 +1,14 @@
using Surge365.MassEmailReact.Domain.Entities;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Application.Interfaces
{
public interface ITestEmailListService
{
Task<TestEmailList?> GetByIdAsync(int id);
Task<List<TestEmailList>> GetAllAsync();
Task<int?> CreateAsync(TestEmailListUpdateDto testEmailListDto);
Task<bool> UpdateAsync(TestEmailListUpdateDto testEmailListDto);
}
}

View File

@ -0,0 +1,15 @@
using Surge365.MassEmailReact.Domain.Entities;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Application.Interfaces
{
public interface IUnsubscribeUrlRepository
{
Task<UnsubscribeUrl?> GetByIdAsync(int id);
Task<List<UnsubscribeUrl>> GetAllAsync(bool activeOnly = true);
Task<int?> CreateAsync(UnsubscribeUrl unsubscribeUrl);
Task<bool> UpdateAsync(UnsubscribeUrl unsubscribeUrl);
}
}

View File

@ -0,0 +1,16 @@
using Surge365.MassEmailReact.Application.DTOs;
using Surge365.MassEmailReact.Domain.Entities;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Application.Interfaces
{
public interface IUnsubscribeUrlService
{
Task<UnsubscribeUrl?> GetByIdAsync(int id);
Task<List<UnsubscribeUrl>> GetAllAsync(bool activeOnly = true);
Task<int?> CreateAsync(UnsubscribeUrlUpdateDto unsubscribeUrlDto);
Task<bool> UpdateAsync(UnsubscribeUrlUpdateDto unsubscribeUrlDto);
}
}

View File

@ -0,0 +1,23 @@
namespace Surge365.MassEmailReact.Domain.Entities
{
public class BouncedEmail
{
public string EmailAddress { get; set; } = "";
public bool Spam { get; set; }
public bool Unsubscribe { get; set; }
public bool EnteredByAdmin { get; set; }
public BouncedEmail() { }
private BouncedEmail(string emailAddress, bool spam, bool unsubscribe, bool enteredByAdmin)
{
EmailAddress = emailAddress;
Spam = spam;
Unsubscribe = unsubscribe;
EnteredByAdmin = enteredByAdmin;
}
public static BouncedEmail Create(string emailAddress, bool spam, bool unsubscribe, bool enteredByAdmin)
{
return new BouncedEmail(emailAddress, spam, unsubscribe, enteredByAdmin);
}
}
}

View File

@ -0,0 +1,33 @@
using System;
namespace Surge365.MassEmailReact.Domain.Entities
{
public class EmailDomain
{
public int? Id { get; private set; }
public string Name { get; set; } = "";
public string EmailAddress { get; set; } = "";
public string Username { get; set; } = "";
public string Password { get; set; } = "";
public bool IsActive { get; set; }
public int DisplayOrder { get; set; }
public EmailDomain() { }
private EmailDomain(int id, string name, string emailAddress, string username, string password, bool isActive, int displayOrder)
{
Id = id;
Name = name;
EmailAddress = emailAddress;
Username = username;
Password = password;
IsActive = isActive;
DisplayOrder = displayOrder;
}
public static EmailDomain Create(int id, string name, string emailAddress, string username, string password, bool isActive, int displayOrder)
{
return new EmailDomain(id, name, emailAddress, username, password, isActive, displayOrder);
}
}
}

View File

@ -0,0 +1,53 @@
using System;
namespace Surge365.MassEmailReact.Domain.Entities
{
public class Template
{
public int? Id { get; private set; }
public string Name { get; set; } = "";
public int DomainId { get; set; }
public string Description { get; set; } = "";
public string HtmlBody { get; set; } = "";
public string Subject { get; set; } = "";
public string ToName { get; set; } = "";
public string FromName { get; set; } = "";
public string FromEmail { get; set; } = "";
public string ReplyToEmail { get; set; } = "";
public bool ClickTracking { get; set; }
public bool OpenTracking { get; set; }
public string CategoryXml { get; set; } = "";
public bool IsActive { get; set; }
public Template() { }
private Template(int id, string name, int domainId, string description, string htmlBody, string subject,
string toName, string fromName, string fromEmail, string replyToEmail,
bool clickTracking, bool openTracking, string categoryXml, bool isActive)
{
Id = id;
Name = name;
DomainId = domainId;
Description = description;
HtmlBody = htmlBody;
Subject = subject;
ToName = toName;
FromName = fromName;
FromEmail = fromEmail;
ReplyToEmail = replyToEmail;
ClickTracking = clickTracking;
OpenTracking = openTracking;
CategoryXml = categoryXml;
IsActive = isActive;
}
public static Template Create(int id, string name, int domainId, string description, string htmlBody,
string subject, string toName, string fromName, string fromEmail,
string replyToEmail, bool clickTracking, bool openTracking,
string categoryXml, bool isActive)
{
return new Template(id, name, domainId, description, htmlBody, subject, toName, fromName,
fromEmail, replyToEmail, clickTracking, openTracking, categoryXml, isActive);
}
}
}

View File

@ -0,0 +1,48 @@
using System.Data.SqlTypes;
using System.Xml.Linq;
namespace Surge365.MassEmailReact.Domain.Entities
{
public class TestEmailList
{
public int? Id { get; private set; }
public string Name { get; set; } = "";
public string List { get; set; } = "";
public List<string> Emails
{
get
{
return ParseListXml(List);
}
}
public TestEmailList() { }
public static List<string> ParseListXml(string xml)
{
return XDocument.Parse(xml).Descendants("Email").Select(e => e.Value).ToList();
}
public string GetListXml()
{
return GetListXml(Emails);
}
public static string GetListXml(List<string> emails)
{
if (emails.Count == 0)
return "<Emails></Emails>";
string xmlString = new XDocument(new XElement("Emails",emails.Select(email => new XElement("Email", email)))).ToString();
return xmlString;
}
private TestEmailList(int id, string name, string list)
{
Id = id;
Name = name;
List = list;
}
public static TestEmailList Create(int id, string name, string list)
{
return new TestEmailList(id, name, list);
}
}
}

View File

@ -0,0 +1,23 @@
using System;
namespace Surge365.MassEmailReact.Domain.Entities
{
public class UnsubscribeUrl
{
public int? Id { get; private set; }
public string Name { get; set; } = "";
public string Url { get; set; } = "";
public UnsubscribeUrl() { }
private UnsubscribeUrl(int id, string name, string url)
{
Id = id;
Name = name;
Url = url;
}
public static UnsubscribeUrl Create(int id, string name, string url)
{
return new UnsubscribeUrl(id, name, url);
}
}
}

View File

@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Dapper.FluentMap.Mapping;
using Surge365.MassEmailReact.Domain.Entities;
namespace Surge365.MassEmailReact.Infrastructure.DapperMaps
{
public class BouncedEmailMap : EntityMap<BouncedEmail>
{
public BouncedEmailMap()
{
Map(p => p.EmailAddress).ToColumn("email_address");
Map(p => p.Spam).ToColumn("spam");
Map(p => p.Unsubscribe).ToColumn("unsubscribe");
Map(p => p.EnteredByAdmin).ToColumn("entered_by_admin");
}
}
}

View File

@ -1,4 +1,5 @@
using Dapper.FluentMap; using Dapper.FluentMap;
using Surge365.MassEmailReact.Domain.Entities;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
@ -15,6 +16,11 @@ namespace Surge365.MassEmailReact.Infrastructure.DapperMaps
{ {
config.AddMap(new TargetMap()); config.AddMap(new TargetMap());
config.AddMap(new ServerMap()); config.AddMap(new ServerMap());
config.AddMap(new TestEmailListMap());
config.AddMap(new BouncedEmailMap());
config.AddMap(new UnsubscribeUrlMap());
config.AddMap(new TemplateMap());
config.AddMap(new EmailDomainMap());
}); });
} }
} }

View File

@ -0,0 +1,19 @@
using Dapper.FluentMap.Mapping;
using Surge365.MassEmailReact.Domain.Entities;
namespace Surge365.MassEmailReact.Infrastructure.DapperMaps
{
public class EmailDomainMap : EntityMap<EmailDomain>
{
public EmailDomainMap()
{
Map(p => p.Id).ToColumn("domain_key");
Map(p => p.Name).ToColumn("name");
Map(p => p.EmailAddress).ToColumn("email_address");
Map(p => p.Username).ToColumn("username");
Map(p => p.Password).ToColumn("password");
Map(p => p.IsActive).ToColumn("is_active");
Map(p => p.DisplayOrder).ToColumn("display_order");
}
}
}

View File

@ -0,0 +1,26 @@
using Dapper.FluentMap.Mapping;
using Surge365.MassEmailReact.Domain.Entities;
namespace Surge365.MassEmailReact.Infrastructure.DapperMaps
{
public class TemplateMap : EntityMap<Template>
{
public TemplateMap()
{
Map(t => t.Id).ToColumn("template_key");
Map(t => t.Name).ToColumn("name");
Map(t => t.DomainId).ToColumn("domain_key");
Map(t => t.Description).ToColumn("description");
Map(t => t.HtmlBody).ToColumn("html_body");
Map(t => t.Subject).ToColumn("subject");
Map(t => t.ToName).ToColumn("to_name");
Map(t => t.FromName).ToColumn("from_name");
Map(t => t.FromEmail).ToColumn("from_email");
Map(t => t.ReplyToEmail).ToColumn("reply_to_email");
Map(t => t.ClickTracking).ToColumn("click_tracking");
Map(t => t.OpenTracking).ToColumn("open_tracking");
Map(t => t.CategoryXml).ToColumn("category_xml");
Map(t => t.IsActive).ToColumn("is_active");
}
}
}

View File

@ -0,0 +1,20 @@
using Dapper.FluentMap.Mapping;
using Surge365.MassEmailReact.Domain.Entities;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Infrastructure.DapperMaps
{
public class TestEmailListMap : EntityMap<TestEmailList>
{
public TestEmailListMap()
{
Map(x => x.Id).ToColumn("test_email_list_key");
Map(x => x.Name).ToColumn("name");
Map(x => x.Emails).ToColumn("emails");
}
}
}

View File

@ -0,0 +1,15 @@
using Dapper.FluentMap.Mapping;
using Surge365.MassEmailReact.Domain.Entities;
namespace Surge365.MassEmailReact.Infrastructure.DapperMaps
{
public class UnsubscribeUrlMap : EntityMap<UnsubscribeUrl>
{
public UnsubscribeUrlMap()
{
Map(u => u.Id).ToColumn("unsubscribe_url_key");
Map(u => u.Name).ToColumn("name");
Map(u => u.Url).ToColumn("url");
}
}
}

View File

@ -0,0 +1,121 @@
using Dapper;
using Dapper.FluentMap;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Domain.Entities;
using System;
using System.Collections.Generic;
using System.Data;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Infrastructure.Repositories
{
public class BouncedEmailRepository : IBouncedEmailRepository
{
private IConfiguration _config;
private const string _connectionStringName = "MassEmail.ConnectionString";
private string? ConnectionString
{
get
{
return _config.GetConnectionString(_connectionStringName);
}
}
public BouncedEmailRepository(IConfiguration config)
{
_config = config;
#if DEBUG
if (!FluentMapper.EntityMaps.ContainsKey(typeof(BouncedEmail)))
{
throw new InvalidOperationException("BouncedEmail dapper mapping is missing. Make sure ConfigureMappings() is called inside program.cs (program startup).");
}
#endif
}
public async Task<BouncedEmail?> GetByEmailAsync(string emailAddress)
{
ArgumentNullException.ThrowIfNull(_config);
ArgumentNullException.ThrowIfNull(_connectionStringName);
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
return (await conn.QueryAsync<BouncedEmail>("mem_get_bounced_email_by_email", new { email_address = emailAddress }, commandType: CommandType.StoredProcedure)).FirstOrDefault();
}
public async Task<List<BouncedEmail>> GetAllAsync()
{
ArgumentNullException.ThrowIfNull(_config);
ArgumentNullException.ThrowIfNull(_connectionStringName);
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
return (await conn.QueryAsync<BouncedEmail>("mem_get_bounced_email_all", new { }, commandType: CommandType.StoredProcedure)).ToList();
}
public async Task<int?> CreateAsync(BouncedEmail bouncedEmail)
{
ArgumentNullException.ThrowIfNull(bouncedEmail);
ArgumentNullException.ThrowIfNullOrEmpty(bouncedEmail.EmailAddress);
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
var parameters = new DynamicParameters();
parameters.Add("@email_address", bouncedEmail.EmailAddress, DbType.String);
parameters.Add("@spam", bouncedEmail.Spam, DbType.Boolean);
parameters.Add("@unsubscribe", bouncedEmail.Unsubscribe, DbType.Boolean);
parameters.Add("@entered_by_admin", bouncedEmail.EnteredByAdmin, DbType.Boolean);
parameters.Add("@success", dbType: DbType.Boolean, direction: ParameterDirection.Output);
await conn.ExecuteAsync("mem_save_bounced_email", parameters, commandType: CommandType.StoredProcedure);
bool success = parameters.Get<bool>("@success");
if (success)
return parameters.Get<int>("@bounced_email_key");
return null;
}
public async Task<bool> UpdateAsync(string originalEmailAddress, BouncedEmail bouncedEmail)
{
ArgumentNullException.ThrowIfNull(bouncedEmail);
ArgumentNullException.ThrowIfNullOrEmpty(originalEmailAddress);
ArgumentNullException.ThrowIfNullOrEmpty(bouncedEmail.EmailAddress);
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
var parameters = new DynamicParameters();
parameters.Add("@old_email_address", originalEmailAddress, DbType.String);
parameters.Add("@email_address", bouncedEmail.EmailAddress, DbType.String);
parameters.Add("@spam", bouncedEmail.Spam, DbType.Boolean);
parameters.Add("@unsubscribe", bouncedEmail.Unsubscribe, DbType.Boolean);
parameters.Add("@entered_by_admin", bouncedEmail.EnteredByAdmin, DbType.Boolean);
parameters.Add("@success", dbType: DbType.Boolean, direction: ParameterDirection.Output);
await conn.ExecuteAsync("mem_save_bounced_email", parameters, commandType: CommandType.StoredProcedure);
bool success = parameters.Get<bool>("@success");
return success;
}
public async Task<bool> DeleteAsync(string emailAddress)
{
ArgumentNullException.ThrowIfNull(emailAddress);
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
var parameters = new DynamicParameters();
parameters.Add("@email_address", emailAddress, DbType.String);
parameters.Add("@success", dbType: DbType.Boolean, direction: ParameterDirection.Output);
await conn.ExecuteAsync("mem_delete_bounced_email", parameters, commandType: CommandType.StoredProcedure);
bool success = parameters.Get<bool>("@success");
return success;
}
}
}

View File

@ -0,0 +1,101 @@
using Dapper;
using Dapper.FluentMap;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Domain.Entities;
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Infrastructure.Repositories
{
public class EmailDomainRepository : IEmailDomainRepository
{
private readonly IConfiguration _config;
private const string _connectionStringName = "MassEmail.ConnectionString";
private string? ConnectionString
{
get
{
return _config.GetConnectionString(_connectionStringName);
}
}
public EmailDomainRepository(IConfiguration config)
{
_config = config;
#if DEBUG
if (!FluentMapper.EntityMaps.ContainsKey(typeof(EmailDomain)))
{
throw new InvalidOperationException("EmailDomain dapper mapping is missing. Make sure ConfigureMappings() is called inside program.cs (program startup).");
}
#endif
}
public async Task<EmailDomain?> GetByIdAsync(int id)
{
ArgumentNullException.ThrowIfNull(_config);
ArgumentNullException.ThrowIfNull(_connectionStringName);
using SqlConnection conn = new SqlConnection(ConnectionString);
return (await conn.QueryAsync<EmailDomain>("mem_get_domain_by_id", new { domain_key = id }, commandType: CommandType.StoredProcedure)).FirstOrDefault();
}
public async Task<List<EmailDomain>> GetAllAsync(bool activeOnly = true)
{
ArgumentNullException.ThrowIfNull(_config);
ArgumentNullException.ThrowIfNull(_connectionStringName);
using SqlConnection conn = new SqlConnection(ConnectionString);
return (await conn.QueryAsync<EmailDomain>("mem_get_domain_all", new { active_only = activeOnly }, commandType: CommandType.StoredProcedure)).ToList();
}
public async Task<int?> CreateAsync(EmailDomain emailDomain)
{
ArgumentNullException.ThrowIfNull(emailDomain);
if (emailDomain.Id != null && emailDomain.Id > 0)
throw new Exception("ID must be null");
using SqlConnection conn = new SqlConnection(ConnectionString);
var parameters = new DynamicParameters();
parameters.Add("@domain_key", dbType: DbType.Int32, direction: ParameterDirection.Output);
parameters.Add("@name", emailDomain.Name, DbType.String);
parameters.Add("@email_address", emailDomain.EmailAddress, DbType.String);
parameters.Add("@username", emailDomain.Username, DbType.String);
parameters.Add("@password", emailDomain.Password, DbType.String);
parameters.Add("@is_active", emailDomain.IsActive, DbType.Boolean);
parameters.Add("@display_order", emailDomain.DisplayOrder, DbType.Int32);
parameters.Add("@success", dbType: DbType.Boolean, direction: ParameterDirection.Output);
await conn.ExecuteAsync("mem_save_domain", parameters, commandType: CommandType.StoredProcedure);
bool success = parameters.Get<bool>("@success");
if (success)
return parameters.Get<int>("@domain_key");
return null;
}
public async Task<bool> UpdateAsync(EmailDomain emailDomain)
{
ArgumentNullException.ThrowIfNull(emailDomain);
ArgumentNullException.ThrowIfNull(emailDomain.Id);
using SqlConnection conn = new SqlConnection(ConnectionString);
var parameters = new DynamicParameters();
parameters.Add("@domain_key", emailDomain.Id, DbType.Int32);
parameters.Add("@name", emailDomain.Name, DbType.String);
parameters.Add("@email_address", emailDomain.EmailAddress, DbType.String);
parameters.Add("@username", emailDomain.Username, DbType.String);
parameters.Add("@password", emailDomain.Password, DbType.String);
parameters.Add("@is_active", emailDomain.IsActive, DbType.Boolean);
parameters.Add("@display_order", emailDomain.DisplayOrder, DbType.Int32);
parameters.Add("@success", dbType: DbType.Boolean, direction: ParameterDirection.Output);
await conn.ExecuteAsync("mem_save_domain", parameters, commandType: CommandType.StoredProcedure);
bool success = parameters.Get<bool>("@success");
return success;
}
}
}

View File

@ -43,7 +43,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName)); using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
return (await conn.QueryAsync<Target>("SELECT * FROM mem_target WHERE target_key = @target_key", new { target_key = targetKey })).ToList().FirstOrDefault(); return (await conn.QueryAsync<Target>("mem_get_target_by_id", new { target_key = targetKey }, commandType: CommandType.StoredProcedure)).FirstOrDefault();
} }
public async Task<List<Target>> GetAllAsync(bool activeOnly = true) public async Task<List<Target>> GetAllAsync(bool activeOnly = true)
{ {
@ -53,7 +53,6 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName)); using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
return (await conn.QueryAsync<Target>("mem_get_target_all", new { active_only = activeOnly }, commandType: CommandType.StoredProcedure)).ToList(); return (await conn.QueryAsync<Target>("mem_get_target_all", new { active_only = activeOnly }, commandType: CommandType.StoredProcedure)).ToList();
//return conn.Query<Target>("SELECT * FROM mem_target WHERE is_active = @active_only", new { active_only = activeOnly }).ToList();
} }
public async Task<int?> CreateAsync(Target target) public async Task<int?> CreateAsync(Target target)

View File

@ -0,0 +1,114 @@
using Dapper;
using Dapper.FluentMap;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Domain.Entities;
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Infrastructure.Repositories
{
public class TemplateRepository : ITemplateRepository
{
private IConfiguration _config;
private const string _connectionStringName = "MassEmail.ConnectionString";
private string? ConnectionString
{
get
{
return _config.GetConnectionString(_connectionStringName);
}
}
public TemplateRepository(IConfiguration config)
{
_config = config;
#if DEBUG
if (!FluentMapper.EntityMaps.ContainsKey(typeof(Template)))
{
throw new InvalidOperationException("Template dapper mapping is missing. Make sure ConfigureMappings() is called inside program.cs (program startup).");
}
#endif
}
public async Task<Template?> GetByIdAsync(int id)
{
ArgumentNullException.ThrowIfNull(_config);
ArgumentNullException.ThrowIfNull(_connectionStringName);
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
return (await conn.QueryAsync<Template>("mem_get_template_by_id", new { template_key = id },
commandType: CommandType.StoredProcedure)).FirstOrDefault();
}
public async Task<List<Template>> GetAllAsync(bool activeOnly = true)
{
ArgumentNullException.ThrowIfNull(_config);
ArgumentNullException.ThrowIfNull(_connectionStringName);
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
return (await conn.QueryAsync<Template>("mem_get_template_all", new { },
commandType: CommandType.StoredProcedure)).ToList();
}
public async Task<int?> CreateAsync(Template template)
{
ArgumentNullException.ThrowIfNull(template);
if (template.Id != null && template.Id > 0)
throw new Exception("ID must be null");
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
var parameters = new DynamicParameters();
parameters.Add("@template_key", dbType: DbType.Int32, direction: ParameterDirection.Output);
parameters.Add("@name", template.Name, DbType.String);
parameters.Add("@domain_key", template.DomainId, DbType.Int32);
parameters.Add("@description", template.Description, DbType.String);
parameters.Add("@html_body", template.HtmlBody, DbType.String);
parameters.Add("@subject", template.Subject, DbType.String);
parameters.Add("@to_name", template.ToName, DbType.String);
parameters.Add("@from_name", template.FromName, DbType.String);
parameters.Add("@from_email", template.FromEmail, DbType.String);
parameters.Add("@reply_to_email", template.ReplyToEmail, DbType.String);
parameters.Add("@click_tracking", template.ClickTracking, DbType.Boolean);
parameters.Add("@open_tracking", template.OpenTracking, DbType.Boolean);
parameters.Add("@category_xml", template.CategoryXml, DbType.Xml);
parameters.Add("@is_active", template.IsActive, DbType.Boolean);
parameters.Add("@success", dbType: DbType.Boolean, direction: ParameterDirection.Output);
await conn.ExecuteAsync("mem_save_template", parameters, commandType: CommandType.StoredProcedure);
bool success = parameters.Get<bool>("@success");
return success ? parameters.Get<int>("@template_key") : null;
}
public async Task<bool> UpdateAsync(Template template)
{
ArgumentNullException.ThrowIfNull(template);
ArgumentNullException.ThrowIfNull(template.Id);
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
var parameters = new DynamicParameters();
parameters.Add("@template_key", template.Id, DbType.Int32);
parameters.Add("@name", template.Name, DbType.String);
parameters.Add("@domain_key", template.DomainId, DbType.Int32);
parameters.Add("@description", template.Description, DbType.String);
parameters.Add("@html_body", template.HtmlBody, DbType.String);
parameters.Add("@subject", template.Subject, DbType.String);
parameters.Add("@to_name", template.ToName, DbType.String);
parameters.Add("@from_name", template.FromName, DbType.String);
parameters.Add("@from_email", template.FromEmail, DbType.String);
parameters.Add("@reply_to_email", template.ReplyToEmail, DbType.String);
parameters.Add("@click_tracking", template.ClickTracking, DbType.Boolean);
parameters.Add("@open_tracking", template.OpenTracking, DbType.Boolean);
parameters.Add("@category_xml", template.CategoryXml, DbType.Xml);
parameters.Add("@is_active", template.IsActive, DbType.Boolean);
parameters.Add("@success", dbType: DbType.Boolean, direction: ParameterDirection.Output);
await conn.ExecuteAsync("mem_save_template", parameters, commandType: CommandType.StoredProcedure);
return parameters.Get<bool>("@success");
}
}
}

View File

@ -0,0 +1,85 @@
using Dapper;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Domain.Entities;
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Infrastructure.Repositories
{
public class TestEmailListRepository : ITestEmailListRepository
{
private readonly IConfiguration _config;
private const string _connectionStringName = "MassEmail.ConnectionString";
public TestEmailListRepository(IConfiguration config)
{
_config = config;
}
public async Task<TestEmailList?> GetByIdAsync(int id)
{
ArgumentNullException.ThrowIfNull(_config);
ArgumentNullException.ThrowIfNull(_connectionStringName);
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
return (await conn.QueryAsync<TestEmailList>(
"mem_get_test_email_list_by_id",
new { test_email_list_key = id },
commandType: CommandType.StoredProcedure
)).FirstOrDefault();
}
public async Task<List<TestEmailList>> GetAllAsync()
{
ArgumentNullException.ThrowIfNull(_config);
ArgumentNullException.ThrowIfNull(_connectionStringName);
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
return (await conn.QueryAsync<TestEmailList>(
"mem_get_test_email_list_all",
commandType: CommandType.StoredProcedure
)).ToList();
}
public async Task<int?> CreateAsync(TestEmailList testEmailList)
{
ArgumentNullException.ThrowIfNull(testEmailList);
if (testEmailList.Id != null && testEmailList.Id > 0)
throw new Exception("ID must be null");
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
var parameters = new DynamicParameters();
parameters.Add("@test_email_list_key", dbType: DbType.Int32, direction: ParameterDirection.Output);
parameters.Add("@name", testEmailList.Name, DbType.String);
parameters.Add("@list", testEmailList.GetListXml(), DbType.String);
parameters.Add("@success", dbType: DbType.Boolean, direction: ParameterDirection.Output);
await conn.ExecuteAsync("mem_save_test_email_list", parameters, commandType: CommandType.StoredProcedure);
bool success = parameters.Get<bool>("@success");
if (success)
return parameters.Get<int>("@test_email_list_key");
return null;
}
public async Task<bool> UpdateAsync(TestEmailList testEmailList)
{
ArgumentNullException.ThrowIfNull(testEmailList);
ArgumentNullException.ThrowIfNull(testEmailList.Id);
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
var parameters = new DynamicParameters();
parameters.Add("@test_email_list_key", testEmailList.Id, DbType.Int32);
parameters.Add("@name", testEmailList.Name, DbType.String);
parameters.Add("@list", testEmailList.GetListXml(), DbType.String);
parameters.Add("@success", dbType: DbType.Boolean, direction: ParameterDirection.Output);
await conn.ExecuteAsync("mem_save_test_email_list", parameters, commandType: CommandType.StoredProcedure);
return parameters.Get<bool>("@success");
}
}
}

View File

@ -0,0 +1,96 @@
using Dapper;
using Dapper.FluentMap;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Domain.Entities;
using System;
using System.Collections.Generic;
using System.Data;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Infrastructure.Repositories
{
public class UnsubscribeUrlRepository : IUnsubscribeUrlRepository
{
private IConfiguration _config;
private const string _connectionStringName = "MassEmail.ConnectionString";
private string? ConnectionString
{
get
{
return _config.GetConnectionString(_connectionStringName);
}
}
public UnsubscribeUrlRepository(IConfiguration config)
{
_config = config;
#if DEBUG
if (!FluentMapper.EntityMaps.ContainsKey(typeof(UnsubscribeUrl)))
{
throw new InvalidOperationException("UnsubscribeUrl dapper mapping is missing. Make sure ConfigureMappings() is called inside program.cs (program startup).");
}
#endif
}
public async Task<UnsubscribeUrl?> GetByIdAsync(int unsubscribeUrlKey)
{
ArgumentNullException.ThrowIfNull(_config);
ArgumentNullException.ThrowIfNull(_connectionStringName);
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
return (await conn.QueryAsync<UnsubscribeUrl>("mem_get_unsubscribe_url_by_id", new { unsubscribe_url_key = unsubscribeUrlKey }, commandType: CommandType.StoredProcedure)).FirstOrDefault();
}
public async Task<List<UnsubscribeUrl>> GetAllAsync(bool activeOnly = true)
{
ArgumentNullException.ThrowIfNull(_config);
ArgumentNullException.ThrowIfNull(_connectionStringName);
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
return (await conn.QueryAsync<UnsubscribeUrl>("mem_get_unsubscribe_url_all", new { }, commandType: CommandType.StoredProcedure)).ToList();
}
public async Task<int?> CreateAsync(UnsubscribeUrl unsubscribeUrl)
{
ArgumentNullException.ThrowIfNull(unsubscribeUrl);
if (unsubscribeUrl.Id != null && unsubscribeUrl.Id > 0)
throw new Exception("ID must be null");
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
var parameters = new DynamicParameters();
parameters.Add("@unsubscribe_url_key", dbType: DbType.Int32, direction: ParameterDirection.Output);
parameters.Add("@name", unsubscribeUrl.Name, DbType.String);
parameters.Add("@url", unsubscribeUrl.Url, DbType.String);
parameters.Add("@success", dbType: DbType.Boolean, direction: ParameterDirection.Output);
await conn.ExecuteAsync("mem_save_unsubscribe_url", parameters, commandType: CommandType.StoredProcedure);
bool success = parameters.Get<bool>("@success");
if (success)
return parameters.Get<int>("@unsubscribe_url_key");
return null;
}
public async Task<bool> UpdateAsync(UnsubscribeUrl unsubscribeUrl)
{
ArgumentNullException.ThrowIfNull(unsubscribeUrl);
ArgumentNullException.ThrowIfNull(unsubscribeUrl.Id);
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
var parameters = new DynamicParameters();
parameters.Add("@unsubscribe_url_key", unsubscribeUrl.Id, DbType.Int32);
parameters.Add("@name", unsubscribeUrl.Name, DbType.String);
parameters.Add("@url", unsubscribeUrl.Url, DbType.String);
parameters.Add("@success", dbType: DbType.Boolean, direction: ParameterDirection.Output);
await conn.ExecuteAsync("mem_save_unsubscribe_url", parameters, commandType: CommandType.StoredProcedure);
bool success = parameters.Get<bool>("@success");
return success;
}
}
}

View File

@ -0,0 +1,72 @@
using Microsoft.Extensions.Configuration;
using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Domain.Entities;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Infrastructure.Services
{
public class BouncedEmailService : IBouncedEmailService
{
private readonly IBouncedEmailRepository _bouncedEmailRepository;
private readonly IConfiguration _config;
public BouncedEmailService(IBouncedEmailRepository bouncedEmailRepository, IConfiguration config)
{
_bouncedEmailRepository = bouncedEmailRepository;
_config = config;
}
public async Task<BouncedEmail?> GetByEmailAsync(string emailAddress)
{
return await _bouncedEmailRepository.GetByEmailAsync(emailAddress);
}
public async Task<List<BouncedEmail>> GetAllAsync()
{
return await _bouncedEmailRepository.GetAllAsync();
}
public async Task<bool> CreateAsync(BouncedEmailUpdateDto bouncedEmailDto)
{
ArgumentNullException.ThrowIfNull(bouncedEmailDto, nameof(bouncedEmailDto));
ArgumentNullException.ThrowIfNullOrWhiteSpace(bouncedEmailDto.EmailAddress, nameof(bouncedEmailDto.EmailAddress));
var bouncedEmail = await _bouncedEmailRepository.GetByEmailAsync(bouncedEmailDto.EmailAddress);
if (bouncedEmail == null)
{
bouncedEmail = new BouncedEmail();
bouncedEmail.EnteredByAdmin = true;
}
bouncedEmail.EmailAddress = bouncedEmailDto.EmailAddress;
bouncedEmail.Spam = bouncedEmailDto.Spam;
bouncedEmail.Unsubscribe = bouncedEmailDto.Unsubscribe;
return await _bouncedEmailRepository.CreateAsync(bouncedEmail) != null;
}
public async Task<bool> UpdateAsync(string originalEmailAddress, BouncedEmailUpdateDto bouncedEmailDto)
{
ArgumentNullException.ThrowIfNull(bouncedEmailDto, nameof(bouncedEmailDto));
ArgumentNullException.ThrowIfNullOrWhiteSpace(bouncedEmailDto.EmailAddress, nameof(bouncedEmailDto.EmailAddress));
ArgumentNullException.ThrowIfNullOrWhiteSpace(originalEmailAddress, nameof(originalEmailAddress));
var bouncedEmail = await _bouncedEmailRepository.GetByEmailAsync(originalEmailAddress);
if (bouncedEmail == null)
throw new Exception("Original email address doesn't exist");
bouncedEmail.EmailAddress = bouncedEmailDto.EmailAddress;
bouncedEmail.Spam = bouncedEmailDto.Spam;
bouncedEmail.Unsubscribe = bouncedEmailDto.Unsubscribe;
return await _bouncedEmailRepository.UpdateAsync(originalEmailAddress, bouncedEmail);
}
public async Task<bool> DeleteAsync(string emailAddress)
{
ArgumentNullException.ThrowIfNullOrWhiteSpace(emailAddress, nameof(emailAddress));
return await _bouncedEmailRepository.DeleteAsync(emailAddress);
}
}
}

View File

@ -0,0 +1,68 @@
using Microsoft.Extensions.Configuration;
using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Domain.Entities;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Infrastructure.Services
{
public class EmailDomainService : IEmailDomainService
{
private readonly IEmailDomainRepository _emailDomainRepository;
private readonly IConfiguration _config;
public EmailDomainService(IEmailDomainRepository emailDomainRepository, IConfiguration config)
{
_emailDomainRepository = emailDomainRepository;
_config = config;
}
public async Task<EmailDomain?> GetByIdAsync(int id)
{
return await _emailDomainRepository.GetByIdAsync(id);
}
public async Task<List<EmailDomain>> GetAllAsync(bool activeOnly = true)
{
return await _emailDomainRepository.GetAllAsync(activeOnly);
}
public async Task<int?> CreateAsync(EmailDomainUpdateDto emailDomainDto)
{
ArgumentNullException.ThrowIfNull(emailDomainDto, nameof(emailDomainDto));
if (emailDomainDto.Id != null && emailDomainDto.Id > 0)
throw new Exception("ID must be null");
var emailDomain = new EmailDomain
{
Name = emailDomainDto.Name,
EmailAddress = emailDomainDto.EmailAddress,
Username = emailDomainDto.Username,
Password = emailDomainDto.Password,
IsActive = emailDomainDto.IsActive,
DisplayOrder = emailDomainDto.DisplayOrder
};
return await _emailDomainRepository.CreateAsync(emailDomain);
}
public async Task<bool> UpdateAsync(EmailDomainUpdateDto emailDomainDto)
{
ArgumentNullException.ThrowIfNull(emailDomainDto, nameof(emailDomainDto));
ArgumentNullException.ThrowIfNull(emailDomainDto.Id, nameof(emailDomainDto.Id));
var emailDomain = await _emailDomainRepository.GetByIdAsync(emailDomainDto.Id.Value);
if (emailDomain == null || emailDomain.Id == null) return false;
emailDomain.Name = emailDomainDto.Name;
emailDomain.EmailAddress = emailDomainDto.EmailAddress;
emailDomain.Username = emailDomainDto.Username;
emailDomain.Password = emailDomainDto.Password;
emailDomain.IsActive = emailDomainDto.IsActive;
emailDomain.DisplayOrder = emailDomainDto.DisplayOrder;
return await _emailDomainRepository.UpdateAsync(emailDomain);
}
}
}

View File

@ -0,0 +1,80 @@
using Microsoft.Extensions.Configuration;
using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Domain.Entities;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Infrastructure.Services
{
public class TemplateService : ITemplateService
{
private readonly ITemplateRepository _templateRepository;
private readonly IConfiguration _config;
public TemplateService(ITemplateRepository templateRepository, IConfiguration config)
{
_templateRepository = templateRepository;
_config = config;
}
public async Task<Template?> GetByIdAsync(int id)
{
return await _templateRepository.GetByIdAsync(id);
}
public async Task<List<Template>> GetAllAsync(bool activeOnly = true)
{
return await _templateRepository.GetAllAsync(activeOnly);
}
public async Task<int?> CreateAsync(TemplateUpdateDto templateDto)
{
ArgumentNullException.ThrowIfNull(templateDto, nameof(templateDto));
if (templateDto.Id != null && templateDto.Id > 0)
throw new Exception("ID must be null");
var template = new Template();
template.Name = templateDto.Name;
template.DomainId = templateDto.DomainId;
template.Description = templateDto.Description;
template.HtmlBody = templateDto.HtmlBody;
template.Subject = templateDto.Subject;
template.ToName = templateDto.ToName;
template.FromName = templateDto.FromName;
template.FromEmail = templateDto.FromEmail;
template.ReplyToEmail = templateDto.ReplyToEmail;
template.ClickTracking = templateDto.ClickTracking;
template.OpenTracking = templateDto.OpenTracking;
template.CategoryXml = templateDto.CategoryXml;
template.IsActive = templateDto.IsActive;
return await _templateRepository.CreateAsync(template);
}
public async Task<bool> UpdateAsync(TemplateUpdateDto templateDto)
{
ArgumentNullException.ThrowIfNull(templateDto, nameof(templateDto));
ArgumentNullException.ThrowIfNull(templateDto.Id, nameof(templateDto.Id));
var template = await _templateRepository.GetByIdAsync(templateDto.Id.Value);
if (template == null || template.Id == null) return false;
template.Name = templateDto.Name;
template.DomainId = templateDto.DomainId;
template.Description = templateDto.Description;
template.HtmlBody = templateDto.HtmlBody;
template.Subject = templateDto.Subject;
template.ToName = templateDto.ToName;
template.FromName = templateDto.FromName;
template.FromEmail = templateDto.FromEmail;
template.ReplyToEmail = templateDto.ReplyToEmail;
template.ClickTracking = templateDto.ClickTracking;
template.OpenTracking = templateDto.OpenTracking;
template.CategoryXml = templateDto.CategoryXml;
template.IsActive = templateDto.IsActive;
return await _templateRepository.UpdateAsync(template);
}
}
}

View File

@ -0,0 +1,57 @@
using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Domain.Entities;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Infrastructure.Services
{
public class TestEmailListService : ITestEmailListService
{
private readonly ITestEmailListRepository _testEmailListRepository;
public TestEmailListService(ITestEmailListRepository testEmailListRepository)
{
_testEmailListRepository = testEmailListRepository;
}
public async Task<TestEmailList?> GetByIdAsync(int id)
{
return await _testEmailListRepository.GetByIdAsync(id);
}
public async Task<List<TestEmailList>> GetAllAsync()
{
return await _testEmailListRepository.GetAllAsync();
}
public async Task<int?> CreateAsync(TestEmailListUpdateDto testEmailListDto)
{
ArgumentNullException.ThrowIfNull(testEmailListDto, nameof(testEmailListDto));
if (testEmailListDto.Id != null && testEmailListDto.Id > 0)
throw new Exception("ID must be null");
var testEmailList = new TestEmailList
{
Name = testEmailListDto.Name,
List = TestEmailList.GetListXml(testEmailListDto.Emails)
};
return await _testEmailListRepository.CreateAsync(testEmailList);
}
public async Task<bool> UpdateAsync(TestEmailListUpdateDto testEmailListDto)
{
ArgumentNullException.ThrowIfNull(testEmailListDto, nameof(testEmailListDto));
ArgumentNullException.ThrowIfNull(testEmailListDto.Id, nameof(testEmailListDto.Id));
var testEmailList = await _testEmailListRepository.GetByIdAsync(testEmailListDto.Id.Value);
if (testEmailList == null || testEmailList.Id == null) return false;
testEmailList.Name = testEmailListDto.Name;
testEmailList.List = TestEmailList.GetListXml(testEmailListDto.Emails);
return await _testEmailListRepository.UpdateAsync(testEmailList);
}
}
}

View File

@ -0,0 +1,58 @@
using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Application.DTOs;
using Surge365.MassEmailReact.Domain.Entities;
using Microsoft.Extensions.Configuration;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Infrastructure.Services
{
public class UnsubscribeUrlService : IUnsubscribeUrlService
{
private readonly IUnsubscribeUrlRepository _unsubscribeUrlRepository;
private readonly IConfiguration _config;
public UnsubscribeUrlService(IUnsubscribeUrlRepository unsubscribeUrlRepository, IConfiguration config)
{
_unsubscribeUrlRepository = unsubscribeUrlRepository;
_config = config;
}
public async Task<UnsubscribeUrl?> GetByIdAsync(int id)
{
return await _unsubscribeUrlRepository.GetByIdAsync(id);
}
public async Task<List<UnsubscribeUrl>> GetAllAsync(bool activeOnly = true)
{
return await _unsubscribeUrlRepository.GetAllAsync(activeOnly);
}
public async Task<int?> CreateAsync(UnsubscribeUrlUpdateDto unsubscribeUrlDto)
{
ArgumentNullException.ThrowIfNull(unsubscribeUrlDto, nameof(unsubscribeUrlDto));
if (unsubscribeUrlDto.Id != null && unsubscribeUrlDto.Id > 0)
throw new Exception("ID must be null");
var unsubscribeUrl = new UnsubscribeUrl
{
Name = unsubscribeUrlDto.Name,
Url = unsubscribeUrlDto.Url
};
return await _unsubscribeUrlRepository.CreateAsync(unsubscribeUrl);
}
public async Task<bool> UpdateAsync(UnsubscribeUrlUpdateDto unsubscribeUrlDto)
{
ArgumentNullException.ThrowIfNull(unsubscribeUrlDto, nameof(unsubscribeUrlDto));
ArgumentNullException.ThrowIfNull(unsubscribeUrlDto.Id, nameof(unsubscribeUrlDto.Id));
var unsubscribeUrl = await _unsubscribeUrlRepository.GetByIdAsync(unsubscribeUrlDto.Id.Value);
if (unsubscribeUrl == null || unsubscribeUrl.Id == null) return false;
unsubscribeUrl.Name = unsubscribeUrlDto.Name;
unsubscribeUrl.Url = unsubscribeUrlDto.Url;
return await _unsubscribeUrlRepository.UpdateAsync(unsubscribeUrl);
}
}
}

View File

@ -16,6 +16,9 @@
"@mui/material": "^6.4.5", "@mui/material": "^6.4.5",
"@mui/x-charts": "^7.27.1", "@mui/x-charts": "^7.27.1",
"@mui/x-data-grid": "^7.27.1", "@mui/x-data-grid": "^7.27.1",
"@tiptap/pm": "^2.11.5",
"@tiptap/react": "^2.11.5",
"@tiptap/starter-kit": "^2.11.5",
"admin-lte": "4.0.0-beta3", "admin-lte": "4.0.0-beta3",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
@ -1685,6 +1688,12 @@
"integrity": "sha512-HVj7LrZ4ReHWBimBvu2SKND3cDVUPWKLqRTmWe/fNY6o1owGOX0cAHbdPDTMelgBlVbrTKrre6lFkhqGZErK/g==", "integrity": "sha512-HVj7LrZ4ReHWBimBvu2SKND3cDVUPWKLqRTmWe/fNY6o1owGOX0cAHbdPDTMelgBlVbrTKrre6lFkhqGZErK/g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@remirror/core-constants": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
"integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
"license": "MIT"
},
"node_modules/@restart/hooks": { "node_modules/@restart/hooks": {
"version": "0.4.16", "version": "0.4.16",
"resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz",
@ -2033,6 +2042,391 @@
"tslib": "^2.8.0" "tslib": "^2.8.0"
} }
}, },
"node_modules/@tiptap/core": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.11.5.tgz",
"integrity": "sha512-jb0KTdUJaJY53JaN7ooY3XAxHQNoMYti/H6ANo707PsLXVeEqJ9o8+eBup1JU5CuwzrgnDc2dECt2WIGX9f8Jw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.11.5.tgz",
"integrity": "sha512-MZfcRIzKRD8/J1hkt/eYv49060GTL6qGR3NY/oTDuw2wYzbQXXLEbjk8hxAtjwNn7G+pWQv3L+PKFzZDxibLuA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.11.5.tgz",
"integrity": "sha512-OAq03MHEbl7MtYCUzGuwb0VpOPnM0k5ekMbEaRILFU5ZC7cEAQ36XmPIw1dQayrcuE8GZL35BKub2qtRxyC9iA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.11.5.tgz",
"integrity": "sha512-rx+rMd7EEdht5EHLWldpkzJ56SWYA9799b33ustePqhXd6linnokJCzBqY13AfZ9+xp3RsR6C0ZHI9GGea0tIA==",
"license": "MIT",
"dependencies": {
"tippy.js": "^6.3.7"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.11.5.tgz",
"integrity": "sha512-VXwHlX6A/T6FAspnyjbKDO0TQ+oetXuat6RY1/JxbXphH42nLuBaGWJ6pgy6xMl6XY8/9oPkTNrfJw/8/eeRwA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-code": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.11.5.tgz",
"integrity": "sha512-xOvHevNIQIcCCVn9tpvXa1wBp0wHN/2umbAZGTVzS+AQtM7BTo0tz8IyzwxkcZJaImONcUVYLOLzt2AgW1LltA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.11.5.tgz",
"integrity": "sha512-ksxMMvqLDlC+ftcQLynqZMdlJT1iHYZorXsXw/n+wuRd7YElkRkd6YWUX/Pq/njFY6lDjKiqFLEXBJB8nrzzBA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-document": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.11.5.tgz",
"integrity": "sha512-7I4BRTpIux2a0O2qS3BDmyZ5LGp3pszKbix32CmeVh7lN9dV7W5reDqtJJ9FCZEEF+pZ6e1/DQA362dflwZw2g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.11.5.tgz",
"integrity": "sha512-uIN7L3FU0904ec7FFFbndO7RQE/yiON4VzAMhNn587LFMyWO8US139HXIL4O8dpZeYwYL3d1FnDTflZl6CwLlg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.11.5.tgz",
"integrity": "sha512-HsMI0hV5Lwzm530Z5tBeyNCBNG38eJ3qjfdV2OHlfSf3+KOEfn6a5AUdoNaZO02LF79/8+7BaYU2drafag9cxQ==",
"license": "MIT",
"dependencies": {
"tippy.js": "^6.3.7"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.11.5.tgz",
"integrity": "sha512-kcWa+Xq9cb6lBdiICvLReuDtz/rLjFKHWpW3jTTF3FiP3wx4H8Rs6bzVtty7uOVTfwupxZRiKICAMEU6iT0xrQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.11.5.tgz",
"integrity": "sha512-q9doeN+Yg9F5QNTG8pZGYfNye3tmntOwch683v0CCVCI4ldKaLZ0jG3NbBTq+mosHYdgOH2rNbIORlRRsQ+iYQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.11.5.tgz",
"integrity": "sha512-x/MV53psJ9baRcZ4k4WjnCUBMt8zCX7mPlKVT+9C/o+DEs/j/qxPLs95nHeQv70chZpSwCQCt93xMmuF0kPoAg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-history": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.11.5.tgz",
"integrity": "sha512-b+wOS33Dz1azw6F1i9LFTEIJ/gUui0Jwz5ZvmVDpL2ZHBhq1Ui0/spTT+tuZOXq7Y/uCbKL8Liu4WoedIvhboQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.11.5.tgz",
"integrity": "sha512-3up2r1Du8/5/4ZYzTC0DjTwhgPI3dn8jhOCLu73m5F3OGvK/9whcXoeWoX103hYMnGDxBlfOje71yQuN35FL4A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.11.5.tgz",
"integrity": "sha512-9VGfb2/LfPhQ6TjzDwuYLRvw0A6VGbaIp3F+5Mql8XVdTBHb2+rhELbyhNGiGVR78CaB/EiKb6dO9xu/tBWSYA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.11.5.tgz",
"integrity": "sha512-Mp5RD/pbkfW1vdc6xMVxXYcta73FOwLmblQlFNn/l/E5/X1DUSA4iGhgDDH4EWO3swbs03x2f7Zka/Xoj3+WLg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.11.5.tgz",
"integrity": "sha512-Cu8KwruBNWAaEfshRQR0yOSaUKAeEwxW7UgbvF9cN/zZuKgK5uZosPCPTehIFCcRe+TBpRtZQh+06f/gNYpYYg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.11.5.tgz",
"integrity": "sha512-YFBWeg7xu/sBnsDIF/+nh9Arf7R0h07VZMd0id5Ydd2Qe3c1uIZwXxeINVtH0SZozuPIQFAT8ICe9M0RxmE+TA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.11.5.tgz",
"integrity": "sha512-PVfUiCqrjvsLpbIoVlegSY8RlkR64F1Rr2RYmiybQfGbg+AkSZXDeO0eIrc03//4gua7D9DfIozHmAKv1KN3ow==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-text": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.11.5.tgz",
"integrity": "sha512-Gq1WwyhFpCbEDrLPIHt5A8aLSlf8bfz4jm417c8F/JyU0J5dtYdmx0RAxjnLw1i7ZHE7LRyqqAoS0sl7JHDNSQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-text-style": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.11.5.tgz",
"integrity": "sha512-YUmYl0gILSd/u/ZkOmNxjNXVw+mu8fpC2f8G4I4tLODm0zCx09j9DDEJXSrM5XX72nxJQqtSQsCpNKnL0hfeEQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/pm": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.11.5.tgz",
"integrity": "sha512-z9JFtqc5ZOsdQLd9vRnXfTCQ8v5ADAfRt9Nm7SqP6FUHII8E1hs38ACzf5xursmth/VonJYb5+73Pqxk1hGIPw==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.2.1",
"prosemirror-collab": "^1.3.1",
"prosemirror-commands": "^1.6.2",
"prosemirror-dropcursor": "^1.8.1",
"prosemirror-gapcursor": "^1.3.2",
"prosemirror-history": "^1.4.1",
"prosemirror-inputrules": "^1.4.0",
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.1",
"prosemirror-menu": "^1.2.4",
"prosemirror-model": "^1.23.0",
"prosemirror-schema-basic": "^1.2.3",
"prosemirror-schema-list": "^1.4.1",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.6.3",
"prosemirror-trailing-node": "^3.0.0",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.37.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/react": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.11.5.tgz",
"integrity": "sha512-Dp8eHL1G+R/C4+QzAczyb3t1ovexEIZx9ln7SGEM+cT1KHKAw9XGPRgsp92+NQaYI+EdEb/YqoBOSzQcd18/OQ==",
"license": "MIT",
"dependencies": {
"@tiptap/extension-bubble-menu": "^2.11.5",
"@tiptap/extension-floating-menu": "^2.11.5",
"@types/use-sync-external-store": "^0.0.6",
"fast-deep-equal": "^3",
"use-sync-external-store": "^1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.11.5.tgz",
"integrity": "sha512-SLI7Aj2ruU1t//6Mk8f+fqW+18uTqpdfLUJYgwu0CkqBckrkRZYZh6GVLk/02k3H2ki7QkFxiFbZrdbZdng0JA==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "^2.11.5",
"@tiptap/extension-blockquote": "^2.11.5",
"@tiptap/extension-bold": "^2.11.5",
"@tiptap/extension-bullet-list": "^2.11.5",
"@tiptap/extension-code": "^2.11.5",
"@tiptap/extension-code-block": "^2.11.5",
"@tiptap/extension-document": "^2.11.5",
"@tiptap/extension-dropcursor": "^2.11.5",
"@tiptap/extension-gapcursor": "^2.11.5",
"@tiptap/extension-hard-break": "^2.11.5",
"@tiptap/extension-heading": "^2.11.5",
"@tiptap/extension-history": "^2.11.5",
"@tiptap/extension-horizontal-rule": "^2.11.5",
"@tiptap/extension-italic": "^2.11.5",
"@tiptap/extension-list-item": "^2.11.5",
"@tiptap/extension-ordered-list": "^2.11.5",
"@tiptap/extension-paragraph": "^2.11.5",
"@tiptap/extension-strike": "^2.11.5",
"@tiptap/extension-text": "^2.11.5",
"@tiptap/extension-text-style": "^2.11.5",
"@tiptap/pm": "^2.11.5"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -2149,6 +2543,28 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"license": "MIT"
},
"node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
"license": "MIT",
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"license": "MIT"
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.13.4", "version": "22.13.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.4.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.4.tgz",
@ -2198,6 +2614,12 @@
"@types/react": "*" "@types/react": "*"
} }
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/warning": { "node_modules/@types/warning": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz",
@ -2509,7 +2931,6 @@
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/babel-plugin-macros": { "node_modules/babel-plugin-macros": {
@ -2740,6 +3161,12 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -2941,6 +3368,18 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/error-ex": { "node_modules/error-ex": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
@ -3194,7 +3633,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-glob": { "node_modules/fast-glob": {
@ -3653,6 +4091,15 @@
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/locate-path": { "node_modules/locate-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@ -3698,6 +4145,29 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/markdown-it": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.4.0",
"linkify-it": "^5.0.0",
"mdurl": "^2.0.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
}
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
"license": "MIT"
},
"node_modules/merge2": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -3801,6 +4271,12 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/p-limit": { "node_modules/p-limit": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@ -3986,6 +4462,201 @@
"integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/prosemirror-changeset": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.2.1.tgz",
"integrity": "sha512-J7msc6wbxB4ekDFj+n9gTW/jav/p53kdlivvuppHsrZXCaQdVgRghoZbSS3kwrRyAstRVQ4/+u5k7YfLgkkQvQ==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-collab": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
"integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.0.tgz",
"integrity": "sha512-6toodS4R/Aah5pdsrIwnTYPEjW70SlO5a66oo5Kk+CIrgJz3ukOoS+FYDGqvQlAX5PxoGWDX1oD++tn5X3pyRA==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.1.tgz",
"integrity": "sha512-M30WJdJZLyXHi3N8vxN6Zh5O8ZBbQCz0gURTfPmTIBNQ5pxrdU7A58QkNqfa98YEjSAL1HUyyU34f6Pm5xBSGw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.3.2.tgz",
"integrity": "sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.4.1.tgz",
"integrity": "sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.4.0.tgz",
"integrity": "sha512-6ygpPRuTJ2lcOXs9JkefieMst63wVJBgHZGl5QOytN7oSZs3Co/BYbc3Yx9zm9H37Bxw8kVzCnDsihsVsL4yEg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.2.tgz",
"integrity": "sha512-EAlXoksqC6Vbocqc0GtzCruZEzYgrn+iiGnNjsJsH4mrnIGex4qbLdWWNza3AW5W36ZRrlBID0eM6bdKH4OStQ==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-markdown": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.1.tgz",
"integrity": "sha512-Sl+oMfMtAjWtlcZoj/5L/Q39MpEnVZ840Xo330WJWUvgyhNmLBLN7MsHn07s53nG/KImevWHSE6fEj4q/GihHw==",
"license": "MIT",
"dependencies": {
"@types/markdown-it": "^14.0.0",
"markdown-it": "^14.0.0",
"prosemirror-model": "^1.20.0"
}
},
"node_modules/prosemirror-menu": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.4.tgz",
"integrity": "sha512-S/bXlc0ODQup6aiBbWVsX/eM+xJgCTAfMq/nLqaO5ID/am4wS0tTCIkzwytmao7ypEtjj39i7YbJjAgO20mIqA==",
"license": "MIT",
"dependencies": {
"crelt": "^1.0.0",
"prosemirror-commands": "^1.0.0",
"prosemirror-history": "^1.0.0",
"prosemirror-state": "^1.0.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.24.1",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.24.1.tgz",
"integrity": "sha512-YM053N+vTThzlWJ/AtPtF1j0ebO36nvbmDy4U7qA2XQB8JVaQp1FmB9Jhrps8s+z+uxhhVTny4m20ptUvhk0Mg==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-basic": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.3.tgz",
"integrity": "sha512-h+H0OQwZVqMon1PNn0AG9cTfx513zgIG2DY00eJ00Yvgb3UD+GQ/VlWW5rcaxacpCGT1Yx8nuhwXk4+QbXUfJA==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.19.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.0.tgz",
"integrity": "sha512-gg1tAfH1sqpECdhIHOA/aLg2VH3ROKBWQ4m8Qp9mBKrOxQRW61zc+gMCI8nh22gnBzd1t2u1/NPLmO3nAa3ssg==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.3.tgz",
"integrity": "sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.6.4.tgz",
"integrity": "sha512-TkDY3Gw52gRFRfRn2f4wJv5WOgAOXLJA2CQJYIJ5+kdFbfj3acR4JUW6LX2e1hiEBiUwvEhzH5a3cZ5YSztpIA==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.2",
"prosemirror-model": "^1.24.1",
"prosemirror-state": "^1.4.3",
"prosemirror-transform": "^1.10.2",
"prosemirror-view": "^1.37.2"
}
},
"node_modules/prosemirror-trailing-node": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
"integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
"license": "MIT",
"dependencies": {
"@remirror/core-constants": "3.0.0",
"escape-string-regexp": "^4.0.0"
},
"peerDependencies": {
"prosemirror-model": "^1.22.1",
"prosemirror-state": "^1.4.2",
"prosemirror-view": "^1.33.8"
}
},
"node_modules/prosemirror-transform": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.2.tgz",
"integrity": "sha512-2iUq0wv2iRoJO/zj5mv8uDUriOHWzXRnOTVgCzSXnktS/2iQRa3UUQwVlkBlYZFtygw6Nh1+X4mGqoYBINn5KQ==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.38.0",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.38.0.tgz",
"integrity": "sha512-O45kxXQTaP9wPdXhp8TKqCR+/unS/gnfg9Q93svQcB3j0mlp2XSPAmsPefxHADwzC+fbNS404jqRxm3UQaGvgw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -3996,6 +4667,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@ -4269,6 +4949,12 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/run-parallel": { "node_modules/run-parallel": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@ -4407,6 +5093,15 @@
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==", "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tippy.js": {
"version": "6.3.7",
"resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz",
"integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==",
"license": "MIT",
"dependencies": {
"@popperjs/core": "^2.9.0"
}
},
"node_modules/to-regex-range": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@ -4513,6 +5208,12 @@
"typescript": ">=4.8.4 <5.8.0" "typescript": ">=4.8.4 <5.8.0"
} }
}, },
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/uncontrollable": { "node_modules/uncontrollable": {
"version": "7.2.1", "version": "7.2.1",
"resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz",
@ -4657,6 +5358,12 @@
} }
} }
}, },
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/warning": { "node_modules/warning": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",

View File

@ -19,6 +19,9 @@
"@mui/material": "^6.4.5", "@mui/material": "^6.4.5",
"@mui/x-charts": "^7.27.1", "@mui/x-charts": "^7.27.1",
"@mui/x-data-grid": "^7.27.1", "@mui/x-data-grid": "^7.27.1",
"@tiptap/pm": "^2.11.5",
"@tiptap/react": "^2.11.5",
"@tiptap/starter-kit": "^2.11.5",
"admin-lte": "4.0.0-beta3", "admin-lte": "4.0.0-beta3",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",

View File

@ -18,12 +18,13 @@ import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText'; import ListItemText from '@mui/material/ListItemText';
import DashboardIcon from '@mui/icons-material/Dashboard'; import DashboardIcon from '@mui/icons-material/Dashboard';
import HttpIcon from '@mui/icons-material/Http';
import DnsIcon from '@mui/icons-material/Dns'; import DnsIcon from '@mui/icons-material/Dns';
import TargetIcon from '@mui/icons-material/TrackChanges'; import TargetIcon from '@mui/icons-material/TrackChanges';
import MarkEmailReadIcon from '@mui/icons-material/MarkEmailRead'; import MarkEmailReadIcon from '@mui/icons-material/MarkEmailRead';
import BlockIcon from '@mui/icons-material/Block'; import BlockIcon from '@mui/icons-material/Block';
import LinkOffIcon from '@mui/icons-material/LinkOff'; //import LinkOffIcon from '@mui/icons-material/LinkOff';
import EmailIcon from '@mui/icons-material/Email'; import EmailIcon from '@mui/icons-material/Email';
import SendIcon from '@mui/icons-material/Send'; import SendIcon from '@mui/icons-material/Send';
import ScheduleSendIcon from '@mui/icons-material/ScheduleSend'; import ScheduleSendIcon from '@mui/icons-material/ScheduleSend';
@ -216,9 +217,10 @@ const Layout = ({ children }: LayoutProps) => {
{ text: 'Home', icon: <DashboardIcon />, path: '/home' }, { text: 'Home', icon: <DashboardIcon />, path: '/home' },
{ text: 'Servers', icon: <DnsIcon />, path: '/servers' }, { text: 'Servers', icon: <DnsIcon />, path: '/servers' },
{ text: 'Targets', icon: <TargetIcon />, path: '/targets' }, { text: 'Targets', icon: <TargetIcon />, path: '/targets' },
{ text: 'Test Lists', icon: <MarkEmailReadIcon />, path: '/testLists' }, { text: 'Test Lists', icon: <MarkEmailReadIcon />, path: '/testEmailLists' },
{ text: 'Blocked Emails', icon: <BlockIcon />, path: '/blockedEmails' }, { text: 'Blocked Emails', icon: <BlockIcon />, path: '/blockedEmails' },
{ text: 'Unsubscribe Urls', icon: <LinkOffIcon />, path: '/unsubscribeUrls' }, { text: 'Email Domains', icon: <HttpIcon />, path: '/emailDomains' },
//{ text: 'Unsubscribe Urls', icon: <LinkOffIcon />, path: '/unsubscribeUrls' },
{ text: 'Templates', icon: <EmailIcon />, path: '/templates' }, { text: 'Templates', icon: <EmailIcon />, path: '/templates' },
{ text: 'New Mailings', icon: <SendIcon />, path: '/newMailings' }, //TODO: Maybe move all mailings to same page? Mailing stats on dashboard? { text: 'New Mailings', icon: <SendIcon />, path: '/newMailings' }, //TODO: Maybe move all mailings to same page? Mailing stats on dashboard?
{ text: 'Scheduled Mailings', icon: <ScheduleSendIcon />, path: '/scheduledMailings' }, // { text: 'Scheduled Mailings', icon: <ScheduleSendIcon />, path: '/scheduledMailings' }, //
@ -226,7 +228,8 @@ const Layout = ({ children }: LayoutProps) => {
{ text: 'Completed Mailings', icon: <CheckCircleIcon />, path: '/completedMailings' }, { text: 'Completed Mailings', icon: <CheckCircleIcon />, path: '/completedMailings' },
].map((item) => ( ].map((item) => (
<ListItem key={item.text} disablePadding> <ListItem key={item.text} disablePadding>
<ListItemButton component={RouterLink} to={item.path}> <ListItemButton component={RouterLink} to={item.path} onClick={() => isMobile && handleDrawerClose()}
>
<ListItemIcon>{item.icon}</ListItemIcon> <ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.text} /> <ListItemText primary={item.text} />
</ListItemButton> </ListItemButton>

View File

@ -0,0 +1,149 @@
import { useState, useEffect } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
TextField,
DialogActions,
Button,
Switch,
FormControlLabel,
} from "@mui/material";
import BouncedEmail from "@/types/bouncedEmail";
import { useSetupData, SetupData } from "@/context/SetupDataContext";
import { useForm, Resolver, Controller } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
type BouncedEmailEditProps = {
open: boolean;
bouncedEmail: BouncedEmail | null;
onClose: () => void;
onSave: (emailAddress: string, updatedBouncedEmail: BouncedEmail) => void;
};
const schema = yup.object().shape({
id: yup.number(),
emailAddress: yup
.string()
.required("Email address is required")
.test("unique-email", "Email must be unique", function (value) {
const setupData = this.options.context?.setupData as { bouncedEmails: BouncedEmail[] };
if (!setupData) return true;
return !setupData.bouncedEmails.some(
(t) => t.emailAddress.toLowerCase() === value?.toLowerCase() && (t.emailAddress !== this.parent.emailAddress)
);
}),
spam: yup.boolean().default(false),
unsubscribe: yup.boolean().default(false),
enteredByAdmin: yup.boolean().default(false),
});
const defaultBouncedEmail: BouncedEmail = {
emailAddress: "",
spam: false,
unsubscribe: false,
enteredByAdmin: true,
};
const BouncedEmailEdit = ({ open, bouncedEmail, onClose, onSave }: BouncedEmailEditProps) => {
const isNew = bouncedEmail == null;
const setupData: SetupData = useSetupData();
const originalBouncedEmail: BouncedEmail | null = bouncedEmail ? { ...bouncedEmail } : null;
const { register, handleSubmit, reset, control, formState: { errors } } = useForm<BouncedEmail>({
mode: "onBlur",
defaultValues: bouncedEmail || defaultBouncedEmail,
resolver: yupResolver(schema) as Resolver<BouncedEmail>,
context: { setupData },
});
const [loading, setLoading] = useState(false);
useEffect(() => {
if (open) {
reset(bouncedEmail || defaultBouncedEmail, { keepDefaultValues: true });
}
}, [open, bouncedEmail, reset]);
const handleSave = async (formData: BouncedEmail) => {
const originalBouncedEmailAddress: string = originalBouncedEmail?.emailAddress ?? "";
const apiUrl = isNew ? "/api/bouncedEmails" : `/api/bouncedEmails/${encodeURIComponent(originalBouncedEmailAddress)}`;
const method = isNew ? "POST" : "PUT"
setLoading(true);
try {
const response = await fetch(apiUrl, {
method: method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (!response.ok) throw new Error(isNew ? "Failed to create" : "Failed to update");
const updatedBouncedEmail = await response.json();
onSave(originalBouncedEmailAddress, updatedBouncedEmail);
onClose();
} catch (error) {
console.error("Update error:", error);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{isNew ? "Add Bounced Email" : "Edit Bounced Email"}</DialogTitle>
<DialogContent>
<TextField
{...register("emailAddress")}
label="Email Address"
fullWidth
margin="dense"
error={!!errors.emailAddress}
helperText={errors.emailAddress?.message}
/>
<Controller
name="spam"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Switch
{...field}
checked={field.value}
onChange={(e) => field.onChange(e.target.checked)}
/>
}
label="Spam"
/>
)}
/>
<Controller
name="unsubscribe"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Switch
{...field}
checked={field.value}
onChange={(e) => field.onChange(e.target.checked)}
/>
}
label="Unsubscribe"
/>
)}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={loading}>Cancel</Button>
<Button onClick={handleSubmit(handleSave)} color="primary" disabled={loading}>
{loading ? "Saving..." : "Save"}
</Button>
</DialogActions>
</Dialog>
);
};
export default BouncedEmailEdit;

View File

@ -0,0 +1,168 @@
import { useState, useEffect } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
TextField,
DialogActions,
Button,
Switch,
FormControlLabel,
} from "@mui/material";
import EmailDomain from "@/types/emailDomain";
import { useSetupData, SetupData } from "@/context/SetupDataContext";
import { useForm, Controller, Resolver } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
type EmailDomainEditProps = {
open: boolean;
emailDomain: EmailDomain | null;
onClose: () => void;
onSave: (updatedEmailDomain: EmailDomain) => void;
};
const schema = yup.object().shape({
id: yup.number(),
name: yup
.string()
.required("Name is required")
.test("unique-name", "Name must be unique", function (value) {
const setupData = this.options.context?.setupData as { emailDomains: EmailDomain[] };
if (!setupData) return true;
return !setupData.emailDomains.some(
(d) => d.name.toLowerCase() === value?.toLowerCase() && (d.id === 0 || d.id !== this.parent.id)
);
}),
emailAddress: yup.string().required("Email address is required"),
username: yup.string().required("Username is required"),
password: yup.string().required("Password is required"),
isActive: yup.boolean().default(true),
displayOrder: yup.number().required("Display order is required"),
});
const defaultEmailDomain: EmailDomain = {
id: 0,
name: "",
emailAddress: "",
username: "",
password: "",
isActive: true,
displayOrder: 0,
};
const EmailDomainEdit = ({ open, emailDomain, onClose, onSave }: EmailDomainEditProps) => {
const isNew = !emailDomain || emailDomain.id === 0;
const setupData: SetupData = useSetupData();
const { register, trigger, control, handleSubmit, reset, formState: { errors } } = useForm<EmailDomain>({
mode: "onBlur",
defaultValues: emailDomain || defaultEmailDomain,
resolver: yupResolver(schema) as Resolver<EmailDomain>,
context: { setupData },
});
const [loading, setLoading] = useState(false);
useEffect(() => {
if (open) {
reset(emailDomain || defaultEmailDomain, { keepDefaultValues: true });
}
}, [open, emailDomain, reset]);
const handleSave = async (formData: EmailDomain) => {
const apiUrl = isNew ? "/api/emailDomains" : `/api/emailDomains/${formData.id}`;
const method = isNew ? "POST" : "PUT";
setLoading(true);
try {
const response = await fetch(apiUrl, {
method: method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (!response.ok) throw new Error(isNew ? "Failed to create" : "Failed to update");
const updatedEmailDomain = await response.json();
onSave(updatedEmailDomain);
onClose();
} catch (error) {
console.error("Update error:", error);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{isNew ? "Add Email Domain" : "Edit Email Domain id=" + emailDomain?.id}</DialogTitle>
<DialogContent>
<TextField
{...register("name")}
label="Name"
fullWidth
margin="dense"
error={!!errors.name}
helperText={errors.name?.message}
/>
<TextField
{...register("emailAddress")}
label="Email Address"
fullWidth
margin="dense"
error={!!errors.emailAddress}
helperText={errors.emailAddress?.message}
/>
<TextField
{...register("username")}
label="Username"
fullWidth
margin="dense"
error={!!errors.username}
helperText={errors.username?.message}
/>
<TextField
{...register("password")}
label="Password"
fullWidth
margin="dense"
error={!!errors.password}
helperText={errors.password?.message}
/>
<TextField
{...register("displayOrder")}
label="Display Order"
type="number"
fullWidth
margin="dense"
error={!!errors.displayOrder}
helperText={errors.displayOrder?.message}
/>
<Controller
name="isActive"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Switch
{...field}
checked={field.value}
onChange={(e) => field.onChange(e.target.checked)}
/>
}
label="Active"
/>
)}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={loading}>Cancel</Button>
<Button onClick={handleSubmit(handleSave)} color="primary" disabled={loading}>
{loading ? "Saving..." : "Save"}
</Button>
</DialogActions>
</Dialog>
);
};
export default EmailDomainEdit;

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect } from "react";
import { import {
Dialog, Dialog,
DialogTitle, DialogTitle,

View File

@ -0,0 +1,283 @@
import { useState, useEffect } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
TextField,
Autocomplete,
DialogActions,
Button,
Switch,
FormControlLabel,
} from "@mui/material";
import { useForm, Controller } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import { EditorContent, useEditor, Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
// Assuming these types and context are defined elsewhere
import Template from "@/types/template";
import { useSetupData } from "@/context/SetupDataContext";
type TemplateEditProps = {
open: boolean;
template: Template | null;
onClose: () => void;
onSave: (updatedTemplate: Template) => void;
};
const schema = yup.object().shape({
id: yup.number().default(0),
name: yup.string().required("Name is required"),
domainId: yup.number().required("Domain is required").moreThan(0, "Domain is required"),
description: yup.string().required("Description is required"),
htmlBody: yup.string().required("HTML Body is required"),
subject: yup.string().required("Subject is required"),
toName: yup.string().default(""),
fromName: yup.string().required("From Name is required"),
fromEmail: yup.string().email("Invalid email").required("From Email is required"),
replyToEmail: yup.string().email("Invalid email").required("Reply To Email is required"),
clickTracking: yup.boolean().default(false),
openTracking: yup.boolean().default(false),
categoryXml: yup.string().default(""),
isActive: yup.boolean().default(true),
});
const defaultTemplate: Template = {
id: 0,
name: "",
domainId: 0,
description: "",
htmlBody: "",
subject: "",
toName: "",
fromName: "",
fromEmail: "",
replyToEmail: "",
clickTracking: false,
openTracking: false,
categoryXml: "",
isActive: true,
};
const TemplateEdit = ({ open, template, onClose, onSave }: TemplateEditProps) => {
const isNew = !template || template.id === 0;
const setupData = useSetupData();
const {
register,
control,
handleSubmit,
reset,
setValue,
formState: { errors },
} = useForm<Template>({
mode: "onBlur",
defaultValues: template || defaultTemplate,
resolver: yupResolver(schema),
context: { setupData },
});
const [loading, setLoading] = useState(false);
const editor = useEditor({
extensions: [StarterKit],
content: template?.htmlBody || '',
onUpdate: ({ editor }) => {
const html = editor.getHTML();
setValue('htmlBody', html, { shouldValidate: true });
},
});
useEffect(() => {
if (open && editor) {
editor.commands.setContent(template?.htmlBody || '');
}
}, [open, template, editor]);
const handleSave = async (formData: Template) => {
const apiUrl = isNew ? "/api/templates" : `/api/templates/${formData.id}`;
const method = isNew ? "POST" : "PUT";
setLoading(true);
try {
const response = await fetch(apiUrl, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (!response.ok) throw new Error(isNew ? "Failed to create" : "Failed to update");
const updatedTemplate = await response.json();
onSave(updatedTemplate);
onClose();
} catch (error) {
console.error("Save error:", error);
} finally {
setLoading(false);
}
};
type MenuProps = {
editor: Editor | null;
};
const MenuBar = ({ editor }: MenuProps) => {
if (!editor) return null;
return (
<div>
<button
onClick={() => editor.chain().focus().toggleBold().run()}
disabled={!editor.can().toggleBold()}
>
Bold
</button>
<button
onClick={() => editor.chain().focus().toggleItalic().run()}
disabled={!editor.can().toggleItalic()}
>
Italic
</button>
<button
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
Bullet List
</button>
<button
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
Ordered List
</button>
</div>
);
};
return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>{isNew ? "Add Template" : "Edit Template id=" + template?.id}</DialogTitle>
<DialogContent>
<Controller
name="domainId"
control={control}
render={({ field }) => (
<Autocomplete
options={setupData.emailDomains}
getOptionLabel={(option) => option.name}
value={setupData.emailDomains.find((d) => d.id === field.value) || null}
onChange={(_, newValue) => field.onChange(newValue ? newValue.id : null)}
renderInput={(params) => (
<TextField
{...params}
label="Domain"
margin="dense"
error={!!errors.domainId}
helperText={errors.domainId?.message}
/>
)}
/>
)}
/>
<TextField
{...register("name")}
label="Name"
fullWidth
margin="dense"
error={!!errors.name}
helperText={errors.name?.message}
/>
<TextField
{...register("description")}
label="Description"
fullWidth
margin="dense"
error={!!errors.description}
helperText={errors.description?.message}
/>
<Controller
name="htmlBody"
control={control}
render={({ field }) => (
<div>
<MenuBar editor={editor} />
<EditorContent editor={editor} />
{errors.htmlBody && (
<p style={{ color: 'red' }}>{errors.htmlBody.message}</p>
)}
</div>
)}
/>
<TextField
{...register("subject")}
label="Subject"
fullWidth
margin="dense"
error={!!errors.subject}
helperText={errors.subject?.message}
/>
<TextField
{...register("fromName")}
label="From Name"
fullWidth
margin="dense"
error={!!errors.fromName}
helperText={errors.fromName?.message}
/>
<TextField
{...register("fromEmail")}
label="From Email"
fullWidth
margin="dense"
error={!!errors.fromEmail}
helperText={errors.fromEmail?.message}
/>
<TextField
{...register("replyToEmail")}
label="Reply To Email"
fullWidth
margin="dense"
error={!!errors.replyToEmail}
helperText={errors.replyToEmail?.message}
/>
<FormControlLabel
control={<Switch {...register("clickTracking")} />}
label="Click Tracking"
/>
<FormControlLabel
control={<Switch {...register("openTracking")} />}
label="Open Tracking"
/>
<TextField
{...register("categoryXml")}
label="Category XML"
fullWidth
margin="dense"
multiline
rows={4}
error={!!errors.categoryXml}
helperText={errors.categoryXml?.message}
/>
<Controller
name="isActive"
control={control}
render={({ field }) => (
<FormControlLabel
control={<Switch checked={field.value} onChange={(e) => field.onChange(e.target.checked)} />}
label="Active"
/>
)}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button onClick={handleSubmit(handleSave)} color="primary" disabled={loading}>
{loading ? "Saving..." : "Save"}
</Button>
</DialogActions>
</Dialog>
);
};
export default TemplateEdit;

View File

@ -0,0 +1,156 @@
// Surge365.MassEmailReact.Frontend/src/components/modals/TestEmailListEdit.tsx
import { useState, useEffect } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
TextField,
DialogActions,
Button,
} from "@mui/material";
import { useForm, Controller, Resolver } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import TestEmailList from "@/types/testEmailList";
import { useSetupData, SetupData } from "@/context/SetupDataContext";
type TestEmailListEditProps = {
open: boolean;
testEmailList: TestEmailList | null;
onClose: () => void;
onSave: (updatedTestEmailList: TestEmailList) => void;
};
type TestEmailListForm = {
id?: number;
name: string;
emails: string;
};
const schema = yup.object().shape({
id: yup.number(),
name: yup
.string()
.required("Name is required")
.test("unique-name", "Name must be unique", function (value) {
const setupData = this.options.context?.setupData as { testEmailLists: TestEmailList[] };
if (!setupData) return true;
return !setupData.testEmailLists.some(
(t) => t.name.toLowerCase() === value?.toLowerCase() && t.id !== this.parent.id
);
}),
emails: yup
.string()
.required("Emails are required")
.test("valid-emails", "Each line must be a valid email", function (value) {
if (!value) return true;
const emails = value.split("\n").map((e) => e.trim()).filter((e) => e);
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emails.every((email) => emailRegex.test(email));
}),
});
const defaultTestEmailList: TestEmailListForm = {
name: "",
emails: "",
};
const TestEmailListEdit = ({ open, testEmailList, onClose, onSave }: TestEmailListEditProps) => {
const isNew = !testEmailList || testEmailList.id === 0;
const setupData: SetupData = useSetupData();
const [loading, setLoading] = useState(false);
const { control, handleSubmit, reset, formState: { errors } } = useForm<TestEmailListForm>({
mode: "onBlur",
defaultValues: defaultTestEmailList,
resolver: yupResolver(schema) as Resolver<TestEmailListForm>,
context: { setupData },
});
useEffect(() => {
if (open) {
if (testEmailList) {
const emails = testEmailList.emails.map((email) => `${email}\n`).join("");
reset({ id: testEmailList.id, name: testEmailList.name, emails });
} else {
reset(defaultTestEmailList);
}
}
}, [open, testEmailList, reset]);
const handleSave = async (formData: TestEmailListForm) => {
const emails = formData.emails.split("\n");
const testEmailListDto = {
id: formData.id,
name: formData.name,
emails,
};
const apiUrl = isNew ? "/api/testEmailLists" : `/api/testEmailLists/${formData.id}`;
const method = isNew ? "POST" : "PUT";
setLoading(true);
try {
const response = await fetch(apiUrl, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(testEmailListDto),
});
if (!response.ok) throw new Error(isNew ? "Failed to create" : "Failed to update");
const updatedTestEmailList = await response.json();
onSave(updatedTestEmailList);
onClose();
} catch (error) {
console.error("Save error:", error);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{isNew ? "Add Test Email List" : "Edit Test Email List id=" + testEmailList?.id}</DialogTitle>
<DialogContent>
<Controller
name="name"
control={control}
render={({ field }) => (
<TextField
{...field}
label="Name"
fullWidth
margin="dense"
error={!!errors.name}
helperText={errors.name?.message}
/>
)}
/>
<Controller
name="emails"
control={control}
render={({ field }) => (
<TextField
{...field}
label="Emails (one per line)"
fullWidth
multiline
rows={10}
margin="dense"
error={!!errors.emails}
helperText={errors.emails?.message || "Enter one email per line"}
/>
)}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={loading}>Cancel</Button>
<Button onClick={handleSubmit(handleSave)} color="primary" disabled={loading}>
{loading ? "Saving..." : "Save"}
</Button>
</DialogActions>
</Dialog>
);
};
export default TestEmailListEdit;

View File

@ -0,0 +1,105 @@
import { useState, useEffect } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
TextField,
DialogActions,
Button,
} from "@mui/material";
import UnsubscribeUrl from "@/types/unsubscribeUrl";
import { useForm, Resolver } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
type UnsubscribeUrlEditProps = {
open: boolean;
unsubscribeUrl: UnsubscribeUrl | null;
onClose: () => void;
onSave: (updatedUnsubscribeUrl: UnsubscribeUrl) => void;
};
const schema = yup.object().shape({
id: yup.number(),
name: yup.string().required("Name is required"),
url: yup.string().required("URL is required"),
});
const defaultUnsubscribeUrl: UnsubscribeUrl = {
id: 0,
name: "",
url: "",
};
const UnsubscribeUrlEdit = ({ open, unsubscribeUrl, onClose, onSave }: UnsubscribeUrlEditProps) => {
const isNew = !unsubscribeUrl || unsubscribeUrl.id === 0;
const { register, handleSubmit, reset, formState: { errors } } = useForm<UnsubscribeUrl>({
mode: "onBlur",
defaultValues: unsubscribeUrl || defaultUnsubscribeUrl,
resolver: yupResolver(schema) as Resolver<UnsubscribeUrl>,
});
const [loading, setLoading] = useState(false);
useEffect(() => {
if (open) {
reset(unsubscribeUrl || defaultUnsubscribeUrl, { keepDefaultValues: true });
}
}, [open, unsubscribeUrl, reset]);
const handleSave = async (formData: UnsubscribeUrl) => {
const apiUrl = isNew ? "/api/unsubscribeUrls" : `/api/unsubscribeUrls/${formData.id}`;
const method = isNew ? "POST" : "PUT";
setLoading(true);
try {
const response = await fetch(apiUrl, {
method: method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (!response.ok) throw new Error(isNew ? "Failed to create" : "Failed to update");
const updatedUnsubscribeUrl = await response.json();
onSave(updatedUnsubscribeUrl);
onClose();
} catch (error) {
console.error("Update error:", error);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{isNew ? "Add Unsubscribe URL" : "Edit Unsubscribe URL id=" + unsubscribeUrl?.id}</DialogTitle>
<DialogContent>
<TextField
{...register("name")}
label="Name"
fullWidth
margin="dense"
error={!!errors.name}
helperText={errors.name?.message}
/>
<TextField
{...register("url")}
label="URL"
fullWidth
margin="dense"
error={!!errors.url}
helperText={errors.url?.message}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={loading}>Cancel</Button>
<Button onClick={handleSubmit(handleSave)} color="primary" disabled={loading}>
{loading ? "Saving..." : "Save"}
</Button>
</DialogActions>
</Dialog>
);
};
export default UnsubscribeUrlEdit;

View File

@ -8,7 +8,12 @@ import Home from '@/components/pages/Home';
import Login from '@/components/pages/Login'; import Login from '@/components/pages/Login';
import Servers from '@/components/pages/Servers'; import Servers from '@/components/pages/Servers';
import Targets from '@/components/pages/Targets'; import Targets from '@/components/pages/Targets';
import TestEmailLists from '@/components/pages/TestEmailLists';
import BouncedEmails from '@/components/pages/BouncedEmails';
import UnsubscribeUrls from '@/components/pages/UnsubscribeUrls';
import Templates from '@/components/pages/Templates'; import Templates from '@/components/pages/Templates';
import EmailDomains from '@/components/pages/EmailDomains';
import { ColorModeContext } from '@/theme/theme'; import { ColorModeContext } from '@/theme/theme';
import { SetupDataProvider } from '@/context/SetupDataContext'; import { SetupDataProvider } from '@/context/SetupDataContext';
import { useTitle } from "@/context/TitleContext"; import { useTitle } from "@/context/TitleContext";
@ -82,6 +87,56 @@ const App = () => {
</PageWrapper> </PageWrapper>
} }
/> />
<Route
path="/testEmailLists"
element={
<PageWrapper title="Test Email Lists">
<Layout>
<TestEmailLists />
</Layout>
</PageWrapper>
}
/>
<Route
path="/blockedEmails"
element={
<PageWrapper title="Blocked Emails">
<Layout>
<BouncedEmails />
</Layout>
</PageWrapper>
}
/>
<Route
path="/blockedEmails"
element={
<PageWrapper title="Blocked Emails">
<Layout>
<BouncedEmails />
</Layout>
</PageWrapper>
}
/>
<Route
path="/emailDomains"
element={
<PageWrapper title="Email Domains">
<Layout>
<EmailDomains />
</Layout>
</PageWrapper>
}
/>
<Route
path="/unsubscribeUrls"
element={
<PageWrapper title="Unsubscribe Urls">
<Layout>
<UnsubscribeUrls />
</Layout>
</PageWrapper>
}
/>
<Route <Route
path="/templates" path="/templates"
element={ element={

View File

@ -0,0 +1,166 @@
import { useState, useRef } from 'react';
import { useSetupData, SetupData } from "@/context/SetupDataContext";
import EditIcon from '@mui/icons-material/Edit';
import { List, Card, CardContent, Typography, Box, useTheme, useMediaQuery, Button, CircularProgress, IconButton } from '@mui/material';
import { DataGrid, GridColDef, GridRenderCellParams, GridRowModel, GridToolbarContainer, GridToolbarQuickFilter, GridToolbarExport, GridToolbarDensitySelector, GridToolbarColumnsButton, GridDeleteIcon } from '@mui/x-data-grid';
import BouncedEmail from '@/types/bouncedEmail';
import BouncedEmailEdit from "@/components/modals/BouncedEmailEdit";
function BouncedEmails() {
const theme = useTheme();
const setupData: SetupData = useSetupData();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const gridContainerRef = useRef<HTMLDivElement | null>(null);
const [selectedRow, setSelectedRow] = useState<BouncedEmail | null>(null);
const [open, setOpen] = useState<boolean>(false);
const columns: GridColDef<BouncedEmail>[] = [
{
field: "actions",
headerName: "",
sortable: false,
renderCell: (params: GridRenderCellParams<BouncedEmail>) => (
<div>
<IconButton onClick={(e) => { e.stopPropagation(); handleEdit(params.row); }}>
<EditIcon />
</IconButton>
<IconButton onClick={(e) => { e.stopPropagation(); handleDelete(params.row); }}>
<GridDeleteIcon />
</IconButton>
</div>
),
},
{ field: "emailAddress", headerName: "Email Address", flex: 1, minWidth: 160 },
{ field: "spam", headerName: "Spam", width: 100 },
{ field: "unsubscribe", headerName: "Unsubscribe", width: 100 },
{ field: "enteredByAdmin", headerName: "Entered By Admin", width: 150 },
];
const handleNew = () => {
setSelectedRow(null);
setOpen(true);
};
const handleEdit = (row: GridRowModel<BouncedEmail>) => {
setSelectedRow(row);
setOpen(true);
};
const handleDelete = async (row: GridRowModel<BouncedEmail>) => {
const apiUrl = `/api/bouncedEmails/${encodeURIComponent(row?.emailAddress ?? "")}`;
const method = "DELETE";
//setLoading(true);
try {
const response = await fetch(apiUrl, {
method: method,
headers: { "Content-Type": "application/json" }
});
if (!response.ok) throw new Error("Failed to delete");
setupData.removeBouncedEmailFromCache(row.emailAddress!);
} catch (error) {
console.error("Update error:", error);
} finally {
//setLoading(false);
}
};
const handleUpdateRow = (emailAddress: string, updatedRow: BouncedEmail) => {
setupData.setBouncedEmails(emailAddress, updatedRow);
};
return (
<Box ref={gridContainerRef} sx={{
position: 'relative', left: 0, right: 0, height: "calc(100vh - 124px)", overflow: "hidden",
transition: theme.transitions.create(['width', 'height'], {
easing: theme.transitions.easing.easeInOut,
duration: theme.transitions.duration.standard,
})
}}>
<Box sx={{ position: 'absolute', inset: 0 }}>
{isMobile ? (
<List>
{setupData.bouncedEmails.map((row) => (
<Card key={row.emailAddress} sx={{ marginBottom: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<CardContent>
<Typography variant="h6">{row.emailAddress}</Typography>
<Typography variant="body2">Spam: {row.spam ? "Yes" : "No"}</Typography>
<Typography variant="body2">Unsubscribe: {row.unsubscribe ? "Yes" : "No"}</Typography>
<Typography variant="body2">Entered By Admin: {row.enteredByAdmin ? "Yes" : "No"}</Typography>
</CardContent>
<IconButton onClick={(e) => { e.stopPropagation(); handleEdit(row); }}>
<EditIcon />
</IconButton>
<IconButton onClick={(e) => { e.stopPropagation(); handleDelete(row); }}>
<GridDeleteIcon />
</IconButton>
</Box>
</Card>
))}
</List>
) : (
<DataGrid
rows={setupData.bouncedEmails}
columns={columns}
getRowId={(row) => row.emailAddress!}//Set this if object doesn't have id as unique id (like serverKey etc)
autoPageSize
sx={{ minWidth: "600px" }}
slots={{
toolbar: () => (
<GridToolbarContainer sx={{ display: "flex", alignItems: "center" }}>
<Button
variant="contained"
color="primary"
onClick={() => handleNew()}
sx={{ marginRight: 2 }}
>
{setupData.bouncedEmailsLoading ? <CircularProgress size={24} color="inherit" /> : "Add New"}
</Button>
<Button
variant="contained"
color="primary"
onClick={() => setupData.reloadBouncedEmails()}
sx={{ marginRight: 2 }}
>
{setupData.bouncedEmailsLoading ? <CircularProgress size={24} color="inherit" /> : "Refresh"}
</Button>
<GridToolbarColumnsButton />
<GridToolbarDensitySelector />
<GridToolbarExport />
<GridToolbarQuickFilter sx={{ ml: "auto" }} />
</GridToolbarContainer>
),
}}
slotProps={{
toolbar: {
showQuickFilter: true,
},
}}
initialState={{
pagination: {
paginationModel: {
pageSize: 20,
},
},
}}
pageSizeOptions={[10, 20, 50, 100]}
/>
)}
</Box>
{open && (
<BouncedEmailEdit
open={open}
bouncedEmail={selectedRow}
onClose={() => setOpen(false)}
onSave={handleUpdateRow}
/>
)}
</Box>
);
}
export default BouncedEmails;

View File

@ -0,0 +1,143 @@
import { useState, useRef } from 'react';
import { useSetupData, SetupData } from "@/context/SetupDataContext";
import EditIcon from '@mui/icons-material/Edit';
import { List, Card, CardContent, Typography, Box, useTheme, useMediaQuery, Button, CircularProgress, IconButton } from '@mui/material';
import { DataGrid, GridColDef, GridRenderCellParams, GridRowModel, GridToolbarContainer, GridToolbarQuickFilter, GridToolbarExport, GridToolbarDensitySelector, GridToolbarColumnsButton } from '@mui/x-data-grid';
import EmailDomain from '@/types/emailDomain';
import EmailDomainEdit from "@/components/modals/EmailDomainEdit";
function EmailDomains() {
const theme = useTheme();
const setupData: SetupData = useSetupData();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const gridContainerRef = useRef<HTMLDivElement | null>(null);
const [selectedRow, setSelectedRow] = useState<EmailDomain | null>(null);
const [open, setOpen] = useState<boolean>(false);
const columns: GridColDef<EmailDomain>[] = [
{
field: "actions",
headerName: "Actions",
sortable: false,
renderCell: (params: GridRenderCellParams<EmailDomain>) => (
<Button variant="contained" color="primary" size="small" onClick={() => handleEdit(params.row)}>
Edit
</Button>
),
},
{ field: "id", headerName: "ID", width: 60 },
{ field: "name", headerName: "Name", flex: 1, minWidth: 160 },
{ field: "emailAddress", headerName: "Email Address", flex: 1, minWidth: 200 },
{ field: "username", headerName: "Username", flex: 1, minWidth: 150 },
{ field: "password", headerName: "Password", flex: 1, minWidth: 150 },
{ field: "displayOrder", headerName: "Display Order", width: 120 },
{ field: "isActive", headerName: "Active", width: 75 },
];
const handleNew = () => {
setSelectedRow(null);
setOpen(true);
};
const handleEdit = (row: GridRowModel<EmailDomain>) => {
setSelectedRow(row);
setOpen(true);
};
const handleUpdateRow = (updatedRow: EmailDomain) => {
setupData.setEmailDomains(updatedRow);
};
return (
<Box ref={gridContainerRef} sx={{
position: 'relative', left: 0, right: 0, height: "calc(100vh - 124px)", overflow: "hidden",
transition: theme.transitions.create(['width', 'height'], {
easing: theme.transitions.easing.easeInOut,
duration: theme.transitions.duration.standard,
})
}}>
<Box sx={{ position: 'absolute', inset: 0 }}>
{isMobile ? (
<List>
{setupData.emailDomains.map((row) => (
<Card key={row.id} sx={{ marginBottom: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<CardContent>
<Typography variant="h6">{row.name}</Typography>
<Typography variant="body2">ID: {row.id}</Typography>
<Typography variant="body2">Email: {row.emailAddress}</Typography>
<Typography variant="body2">Username: {row.username}</Typography>
<Typography variant="body2">Password: {row.password}</Typography>
<Typography variant="body2">Display Order: {row.displayOrder}</Typography>
<Typography variant="body2">Active: {row.isActive ? "Yes" : "No"}</Typography>
</CardContent>
<IconButton onClick={(e) => { e.stopPropagation(); handleEdit(row); }}>
<EditIcon />
</IconButton>
</Box>
</Card>
))}
</List>
) : (
<DataGrid
rows={setupData.emailDomains}
columns={columns}
autoPageSize
sx={{ minWidth: "600px" }}
slots={{
toolbar: () => (
<GridToolbarContainer sx={{ display: "flex", alignItems: "center" }}>
<Button
variant="contained"
color="primary"
onClick={() => handleNew()}
sx={{ marginRight: 2 }}
>
{setupData.emailDomainsLoading ? <CircularProgress size={24} color="inherit" /> : "Add New"}
</Button>
<Button
variant="contained"
color="primary"
onClick={() => setupData.reloadEmailDomains()}
sx={{ marginRight: 2 }}
>
{setupData.emailDomainsLoading ? <CircularProgress size={24} color="inherit" /> : "Refresh"}
</Button>
<GridToolbarColumnsButton />
<GridToolbarDensitySelector />
<GridToolbarExport />
<GridToolbarQuickFilter sx={{ ml: "auto" }} />
</GridToolbarContainer>
),
}}
slotProps={{
toolbar: {
showQuickFilter: true,
},
}}
initialState={{
pagination: {
paginationModel: {
pageSize: 20,
},
},
}}
pageSizeOptions={[10, 20, 50, 100]}
/>
)}
</Box>
{open && (
<EmailDomainEdit
open={open}
emailDomain={selectedRow}
onClose={() => setOpen(false)}
onSave={handleUpdateRow}
/>
)}
</Box>
);
}
export default EmailDomains;

View File

@ -1,6 +1,7 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef } from 'react';
import { useSetupData, SetupData } from "@/context/SetupDataContext"; import { useSetupData, SetupData } from "@/context/SetupDataContext";
//import Typography from '@mui/material/Typography'; //import Typography from '@mui/material/Typography';
import EditIcon from '@mui/icons-material/Edit';
import { List, Card, CardContent, Typography, Box, useTheme, useMediaQuery, Button, CircularProgress, IconButton } from '@mui/material'; import { List, Card, CardContent, Typography, Box, useTheme, useMediaQuery, Button, CircularProgress, IconButton } from '@mui/material';
import { DataGrid, GridColDef, GridRenderCellParams, GridRowModel, GridToolbarContainer, GridToolbarQuickFilter, GridToolbarExport, GridToolbarDensitySelector, GridToolbarColumnsButton } from '@mui/x-data-grid'; import { DataGrid, GridColDef, GridRenderCellParams, GridRowModel, GridToolbarContainer, GridToolbarQuickFilter, GridToolbarExport, GridToolbarDensitySelector, GridToolbarColumnsButton } from '@mui/x-data-grid';
import { Lock, LockOpen } from "@mui/icons-material"; import { Lock, LockOpen } from "@mui/icons-material";
@ -51,7 +52,7 @@ function Servers() {
}, },
{ field: "id", headerName: "ID", width: 60 }, { field: "id", headerName: "ID", width: 60 },
{ field: "name", headerName: "Name", flex: 1, minWidth: 160 }, { field: "name", headerName: "Name", flex: 1, minWidth: 160 },
{ field: "serverName", headerName: "Database", flex: 1, minWidth: 160 }, { field: "serverName", headerName: "Server", flex: 1, minWidth: 160 },
{ field: "port", headerName: "Port", width: 75 }, { field: "port", headerName: "Port", width: 75 },
{ field: "username", headerName: "Username", flex: 1, minWidth: 100 }, { field: "username", headerName: "Username", flex: 1, minWidth: 100 },
{ {
@ -98,16 +99,24 @@ function Servers() {
<Box sx={{ position: 'absolute', inset: 0 }}> <Box sx={{ position: 'absolute', inset: 0 }}>
{isMobile ? ( {isMobile ? (
<List> <List>
{ (!isPasswordVisible ? setupData.servers : servers)?.map((row) => ( {(!isPasswordVisible ? setupData.servers : servers)?.map((row) => (
<Card key={row.id} sx={{ marginBottom: 2 }}> <Card key={row.id} sx={{ marginBottom: 2 }}>
<CardContent> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6">{row.name}</Typography> <CardContent>
<Typography variant="body2">Name: {row.name}</Typography> <Typography variant="h6">{row.name}</Typography>
<Typography variant="body2">Server Name: {row.serverName}</Typography> <Typography variant="body2">ID: {row.id}</Typography>
<Typography variant="body2">Port: {row.port}</Typography> <Typography variant="body2">Server Name: {row.serverName}</Typography>
<Typography variant="body2">Username: {row.username}</Typography> <Typography variant="body2">Port: {row.port}</Typography>
<Typography variant="body2">Password: {row.password}</Typography> <Typography variant="body2">Username: {row.username}</Typography>
</CardContent> <Typography variant="body2">Password
<IconButton size="small" onClick={togglePasswordVisibility} sx={{ marginLeft: 1 }}>
{isPasswordVisible ? <LockOpen /> : <Lock />}
</IconButton>: {row.password}</Typography>
</CardContent>
<IconButton onClick={(e) => { e.stopPropagation(); handleEdit(row); }}>
<EditIcon />
</IconButton>
</Box>
</Card> </Card>
))} ))}
</List> </List>

View File

@ -1,9 +1,8 @@
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
import { useSetupData, SetupData } from "@/context/SetupDataContext"; import { useSetupData, SetupData } from "@/context/SetupDataContext";
//import Typography from '@mui/material/Typography'; import EditIcon from '@mui/icons-material/Edit';
import { List, Card, CardContent, Typography, Box, useTheme, useMediaQuery, Button, CircularProgress } from '@mui/material'; import { List, Card, CardContent, Typography, Box, useTheme, useMediaQuery, Button, CircularProgress, IconButton } from '@mui/material';
import { DataGrid, GridColDef, GridRenderCellParams, GridRowModel, GridToolbarContainer, GridToolbarQuickFilter, GridToolbarExport, GridToolbarDensitySelector, GridToolbarColumnsButton } from '@mui/x-data-grid'; import { DataGrid, GridColDef, GridRenderCellParams, GridRowModel, GridToolbarContainer, GridToolbarQuickFilter, GridToolbarExport, GridToolbarDensitySelector, GridToolbarColumnsButton } from '@mui/x-data-grid';
//import utils from '@/ts/utils';
import Target from '@/types/target'; import Target from '@/types/target';
import TargetEdit from "@/components/modals/TargetEdit"; import TargetEdit from "@/components/modals/TargetEdit";
@ -92,16 +91,21 @@ function Targets() {
<List> <List>
{setupData.targets.map((row) => ( {setupData.targets.map((row) => (
<Card key={row.id} sx={{ marginBottom: 2 }}> <Card key={row.id} sx={{ marginBottom: 2 }}>
<CardContent> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6">{row.name}</Typography> <CardContent>
<Typography variant="body2">Target Key: {row.id}</Typography> <Typography variant="h6">{row.name}</Typography>
{/*<Typography variant="body2">Server Key: {row.serverKey}</Typography>*/} <Typography variant="body2">ID: {row.id}</Typography>
<Typography variant="body2">Database: {row.databaseName}</Typography> {/*<Typography variant="body2">Server Key: {row.serverKey}</Typography>*/}
<Typography variant="body2">View: {row.viewName}</Typography> <Typography variant="body2">Database: {row.databaseName}</Typography>
<Typography variant="body2">Filter: {row.filterQuery}</Typography> <Typography variant="body2">View: {row.viewName}</Typography>
<Typography variant="body2">Writeback: {row.allowWriteBack ? "Yes" : "No"}</Typography> <Typography variant="body2">Filter: {row.filterQuery}</Typography>
<Typography variant="body2">Active: {row.isActive ? "Yes" : "No"}</Typography> <Typography variant="body2">Writeback: {row.allowWriteBack ? "Yes" : "No"}</Typography>
</CardContent> <Typography variant="body2">Active: {row.isActive ? "Yes" : "No"}</Typography>
</CardContent>
<IconButton onClick={(e) => { e.stopPropagation(); handleEdit(row); }}>
<EditIcon />
</IconButton>
</Box>
</Card> </Card>
))} ))}
</List> </List>

View File

@ -1,63 +1,139 @@
import React from 'react'; import { useState, useRef } from 'react';
import { useSetupData, SetupData } from "@/context/SetupDataContext";
import EditIcon from '@mui/icons-material/Edit';
import { List, Card, CardContent, Typography, Box, useTheme, useMediaQuery, Button, CircularProgress, IconButton } from '@mui/material';
import { DataGrid, GridColDef, GridRenderCellParams, GridRowModel, GridToolbarContainer, GridToolbarQuickFilter, GridToolbarExport, GridToolbarDensitySelector, GridToolbarColumnsButton } from '@mui/x-data-grid';
import Template from '@/types/template';
import TemplateEdit from "@/components/modals/TemplateEdit";
function Templates() {
const theme = useTheme();
const setupData: SetupData = useSetupData();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const gridContainerRef = useRef<HTMLDivElement | null>(null);
const [selectedRow, setSelectedRow] = useState<Template | null>(null);
const [open, setOpen] = useState<boolean>(false);
const columns: GridColDef<Template>[] = [
{
field: "actions",
headerName: "Actions",
sortable: false,
renderCell: (params: GridRenderCellParams<Template>) => (
<Button variant="contained" color="primary" size="small" onClick={() => handleEdit(params.row)}>
Edit
</Button>
),
},
{ field: "id", headerName: "ID", width: 60 },
{ field: "name", headerName: "Name", flex: 1, minWidth: 160 },
{ field: "domainId", headerName: "Domain ID", width: 100 },
{ field: "description", headerName: "Description", flex: 1, minWidth: 200 },
{ field: "isActive", headerName: "Active", width: 75 },
];
const handleNew = () => {
setSelectedRow(null);
setOpen(true);
};
const handleEdit = (row: GridRowModel<Template>) => {
setSelectedRow(row);
setOpen(true);
};
const handleUpdateRow = (updatedRow: Template) => {
setupData.setTemplates(updatedRow);
};
const Templates: React.FC = () => {
return ( return (
<div> <Box ref={gridContainerRef} sx={{
{/* Content Header */} position: 'relative', left: 0, right: 0, height: "calc(100vh - 124px)", overflow: "hidden",
<div className="app-content-header"> transition: theme.transitions.create(['width', 'height'], {
<div className="container-fluid"> easing: theme.transitions.easing.easeInOut,
<div className="row"> duration: theme.transitions.duration.standard,
<div className="col-sm-6"> })
<h3 className="mb-0">Templates</h3> }}>
</div> <Box sx={{ position: 'absolute', inset: 0 }}>
<div className="col-sm-6"> {isMobile ? (
<ol className="breadcrumb float-sm-end"> <List>
<li className="breadcrumb-item"> {setupData.templates.map((row) => (
<a href="./home">Home</a> <Card key={row.id} sx={{ marginBottom: 2 }}>
</li> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<li className="breadcrumb-item active" aria-current="page"> <CardContent>
Templates <Typography variant="h6">{row.name}</Typography>
</li> <Typography variant="body2">ID: {row.id}</Typography>
</ol> <Typography variant="body2">Domain ID: {row.domainId}</Typography>
</div> <Typography variant="body2">Description: {row.description}</Typography>
</div> <Typography variant="body2">Active: {row.isActive ? "Yes" : "No"}</Typography>
</div> </CardContent>
</div> <IconButton onClick={(e) => { e.stopPropagation(); handleEdit(row); }}>
<EditIcon />
</IconButton>
</Box>
</Card>
))}
</List>
) : (
<DataGrid
rows={setupData.templates}
columns={columns}
autoPageSize
sx={{ minWidth: "600px" }}
slots={{
toolbar: () => (
<GridToolbarContainer sx={{ display: "flex", alignItems: "center" }}>
<Button
variant="contained"
color="primary"
onClick={() => handleNew()}
sx={{ marginRight: 2 }}
>
{setupData.templatesLoading ? <CircularProgress size={24} color="inherit" /> : "Add New"}
</Button>
<Button
variant="contained"
color="primary"
onClick={() => setupData.reloadTemplates()}
sx={{ marginRight: 2 }}
>
{setupData.templatesLoading ? <CircularProgress size={24} color="inherit" /> : "Refresh"}
</Button>
<GridToolbarColumnsButton />
<GridToolbarDensitySelector />
<GridToolbarExport />
<GridToolbarQuickFilter sx={{ ml: "auto" }} />
</GridToolbarContainer>
),
}}
slotProps={{
toolbar: {
showQuickFilter: true,
},
}}
initialState={{
pagination: {
paginationModel: {
pageSize: 20,
},
},
}}
pageSizeOptions={[10, 20, 50, 100]}
/>
)}
</Box>
{/* Page-specific Content */} {open && (
<div className="app-content"> <TemplateEdit
<div className="container-fluid"> open={open}
<div className="row"> template={selectedRow}
{/* Example: Small Box Widget 1 */} onClose={() => setOpen(false)}
<div className="col-lg-3 col-6"> onSave={handleUpdateRow}
<div className="small-box text-bg-primary"> />
<div className="inner"> )}
<h3>150</h3> </Box>
<p>Templates</p>
</div>
<svg
className="small-box-icon"
fill="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M2.25 2.25a.75.75 0 000 1.5h1.386c.17 0 .318.114.362.278l2.558 9.592a3.752 3.752 0 00-2.806 3.63c0 .414.336.75.75.75h15.75a.75.75 0 000-1.5H5.378A2.25 2.25 0 017.5 15h11.218a.75.75 0 00.674-.421 60.358 60.358 0 002.96-7.228.75.75 0 00-.525-.965A60.864 60.864 0 005.68 4.509l-.232-.867A1.875 1.875 0 003.636 2.25H2.25zM3.75 20.25a1.5 1.5 0 113 0 1.5 1.5 0 01-3 0zM16.5 20.25a1.5 1.5 0 113 0 1.5 1.5 0 01-3 0z"></path>
</svg>
<a
href="#"
className="small-box-footer link-light link-underline-opacity-0 link-underline-opacity-50-hover"
>
More info <i className="bi bi-link-45deg"></i>
</a>
</div>
</div>
{/* Additional small boxes, charts, or other widgets can be included here */}
</div>
{/* You can also add more rows for charts, direct chat, etc. */}
</div>
</div>
</div>
); );
}; }
export default Templates; export default Templates;

View File

@ -0,0 +1,141 @@
import { useState, useRef } from 'react';
import { useSetupData, SetupData } from "@/context/SetupDataContext";
import EditIcon from '@mui/icons-material/Edit';
import { List, Card, CardContent, Typography, Box, useTheme, useMediaQuery, Button, CircularProgress, IconButton } from '@mui/material';
import { DataGrid, GridColDef, GridRenderCellParams, GridRowModel, GridToolbarContainer, GridToolbarQuickFilter, GridToolbarExport, GridToolbarDensitySelector, GridToolbarColumnsButton } from '@mui/x-data-grid';
import TestEmailList from '@/types/testEmailList';
import TestEmailListEdit from "@/components/modals/TestEmailListEdit";
function TestEmailLists() {
const theme = useTheme();
const setupData: SetupData = useSetupData();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const gridContainerRef = useRef<HTMLDivElement | null>(null);
const [selectedRow, setSelectedRow] = useState<TestEmailList | null>(null);
const [open, setOpen] = useState<boolean>(false);
const columns: GridColDef<TestEmailList>[] = [
{
field: "actions",
headerName: "Actions",
sortable: false,
renderCell: (params: GridRenderCellParams<TestEmailList>) => (
<Button variant="contained" color="primary" size="small" onClick={() => handleEdit(params.row)}>
Edit
</Button>
),
},
{ field: "id", headerName: "ID", width: 60 },
{ field: "name", headerName: "Name", flex: 1, minWidth: 160 },
{
field: "emails",
headerName: "Email List",
flex: 1,
minWidth: 300,
valueGetter: (emails: string[]) => emails.join(", "),
},
];
const handleNew = () => {
setSelectedRow(null);
setOpen(true);
};
const handleEdit = (row: GridRowModel<TestEmailList>) => {
setSelectedRow(row);
setOpen(true);
};
const handleUpdateRow = (updatedRow: TestEmailList) => {
setupData.setTestEmailLists(updatedRow);
};
return (
<Box ref={gridContainerRef} sx={{
position: 'relative', left: 0, right: 0, height: "calc(100vh - 124px)", overflow: "hidden",
transition: theme.transitions.create(['width', 'height'], {
easing: theme.transitions.easing.easeInOut,
duration: theme.transitions.duration.standard,
})
}}>
<Box sx={{ position: 'absolute', inset: 0 }}>
{isMobile ? (
<List>
{setupData.testEmailLists.map((row) => (
<Card key={row.id} sx={{ marginBottom: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<CardContent>
<Typography variant="h6">{row.name}</Typography>
<Typography variant="body2">ID: {row.id}</Typography>
<Typography variant="body2">Email List: {row.emails}</Typography> {/*TODO: Format properly*/}
</CardContent>
<IconButton onClick={(e) => { e.stopPropagation(); handleEdit(row); }}>
<EditIcon />
</IconButton>
</Box>
</Card>
))}
</List>
) : (
<DataGrid
rows={setupData.testEmailLists}
columns={columns}
autoPageSize
sx={{ minWidth: "600px" }}
slots={{
toolbar: () => (
<GridToolbarContainer sx={{ display: "flex", alignItems: "center" }}>
<Button
variant="contained"
color="primary"
onClick={() => handleNew()}
sx={{ marginRight: 2 }}
>
{setupData.testEmailListsLoading ? <CircularProgress size={24} color="inherit" /> : "Add New"}
</Button>
<Button
variant="contained"
color="primary"
onClick={() => setupData.reloadTestEmailLists()}
sx={{ marginRight: 2 }}
>
{setupData.testEmailListsLoading ? <CircularProgress size={24} color="inherit" /> : "Refresh"}
</Button>
<GridToolbarColumnsButton />
<GridToolbarDensitySelector />
<GridToolbarExport />
<GridToolbarQuickFilter sx={{ ml: "auto" }} />
</GridToolbarContainer>
),
}}
slotProps={{
toolbar: {
showQuickFilter: true,
},
}}
initialState={{
pagination: {
paginationModel: {
pageSize: 20,
},
},
}}
pageSizeOptions={[10, 20, 50, 100]}
/>
)}
</Box>
{open && (
<TestEmailListEdit
open={open}
testEmailList={selectedRow}
onClose={() => setOpen(false)}
onSave={handleUpdateRow}
/>
)}
</Box>
);
}
export default TestEmailLists;

View File

@ -0,0 +1,134 @@
import { useState, useRef } from 'react';
import { useSetupData, SetupData } from "@/context/SetupDataContext";
import EditIcon from '@mui/icons-material/Edit';
import { List, Card, CardContent, Typography, Box, useTheme, useMediaQuery, Button, CircularProgress, IconButton } from '@mui/material';
import { DataGrid, GridColDef, GridRenderCellParams, GridRowModel, GridToolbarContainer, GridToolbarQuickFilter, GridToolbarExport, GridToolbarDensitySelector, GridToolbarColumnsButton } from '@mui/x-data-grid';
import UnsubscribeUrl from '@/types/unsubscribeUrl';
import UnsubscribeUrlEdit from "@/components/modals/UnsubscribeUrlEdit";
function UnsubscribeUrls() {
const theme = useTheme();
const setupData: SetupData = useSetupData();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const gridContainerRef = useRef<HTMLDivElement | null>(null);
const [selectedRow, setSelectedRow] = useState<UnsubscribeUrl | null>(null);
const [open, setOpen] = useState<boolean>(false);
const columns: GridColDef<UnsubscribeUrl>[] = [
{
field: "actions",
headerName: "Actions",
sortable: false,
renderCell: (params: GridRenderCellParams<UnsubscribeUrl>) => (
<Button variant="contained" color="primary" size="small" onClick={() => handleEdit(params.row)}>
Edit
</Button>
),
},
{ field: "id", headerName: "ID", width: 60 },
{ field: "name", headerName: "Name", flex: 1, minWidth: 160 },
{ field: "url", headerName: "URL", flex: 1, minWidth: 300 },
];
const handleNew = () => {
setSelectedRow(null);
setOpen(true);
};
const handleEdit = (row: GridRowModel<UnsubscribeUrl>) => {
setSelectedRow(row);
setOpen(true);
};
const handleUpdateRow = (updatedRow: UnsubscribeUrl) => {
setupData.setUnsubscribeUrls(updatedRow);
};
return (
<Box ref={gridContainerRef} sx={{
position: 'relative', left: 0, right: 0, height: "calc(100vh - 124px)", overflow: "hidden",
transition: theme.transitions.create(['width', 'height'], {
easing: theme.transitions.easing.easeInOut,
duration: theme.transitions.duration.standard,
})
}}>
<Box sx={{ position: 'absolute', inset: 0 }}>
{isMobile ? (
<List>
{setupData.unsubscribeUrls.map((row) => (
<Card key={row.id} sx={{ marginBottom: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<CardContent>
<Typography variant="h6">{row.name}</Typography>
<Typography variant="body2">ID: {row.id}</Typography>
<Typography variant="body2">URL: {row.url}</Typography>
</CardContent>
<IconButton onClick={(e) => { e.stopPropagation(); handleEdit(row); }}>
<EditIcon />
</IconButton>
</Box>
</Card>
))}
</List>
) : (
<DataGrid
rows={setupData.unsubscribeUrls}
columns={columns}
autoPageSize
sx={{ minWidth: "600px" }}
slots={{
toolbar: () => (
<GridToolbarContainer sx={{ display: "flex", alignItems: "center" }}>
<Button
variant="contained"
color="primary"
onClick={() => handleNew()}
sx={{ marginRight: 2 }}
>
{setupData.unsubscribeUrlsLoading ? <CircularProgress size={24} color="inherit" /> : "Add New"}
</Button>
<Button
variant="contained"
color="primary"
onClick={() => setupData.reloadUnsubscribeUrls()}
sx={{ marginRight: 2 }}
>
{setupData.unsubscribeUrlsLoading ? <CircularProgress size={24} color="inherit" /> : "Refresh"}
</Button>
<GridToolbarColumnsButton />
<GridToolbarDensitySelector />
<GridToolbarExport />
<GridToolbarQuickFilter sx={{ ml: "auto" }} />
</GridToolbarContainer>
),
}}
slotProps={{
toolbar: {
showQuickFilter: true,
},
}}
initialState={{
pagination: {
paginationModel: {
pageSize: 20,
},
},
}}
pageSizeOptions={[10, 20, 50, 100]}
/>
)}
</Box>
{open && (
<UnsubscribeUrlEdit
open={open}
unsubscribeUrl={selectedRow}
onClose={() => setOpen(false)}
onSave={handleUpdateRow}
/>
)}
</Box>
);
}
export default UnsubscribeUrls;

View File

@ -1,27 +1,77 @@
import { createContext, useState, useEffect, useContext } from "react"; import { createContext, useState, useEffect, useContext } from "react";
import { Target } from "@/types/target"; import Target from "@/types/target";
import { Server } from "@/types/server"; import Server from "@/types/server";
import TestEmailList from '@/types/testEmailList';
import BouncedEmail from '@/types/bouncedEmail';
import UnsubscribeUrl from '@/types/unsubscribeUrl';
import Template from '@/types/template';
import EmailDomain from '@/types/emailDomain';
export type SetupData = { export type SetupData = {
targets: Target[]; targets: Target[];
reloadTargets: () => void; reloadTargets: () => void;
setTargets: (updatedTarget: Target) => void; setTargets: (updatedTarget: Target) => void;
targetsLoading: boolean;
servers: Server[]; servers: Server[];
reloadServers: () => void; reloadServers: () => void;
setServers: (updatedServer: Server) => void; setServers: (updatedServer: Server) => void;
reloadSetupData: () => void;
targetsLoading: boolean;
serversLoading: boolean; serversLoading: boolean;
testEmailLists: TestEmailList[];
reloadTestEmailLists: () => void;
setTestEmailLists: (testEmailList: TestEmailList) => void;
testEmailListsLoading: boolean;
bouncedEmails: BouncedEmail[];
reloadBouncedEmails: () => void;
setBouncedEmails: (emailAddress: string, updatedBouncedEmail: BouncedEmail) => void;
bouncedEmailsLoading: boolean;
removeBouncedEmailFromCache: (emailAddress: string) => void;
unsubscribeUrls: UnsubscribeUrl[];
reloadUnsubscribeUrls: () => void;
setUnsubscribeUrls: (updatedUnsubscribeUrl: UnsubscribeUrl) => void;
unsubscribeUrlsLoading: boolean;
templates: Template[];
reloadTemplates: () => void;
setTemplates: (updatedTemplate: Template) => void;
templatesLoading: boolean;
emailDomains: EmailDomain[];
reloadEmailDomains: () => void;
setEmailDomains: (updatedEmailDomain: EmailDomain) => void;
emailDomainsLoading: boolean;
reloadSetupData: () => void;
dataLoading: boolean; dataLoading: boolean;
}; };
const SetupDataContext = createContext<SetupData | undefined>(undefined); const SetupDataContext = createContext<SetupData | undefined>(undefined);
export const SetupDataProvider = ({ children }: { children: React.ReactNode }) => { export const SetupDataProvider = ({ children }: { children: React.ReactNode }) => {
const [servers, setServers] = useState<Server[]>([]);
const [targets, setTargets] = useState<Target[]>([]); const [targets, setTargets] = useState<Target[]>([]);
const [targetsLoading, setTargetsLoading] = useState<boolean>(false); const [targetsLoading, setTargetsLoading] = useState<boolean>(false);
const [servers, setServers] = useState<Server[]>([]);
const [serversLoading, setServersLoading] = useState<boolean>(false); const [serversLoading, setServersLoading] = useState<boolean>(false);
const [testEmailLists, setTestEmailLists] = useState<TestEmailList[]>([]);
const [testEmailListsLoading, setTestEmailListsLoading] = useState<boolean>(false);
const [bouncedEmails, setBouncedEmails] = useState<BouncedEmail[]>([]);
const [bouncedEmailsLoading, setBouncedEmailsLoading] = useState<boolean>(false);
const [unsubscribeUrls, setUnsubscribeUrls] = useState<UnsubscribeUrl[]>([]);
const [unsubscribeUrlsLoading, setUnsubscribeUrlsLoading] = useState<boolean>(false);
const [templates, setTemplates] = useState<Template[]>([]);
const [templatesLoading, setTemplatesLoading] = useState<boolean>(false);
const [emailDomains, setEmailDomains] = useState<EmailDomain[]>([]);
const [emailDomainsLoading, setEmailDomainsLoading] = useState<boolean>(false);
const [dataLoading, setDataLoading] = useState<boolean>(false); const [dataLoading, setDataLoading] = useState<boolean>(false);
const reloadSetupData = async () => { const reloadSetupData = async () => {
@ -31,33 +81,175 @@ export const SetupDataProvider = ({ children }: { children: React.ReactNode }) =
const fetchSetupData = async () => { const fetchSetupData = async () => {
try { try {
setDataLoading(true); setDataLoading(true);
setTargetsLoading(true);
setServersLoading(true);
const cachedData = sessionStorage.getItem("setupData"); const cachedData = sessionStorage.getItem("setupData");
setTargetsLoading(true);
let targetsData: Target[] | null = null;
let loadTargets = true;
setServersLoading(true);
let serversData: Server[] | null = null;
let loadServers = true;
setTestEmailListsLoading(true);
let testEmailListsData: TestEmailList[] | null = null;
let loadTestEmailLists = true;
setBouncedEmailsLoading(true);
let bouncedEmailsData: BouncedEmail[] | null = null;
let loadBouncedEmails = true;
setUnsubscribeUrlsLoading(true);
let unsubscribeUrlsData: UnsubscribeUrl[] | null = null;
let loadUnsubscribeUrls = true;
setTemplatesLoading(true);
let templatesData: Template[] | null = null;
let loadTemplates = true;
setEmailDomainsLoading(true);
let emailDomainsData: EmailDomain[] | null = null;
let loadEmailDomains = true;
if (cachedData) { //TODO: check if data is stale if (cachedData) { //TODO: check if data is stale
const parsedData = JSON.parse(cachedData); const parsedData = JSON.parse(cachedData);
setTargets(parsedData.targets); if (parsedData.targets) {
setServers(parsedData.servers); loadTargets = false;
setDataLoading(false); setTargets(parsedData.targets);
setTargetsLoading(false); setTargetsLoading(false);
setServersLoading(false); }
return; if (parsedData.servers) {
loadServers = false;
setServers(parsedData.servers);
setServersLoading(false);
}
if (parsedData.testEmailLists) {
loadTestEmailLists = false;
setTestEmailLists(parsedData.testEmailLists);
setTestEmailListsLoading(false);
}
if (parsedData.bouncedEmails) {
loadBouncedEmails = false;
setBouncedEmails(parsedData.bouncedEmails);
setBouncedEmailsLoading(false);
}
if (parsedData.unsubscribeUrls) {
loadUnsubscribeUrls = false;
setUnsubscribeUrls(parsedData.unsubscribeUrls);
setUnsubscribeUrlsLoading(false);
}
if (parsedData.templates) {
loadTemplates = false;
setTemplates(parsedData.templates);
setTemplatesLoading(false);
}
if (parsedData.emailDomains) {
loadEmailDomains = false;
setEmailDomains(parsedData.emailDomains);
setEmailDomainsLoading(false);
}
}
if (loadTargets) {
const targetsResponse = await fetch("/api/targets/GetAll?activeOnly=false");
targetsData = await targetsResponse.json();
if (targetsData) {
setTargets(targetsData);
setTargetsLoading(false);
}
else {
console.error("Failed to fetch targets");
setTargetsLoading(false);
}
}
if (loadServers) {
const serversResponse = await fetch("/api/servers/GetAll?activeOnly=false&returnPassword=false");
serversData = await serversResponse.json();
if (serversData) {
setServers(serversData);
setServersLoading(false);
}
else {
console.error("Failed to fetch servers");
setServersLoading(false);
}
}
if (loadTestEmailLists) {
const testEmailListsResponse = await fetch("/api/testEmailLists/GetAll?activeOnly=false");
testEmailListsData = await testEmailListsResponse.json();
if (testEmailListsData) {
setTestEmailLists(testEmailListsData);
setTestEmailListsLoading(false);
}
else {
console.error("Failed to load test email lists");
setTestEmailListsLoading(false);
}
}
if (loadBouncedEmails) {
const bouncedEmailsResponse = await fetch("/api/bouncedEmails/GetAll?activeOnly=false");
bouncedEmailsData = await bouncedEmailsResponse.json();
if (bouncedEmailsData) {
setBouncedEmails(bouncedEmailsData);
setBouncedEmailsLoading(false);
}
else {
console.error("Failed to fetch bounced emails");
setBouncedEmailsLoading(false);
}
}
if (loadUnsubscribeUrls) {
const unsubscribeUrlsResponse = await fetch("/api/unsubscribeUrls/GetAll?activeOnly=false");
unsubscribeUrlsData = await unsubscribeUrlsResponse.json();
if (unsubscribeUrlsData) {
setUnsubscribeUrls(unsubscribeUrlsData);
setUnsubscribeUrlsLoading(false);
}
else {
console.error("Failed to fetch unsubscribeUrls");
setUnsubscribeUrlsLoading(false);
}
}
if (loadTemplates) {
const templatesResponse = await fetch("/api/templates/GetAll?activeOnly=false");
templatesData = await templatesResponse.json();
if (templatesData) {
setTemplates(templatesData);
setTemplatesLoading(false);
}
else {
console.error("Failed to fetch templates");
setTemplatesLoading(false);
}
if (loadEmailDomains) {
const emailDomainsResponse = await fetch("/api/emailDomains/GetAll?activeOnly=false&returnPassword=false");
emailDomainsData = await emailDomainsResponse.json();
if (emailDomainsData) {
setEmailDomains(emailDomainsData);
setEmailDomainsLoading(false);
}
else {
console.error("Failed to fetch emailDomains");
setEmailDomainsLoading(false);
}
}
} }
const targetsResponse = await fetch("/api/targets/GetAll?activeOnly=false");
const targetsData = await targetsResponse.json();
setTargets(targetsData);
setTargetsLoading(false);
const serversResponse = await fetch("/api/servers/GetAll?activeOnly=false&returnPassword=false");
const serversData = await serversResponse.json();
setServers(serversData);
setServersLoading(false);
setDataLoading(false); setDataLoading(false);
sessionStorage.setItem("setupData", JSON.stringify({ targets: targetsData, servers: serversData })); sessionStorage.setItem("setupData", JSON.stringify({ targets: targetsData, servers: serversData, testEmailLists: testEmailListsData, bouncedEmails: bouncedEmailsData, unsubscribeUrls: unsubscribeUrlsData, templates: templatesData, emailDomains: emailDomainsData }));
} catch (error) { } catch (error) {
setDataLoading(false); setDataLoading(false);
setTargetsLoading(false); setTargetsLoading(false);
setServersLoading(false); setServersLoading(false);
setTestEmailListsLoading(false);
setBouncedEmailsLoading(false);
setTemplatesLoading(false);
setEmailDomainsLoading(false);
console.error("Failed to fetch setup data:", error); console.error("Failed to fetch setup data:", error);
} }
}; };
@ -70,22 +262,127 @@ export const SetupDataProvider = ({ children }: { children: React.ReactNode }) =
: [...prevTargets, updatedTarget]; // Push new target if not found : [...prevTargets, updatedTarget]; // Push new target if not found
}); });
sessionStorage.setItem("setupData", JSON.stringify({ servers, targets })); sessionStorage.setItem("setupData", JSON.stringify({ servers, targets, testEmailLists, bouncedEmails }));
}; };
const updateServerCache = (updatedServer: Server) => { const updateServerCache = (updatedServer: Server) => {
updatedServer.password = ""; setServers((prevServers) => {
setServers((prevServers) => const serverExists = prevServers.some((server) => server.id === updatedServer.id);
prevServers.map((server) => (server.id === updatedServer.id ? updatedServer : server)) return serverExists
); ? prevServers.map((server) => (server.id === updatedServer.id ? updatedServer : server))
sessionStorage.setItem("setupData", JSON.stringify({ servers, targets })); : [...prevServers, updatedServer]; // Push new server if not found
});
sessionStorage.setItem("setupData", JSON.stringify({ servers, targets, testEmailLists, bouncedEmails }));
}; };
const updateTestEmailListCache = (updatedTestEmailList: TestEmailList) => {
setTestEmailLists((prevTestEmailLists) => {
const testEmailListExists = prevTestEmailLists.some((testEmailList) => testEmailList.id === updatedTestEmailList.id);
return testEmailListExists
? prevTestEmailLists.map((testEmailList) => (testEmailList.id === updatedTestEmailList.id ? updatedTestEmailList : testEmailList))
: [...prevTestEmailLists, updatedTestEmailList]; // Push new testEmailList if not found
});
sessionStorage.setItem("setupData", JSON.stringify({ servers, targets, testEmailLists, bouncedEmails }));
};
const updateBouncedEmailCache = (emailAddress: string, updatedBouncedEmail: BouncedEmail) => {
setBouncedEmails((prevBouncedEmails) => {
const bouncedEmailExists = prevBouncedEmails.some((e) => e.emailAddress === emailAddress);
return bouncedEmailExists
? prevBouncedEmails.map((bouncedEmail) => (bouncedEmail.emailAddress === emailAddress ? updatedBouncedEmail : bouncedEmail))
: [...prevBouncedEmails, updatedBouncedEmail]; // Push new bouncedEmail if not found
});
sessionStorage.setItem("setupData", JSON.stringify({ servers, targets, testEmailLists, bouncedEmails }));
};
const removeBouncedEmailFromCache = (emailAddress: string) => {
setBouncedEmails((prevBouncedEmails) => {
return prevBouncedEmails.filter(el => el.emailAddress !== emailAddress);
});
sessionStorage.setItem("setupData", JSON.stringify({ servers, targets, testEmailLists, bouncedEmails }));
};
const updateUnsubscribeUrlCache = (updatedUnsubscribeUrl: UnsubscribeUrl) => {
setUnsubscribeUrls((prevUnsubscribeUrls) => {
const unsubscribeUrlExists = prevUnsubscribeUrls.some((unsubscribeUrl) => unsubscribeUrl.id === updatedUnsubscribeUrl.id);
return unsubscribeUrlExists
? prevUnsubscribeUrls.map((unsubscribeUrl) => (unsubscribeUrl.id === updatedUnsubscribeUrl.id ? updatedUnsubscribeUrl : unsubscribeUrl))
: [...prevUnsubscribeUrls, updatedUnsubscribeUrl]; // Push new unsubscribeUrl if not found
});
sessionStorage.setItem("setupData", JSON.stringify({ unsubscribeUrls, targets, testEmailLists, bouncedEmails }));
};
const updateTemplateCache = (updatedTemplate: Template) => {
setTemplates((prevTemplates) => {
const templateExists = prevTemplates.some((template) => template.id === updatedTemplate.id);
return templateExists
? prevTemplates.map((template) => (template.id === updatedTemplate.id ? updatedTemplate : template))
: [...prevTemplates, updatedTemplate]; // Push new template if not found
});
sessionStorage.setItem("setupData", JSON.stringify({ templates, targets, testEmailLists, bouncedEmails }));
};
const updateEmailDomainCache = (updatedEmailDomain: EmailDomain) => {
setEmailDomains((prevEmailDomains) => {
const emailDomainExists = prevEmailDomains.some((emailDomain) => emailDomain.id === updatedEmailDomain.id);
return emailDomainExists
? prevEmailDomains.map((emailDomain) => (emailDomain.id === updatedEmailDomain.id ? updatedEmailDomain : emailDomain))
: [...prevEmailDomains, updatedEmailDomain]; // Push new emailDomain if not found
});
sessionStorage.setItem("setupData", JSON.stringify({ emailDomains, targets, testEmailLists, bouncedEmails }));
};
useEffect(() => { useEffect(() => {
fetchSetupData(); fetchSetupData();
}, []); }, []);
return ( return (
<SetupDataContext.Provider value={{ targets, reloadTargets: reloadSetupData, setTargets: updateTargetCache, servers, reloadServers: reloadSetupData, setServers: updateServerCache, reloadSetupData: reloadSetupData, targetsLoading, serversLoading, dataLoading }}> <SetupDataContext.Provider value={{
targets,
reloadTargets: reloadSetupData,
setTargets: updateTargetCache,
targetsLoading,
servers,
reloadServers: reloadSetupData,
setServers: updateServerCache,
serversLoading,
testEmailLists,
reloadTestEmailLists: reloadSetupData,
setTestEmailLists: updateTestEmailListCache,
testEmailListsLoading,
bouncedEmails,
reloadBouncedEmails: reloadSetupData,
setBouncedEmails: updateBouncedEmailCache,
bouncedEmailsLoading,
removeBouncedEmailFromCache,
unsubscribeUrls,
reloadUnsubscribeUrls: reloadSetupData,
setUnsubscribeUrls: updateUnsubscribeUrlCache,
unsubscribeUrlsLoading,
templates,
reloadTemplates: reloadSetupData,
setTemplates: updateTemplateCache,
templatesLoading,
emailDomains,
reloadEmailDomains: reloadSetupData,
setEmailDomains: updateEmailDomainCache,
emailDomainsLoading,
reloadSetupData: reloadSetupData,
dataLoading
}}>
{children} {children}
</SetupDataContext.Provider> </SetupDataContext.Provider>
); );

View File

@ -0,0 +1,8 @@
export interface BouncedEmail {
emailAddress: string;
spam: boolean;
unsubscribe: boolean;
enteredByAdmin: boolean;
}
export default BouncedEmail;

View File

@ -0,0 +1,11 @@
export interface EmailDomain {
id?: number;
name: string;
emailAddress: string;
username: string;
password: string;
isActive: boolean;
displayOrder: number;
}
export default EmailDomain;

View File

@ -0,0 +1,18 @@
export interface Template {
id: number;
name: string;
domainId: number;
description: string;
htmlBody: string;
subject: string;
toName: string;
fromName: string;
fromEmail: string;
replyToEmail: string;
clickTracking: boolean;
openTracking: boolean;
categoryXml: string;
isActive: boolean;
}
export default Template;

View File

@ -0,0 +1,5 @@
export default interface TestEmailList{
id: number;
name: string;
emails: string[];
}

View File

@ -0,0 +1,7 @@
export interface UnsubscribeUrl {
id?: number;
name: string;
url: string;
}
export default UnsubscribeUrl;