diff --git a/Surge365.MassEmailReact.API/Controllers/MailingsController.cs b/Surge365.MassEmailReact.API/Controllers/MailingsController.cs index 4da2d21..284de8f 100644 --- a/Surge365.MassEmailReact.API/Controllers/MailingsController.cs +++ b/Surge365.MassEmailReact.API/Controllers/MailingsController.cs @@ -82,6 +82,15 @@ namespace Surge365.MassEmailReact.API.Controllers return template is not null ? Ok(template) : NotFound($"Mailing template with id '{id}' not found."); } + [HttpPost("validate-unsubscribe")] + public async Task ValidateUnsubscribeLink([FromBody] MailingUpdateDto mailingUpdateDto) + { + ArgumentNullException.ThrowIfNull(mailingUpdateDto); + + var validationResult = await _mailingService.ValidateUnsubscribeLinkAsync(mailingUpdateDto); + return Ok(validationResult); + } + [HttpPost] public async Task CreateMailing([FromBody] MailingUpdateDto mailingUpdateDto) { @@ -118,7 +127,7 @@ namespace Surge365.MassEmailReact.API.Controllers if (existingMailing == null) return NotFound($"Mailing with Id {id} not found"); - var success = await _mailingService.UpdateAsync(mailingUpdateDto); + var success = await _mailingService.UpdateAsync(mailingUpdateDto); if (!success) return StatusCode(StatusCodes.Status500InternalServerError, "Failed to update mailing."); diff --git a/Surge365.MassEmailReact.API/Controllers/UnsubscribeListsController.cs b/Surge365.MassEmailReact.API/Controllers/UnsubscribeListsController.cs index 114a00b..9d01722 100644 --- a/Surge365.MassEmailReact.API/Controllers/UnsubscribeListsController.cs +++ b/Surge365.MassEmailReact.API/Controllers/UnsubscribeListsController.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Surge365.Core.Controllers; using Surge365.MassEmailReact.Application.DTOs; using Surge365.MassEmailReact.Application.Interfaces; using System; @@ -7,10 +8,7 @@ using System.Threading.Tasks; namespace Surge365.MassEmailReact.API.Controllers { - [ApiController] - [Route("api/[controller]")] - [Authorize] - public class UnsubscribeListsController : ControllerBase + public class UnsubscribeListsController : BaseController { private readonly IUnsubscribeListService _unsubscribeListService; diff --git a/Surge365.MassEmailReact.Application/DTOs/UnsubscribeListUpdateDto.cs b/Surge365.MassEmailReact.Application/DTOs/UnsubscribeListUpdateDto.cs index eb6b793..36a9b73 100644 --- a/Surge365.MassEmailReact.Application/DTOs/UnsubscribeListUpdateDto.cs +++ b/Surge365.MassEmailReact.Application/DTOs/UnsubscribeListUpdateDto.cs @@ -7,6 +7,7 @@ namespace Surge365.MassEmailReact.Application.DTOs public string UnsubscribeListCode { get; set; } = ""; public string FriendlyName { get; set; } = ""; public string? FriendlyDescription { get; set; } + public string? Url { get; set; } public bool IsActive { get; set; } = true; public short DisplayOrder { get; set; } = 0; } diff --git a/Surge365.MassEmailReact.Application/DTOs/UnsubscribeValidationResult.cs b/Surge365.MassEmailReact.Application/DTOs/UnsubscribeValidationResult.cs new file mode 100644 index 0000000..59c0224 --- /dev/null +++ b/Surge365.MassEmailReact.Application/DTOs/UnsubscribeValidationResult.cs @@ -0,0 +1,21 @@ +using System.Text.Json.Serialization; + +namespace Surge365.MassEmailReact.Application.DTOs +{ + public class UnsubscribeValidationResult + { + public bool IsValid { get; set; } + public string ValidationMessage { get; set; } = ""; + + [JsonConverter(typeof(JsonStringEnumConverter))] + public UnsubscribeValidationType ValidationType { get; set; } + } + + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum UnsubscribeValidationType + { + Valid, + HasUnsubscribeListButNoUrlInTemplate, + HasUrlInTemplateButNoUnsubscribeList + } +} \ No newline at end of file diff --git a/Surge365.MassEmailReact.Application/Interfaces/IMailingService.cs b/Surge365.MassEmailReact.Application/Interfaces/IMailingService.cs index 02a92e3..5cc273e 100644 --- a/Surge365.MassEmailReact.Application/Interfaces/IMailingService.cs +++ b/Surge365.MassEmailReact.Application/Interfaces/IMailingService.cs @@ -23,5 +23,6 @@ namespace Surge365.MassEmailReact.Application.Interfaces Task UpdateAsync(MailingUpdateDto mailingDto); Task CancelMailingAsync(int id); Task TestMailing(MailingUpdateDto mailingUpdateDto, List emails); + Task ValidateUnsubscribeLinkAsync(MailingUpdateDto mailingDto); } } \ No newline at end of file diff --git a/Surge365.MassEmailReact.Application/Interfaces/ITemplateService.cs b/Surge365.MassEmailReact.Application/Interfaces/ITemplateService.cs index 6d8d034..6913e90 100644 --- a/Surge365.MassEmailReact.Application/Interfaces/ITemplateService.cs +++ b/Surge365.MassEmailReact.Application/Interfaces/ITemplateService.cs @@ -1,4 +1,5 @@ -using Surge365.MassEmailReact.Domain.Entities; +using Surge365.MassEmailReact.Application.DTOs; +using Surge365.MassEmailReact.Domain.Entities; using System.Collections.Generic; using System.Threading.Tasks; diff --git a/Surge365.MassEmailReact.Domain/Entities/UnsubscribeList.cs b/Surge365.MassEmailReact.Domain/Entities/UnsubscribeList.cs index 802ccab..ed708ad 100644 --- a/Surge365.MassEmailReact.Domain/Entities/UnsubscribeList.cs +++ b/Surge365.MassEmailReact.Domain/Entities/UnsubscribeList.cs @@ -7,6 +7,7 @@ namespace Surge365.MassEmailReact.Domain.Entities public string UnsubscribeListCode { get; private set; } = ""; public string FriendlyName { get; set; } = ""; public string? FriendlyDescription { get; set; } + public string? Url { get; set; } public bool IsActive { get; set; } = true; public short DisplayOrder { get; set; } = 0; public DateTime CreateDate { get; set; } = DateTime.Now; @@ -15,11 +16,12 @@ namespace Surge365.MassEmailReact.Domain.Entities public UnsubscribeList() { } private UnsubscribeList(string unsubscribeListCode, string friendlyName, string? friendlyDescription, - bool isActive, short displayOrder, DateTime createDate, DateTime updateDate) + string? url, bool isActive, short displayOrder, DateTime createDate, DateTime updateDate) { UnsubscribeListCode = unsubscribeListCode; FriendlyName = friendlyName; FriendlyDescription = friendlyDescription; + Url = url; IsActive = isActive; DisplayOrder = displayOrder; CreateDate = createDate; @@ -27,9 +29,9 @@ namespace Surge365.MassEmailReact.Domain.Entities } public static UnsubscribeList Create(string unsubscribeListCode, string friendlyName, string? friendlyDescription, - bool isActive, short displayOrder, DateTime createDate, DateTime updateDate) + string? url, bool isActive, short displayOrder, DateTime createDate, DateTime updateDate) { - return new UnsubscribeList(unsubscribeListCode, friendlyName, friendlyDescription, isActive, + return new UnsubscribeList(unsubscribeListCode, friendlyName, friendlyDescription, url, isActive, displayOrder, createDate, updateDate); } } diff --git a/Surge365.MassEmailReact.Infrastructure/EntityMaps/UnsubscribeListMap.cs b/Surge365.MassEmailReact.Infrastructure/EntityMaps/UnsubscribeListMap.cs index 7d88c95..12b4ebd 100644 --- a/Surge365.MassEmailReact.Infrastructure/EntityMaps/UnsubscribeListMap.cs +++ b/Surge365.MassEmailReact.Infrastructure/EntityMaps/UnsubscribeListMap.cs @@ -10,6 +10,7 @@ namespace Surge365.MassEmailReact.Infrastructure.EntityMaps Map(u => u.UnsubscribeListCode).ToColumn("unsubscribe_list_code"); Map(u => u.FriendlyName).ToColumn("friendly_name"); Map(u => u.FriendlyDescription).ToColumn("friendly_description"); + Map(u => u.Url).ToColumn("url"); Map(u => u.IsActive).ToColumn("is_active"); Map(u => u.DisplayOrder).ToColumn("display_order"); Map(u => u.CreateDate).ToColumn("create_date"); diff --git a/Surge365.MassEmailReact.Infrastructure/Repositories/UnsubscribeListRepository.cs b/Surge365.MassEmailReact.Infrastructure/Repositories/UnsubscribeListRepository.cs index 4c9e88b..f015690 100644 --- a/Surge365.MassEmailReact.Infrastructure/Repositories/UnsubscribeListRepository.cs +++ b/Surge365.MassEmailReact.Infrastructure/Repositories/UnsubscribeListRepository.cs @@ -79,6 +79,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories new SqlParameter("@unsubscribe_list_code", unsubscribeList.UnsubscribeListCode), new SqlParameter("@friendly_name", unsubscribeList.FriendlyName), new SqlParameter("@friendly_description", unsubscribeList.FriendlyDescription ?? (object)DBNull.Value), + new SqlParameter("@url", unsubscribeList.Url ?? (object)DBNull.Value), new SqlParameter("@is_active", unsubscribeList.IsActive), new SqlParameter("@display_order", unsubscribeList.DisplayOrder), pmSuccess @@ -107,6 +108,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories new SqlParameter("@unsubscribe_list_code", unsubscribeList.UnsubscribeListCode), new SqlParameter("@friendly_name", unsubscribeList.FriendlyName), new SqlParameter("@friendly_description", unsubscribeList.FriendlyDescription ?? (object)DBNull.Value), + new SqlParameter("@url", unsubscribeList.Url ?? (object)DBNull.Value), new SqlParameter("@is_active", unsubscribeList.IsActive), new SqlParameter("@display_order", unsubscribeList.DisplayOrder), pmSuccess diff --git a/Surge365.MassEmailReact.Infrastructure/Services/MailingService.cs b/Surge365.MassEmailReact.Infrastructure/Services/MailingService.cs index 8fecb32..392987c 100644 --- a/Surge365.MassEmailReact.Infrastructure/Services/MailingService.cs +++ b/Surge365.MassEmailReact.Infrastructure/Services/MailingService.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration; using Surge365.MassEmailReact.Application.DTOs; using Surge365.MassEmailReact.Application.Interfaces; using Surge365.MassEmailReact.Domain.Entities; @@ -20,13 +20,14 @@ namespace Surge365.MassEmailReact.Infrastructure.Services private readonly ITargetService _targetService; private readonly ITemplateService _templateService; private readonly IEmailDomainService _emailDomainService; + private readonly IUnsubscribeListService _unsubscribeListService; private readonly IMailingRepository _mailingRepository; private readonly IConfiguration _config; private string DefaultUnsubscribeUrl { get { - return _config["DefaultUnsubscribeUrl"] ?? ""; + return _config["MassEmail_UnsubscribeProcessingUrl"] ?? ""; } } private bool SendGridTestMode @@ -43,13 +44,14 @@ namespace Surge365.MassEmailReact.Infrastructure.Services return _config["RegularExpression_Email"] ?? ""; } } - public MailingService(IHttpClientFactory httpClientFactory, IMailingRepository mailingRepository, ITargetService targetService, ITemplateService templateService, IEmailDomainService emailDomainService, IConfiguration config) + public MailingService(IHttpClientFactory httpClientFactory, IMailingRepository mailingRepository, ITargetService targetService, ITemplateService templateService, IEmailDomainService emailDomainService, IUnsubscribeListService unsubscribeListService, IConfiguration config) { _httpClientFactory = httpClientFactory; _mailingRepository = mailingRepository; _targetService = targetService; _templateService = templateService; _emailDomainService = emailDomainService; + _unsubscribeListService = unsubscribeListService; _config = config; } @@ -177,11 +179,11 @@ namespace Surge365.MassEmailReact.Infrastructure.Services return false; foreach (string email in emails) - await SendTestEmailLocal(template, domain, targetSample, email); + await SendTestEmailLocal(template, domain, targetSample, email, mailing.UnsubscribeListCode); return true; } - private async Task SendTestEmailLocal(Template template, EmailDomain emailDomain, TargetSample targetSample, string emailAddress) + private async Task SendTestEmailLocal(Template template, EmailDomain emailDomain, TargetSample targetSample, string emailAddress, string? unsubscribeListCode = null) { string html = template.HtmlBody; string tokenTemplate = "(?i)##{0}##"; @@ -197,7 +199,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Services toName = r.Replace(toName, targetSample.Rows[0][columnName]); } html = RemoveOpenTag(html); - html = MergeWithUnsubscribe(html, template); + html = await MergeWithUnsubscribeAsync(html, template, unsubscribeListCode, emailAddress, null); return await SendEmailLocal(emailDomain, emailAddress, toName, html, subject, template.FromName, template.FromEmail, template.ReplyToEmail, new List(), null); } @@ -207,27 +209,64 @@ namespace Surge365.MassEmailReact.Infrastructure.Services html = r.Replace(html, ""); return html; } - private string MergeWithUnsubscribe(string html, Template template) + private async Task MergeWithUnsubscribeAsync(string html, Template template, string? unsubscribeListCode = null, string? emailAddress = null, int? blastEmailID = null) { - string url = DefaultUnsubscribeUrl; - //if (template.UnsubscribeUrl != null) - //{ - // url = template.UnsubscribeUrl.Url; - //} - //else if (blast.BlastTemplate != null && blast.BlastTemplate.BlastUnsubscribeUrl != null) - //{ - // url = blast.BlastTemplate.BlastUnsubscribeUrl.Url; - //} - //else if (blast.BlastTemplate != null && blast.BlastTemplate.UnsubscribeUrl != null) - //{ - // url = blast.BlastTemplate.UnsubscribeUrl.Url; - //} - //else - //{ - // url = Utilities.DefaultUnsubscribeUrl; - //} - Regex r = new Regex("(?i)##unsubscribeurl##", RegexOptions.IgnoreCase); - html = r.Replace(html, url); + string baseUrl = ""; // Default to empty, will use default only if list found without URL. + + // If unsubscribe list is provided, try to get its URL + if (!string.IsNullOrWhiteSpace(unsubscribeListCode)) + { + var unsubscribeList = await _unsubscribeListService.GetByCodeAsync(unsubscribeListCode); + if (unsubscribeList != null && !string.IsNullOrWhiteSpace(unsubscribeList.Url)) + { + baseUrl = unsubscribeList.Url; + } + else if(unsubscribeList != null) + { + baseUrl = DefaultUnsubscribeUrl; + } + } + + // Format the URL with the required parameters: {baseUrl}?emailid={blastEmailID ?? 0}&emailaddress={emailAddress} + string url = ""; + if (!string.IsNullOrWhiteSpace(baseUrl)) + { + var uriBuilder = new UriBuilder(baseUrl); + var query = System.Web.HttpUtility.ParseQueryString(uriBuilder.Query); + + query["emailid"] = (blastEmailID ?? 0).ToString(); + query["emailaddress"] = emailAddress ?? ""; + + uriBuilder.Query = query.ToString(); + url = uriBuilder.ToString(); + } + + var unsubscribeRegex = new Regex("(?i)##unsubscribe_url##|##unsubscribeurl##", RegexOptions.IgnoreCase); + bool templateHasUnsubscribeToken = unsubscribeRegex.IsMatch(template.HtmlBody); + + // Always replace any unsubscribe tokens that exist + html = unsubscribeRegex.Replace(html, url); + + // If the original template didn't have unsubscribe tokens, auto-append + if (!templateHasUnsubscribeToken && !string.IsNullOrWhiteSpace(url)) + { + string unsubscribeFooter = $"

Unsubscribe

"; + + // Smart insertion logic: before , then , then at end + if (Regex.IsMatch(html, @"", RegexOptions.IgnoreCase)) + { + html = Regex.Replace(html, @"", $"\n{unsubscribeFooter}\n", RegexOptions.IgnoreCase); + } + else if (Regex.IsMatch(html, @"", RegexOptions.IgnoreCase)) + { + html = Regex.Replace(html, @"", $"\n{unsubscribeFooter}\n", RegexOptions.IgnoreCase); + } + else + { + html += "\n" + unsubscribeFooter; + } + } + return html; } private async Task SendEmailLocal(EmailDomain domain, string toAddress, string toName, string htmlBody, string subject, string fromName, string fromAddress, string replyToAddress, List categories, int? blastEmailID) @@ -314,5 +353,66 @@ namespace Surge365.MassEmailReact.Infrastructure.Services throw; } } + + public async Task ValidateUnsubscribeLinkAsync(MailingUpdateDto mailingDto) + { + ArgumentNullException.ThrowIfNull(mailingDto, nameof(mailingDto)); + + // Get the template to check for unsubscribe URL in HTML + var template = await _templateService.GetByIdAsync(mailingDto.TemplateId); + if (template == null) + { + return new UnsubscribeValidationResult + { + IsValid = false, + ValidationMessage = "Template not found", + ValidationType = UnsubscribeValidationType.Valid + }; + } + + // Check for ##unsubscribe_url## token in the HTML body + bool hasUnsubscribeUrlInTemplate = ContainsUnsubscribeUrl(template.HtmlBody); + bool hasUnsubscribeList = !string.IsNullOrWhiteSpace(mailingDto.UnsubscribeListCode); + + // Case 1: Has unsubscribe list but no URL in template + if (hasUnsubscribeList && !hasUnsubscribeUrlInTemplate) + { + return new UnsubscribeValidationResult + { + IsValid = false, + ValidationMessage = "An unsubscribe list is selected, but the ##unsubscribe_url## merge field was not found in the email template. The unsubscribe link will be automatically added to the bottom of the email when sent.", + ValidationType = UnsubscribeValidationType.HasUnsubscribeListButNoUrlInTemplate + }; + } + + // Case 2: Has URL in template but no unsubscribe list + if (!hasUnsubscribeList && hasUnsubscribeUrlInTemplate) + { + return new UnsubscribeValidationResult + { + IsValid = false, + ValidationMessage = "The template contains an ##unsubscribe_url## merge field, but no unsubscribe list is selected. Please either select an unsubscribe list or remove the ##unsubscribe_url## merge field from the template.", + ValidationType = UnsubscribeValidationType.HasUrlInTemplateButNoUnsubscribeList + }; + } + + // Case 3: Both match or neither (valid scenarios) + return new UnsubscribeValidationResult + { + IsValid = true, + ValidationMessage = "", + ValidationType = UnsubscribeValidationType.Valid + }; + } + + private bool ContainsUnsubscribeUrl(string htmlBody) + { + if (string.IsNullOrWhiteSpace(htmlBody)) + return false; + + // Check for both ##unsubscribe_url## and ##unsubscribeurl## tokens (case insensitive) + var regex = new Regex(@"##unsubscribe_url##|##unsubscribeurl##", RegexOptions.IgnoreCase); + return regex.IsMatch(htmlBody); + } } } \ No newline at end of file diff --git a/Surge365.MassEmailReact.Infrastructure/Services/TemplateService.cs b/Surge365.MassEmailReact.Infrastructure/Services/TemplateService.cs index 29c538e..54696c6 100644 --- a/Surge365.MassEmailReact.Infrastructure/Services/TemplateService.cs +++ b/Surge365.MassEmailReact.Infrastructure/Services/TemplateService.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Configuration; +using Surge365.MassEmailReact.Application.DTOs; using Surge365.MassEmailReact.Application.Interfaces; using Surge365.MassEmailReact.Domain.Entities; using System; diff --git a/Surge365.MassEmailReact.Infrastructure/Services/UnsubscribeListService.cs b/Surge365.MassEmailReact.Infrastructure/Services/UnsubscribeListService.cs index d4e03dc..0a7a23a 100644 --- a/Surge365.MassEmailReact.Infrastructure/Services/UnsubscribeListService.cs +++ b/Surge365.MassEmailReact.Infrastructure/Services/UnsubscribeListService.cs @@ -39,6 +39,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Services { FriendlyName = unsubscribeListDto.FriendlyName, FriendlyDescription = unsubscribeListDto.FriendlyDescription, + Url = unsubscribeListDto.Url, IsActive = unsubscribeListDto.IsActive, DisplayOrder = unsubscribeListDto.DisplayOrder }; @@ -57,6 +58,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Services unsubscribeList.FriendlyName = unsubscribeListDto.FriendlyName; unsubscribeList.FriendlyDescription = unsubscribeListDto.FriendlyDescription; + unsubscribeList.Url = unsubscribeListDto.Url; unsubscribeList.IsActive = unsubscribeListDto.IsActive; unsubscribeList.DisplayOrder = unsubscribeListDto.DisplayOrder; diff --git a/Surge365.MassEmailReact.Web/README.md b/Surge365.MassEmailReact.Web/README.md index 6f7553a..61d684d 100644 --- a/Surge365.MassEmailReact.Web/README.md +++ b/Surge365.MassEmailReact.Web/README.md @@ -9,7 +9,8 @@ - +# to publish the react project, in command prompt, go to the directory where you cloned the project, then type: npm run build +# This will create a "dist" folder in the same directory. That folder gets deployed to the web server. diff --git a/Surge365.MassEmailReact.Web/src/components/modals/MailingEdit.tsx b/Surge365.MassEmailReact.Web/src/components/modals/MailingEdit.tsx index bdf688c..75ad418 100644 --- a/Surge365.MassEmailReact.Web/src/components/modals/MailingEdit.tsx +++ b/Surge365.MassEmailReact.Web/src/components/modals/MailingEdit.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import React, { useState, useEffect } from "react"; import { Dialog, DialogTitle, @@ -24,6 +24,7 @@ import Mailing from "@/types/mailing"; import TargetSample from "@/types/targetSample"; import Target from "@/types/target"; import UnsubscribeList from "@/types/unsubscribeList"; +import UnsubscribeValidationResult from "@/types/unsubscribeValidationResult"; //import MailingTemplate from "@/types/mailingTemplate"; //import MailingTarget from "@/types/mailingTarget"; import EmailList from "@/components/forms/EmailList"; @@ -84,6 +85,10 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => { const [targetSample, setTargetSample] = useState(null); const [targetSampleLoading, setTargetSampleLoading] = useState(false); const [availableUnsubscribeLists, setAvailableUnsubscribeLists] = useState([]); + const [showUnsubscribeConfirmation, setShowUnsubscribeConfirmation] = useState(false); + const [pendingFormData, setPendingFormData] = useState(null); + const [showUnsubscribeValidationDialog, setShowUnsubscribeValidationDialog] = useState(false); + const [unsubscribeValidationResult, setUnsubscribeValidationResult] = useState(null); const defaultMailing: Mailing = { id: 0, @@ -357,6 +362,56 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => { }, [open, mailing, reset, setupData.testEmailLists, setupData.targets, setupData.templates, setupData.unsubscribeLists]); const handleSave = async (formData: Mailing) => { + // Check if no unsubscribe list is selected and confirmation hasn't been shown + if (!formData.unsubscribeListCode && availableUnsubscribeLists.length > 0) { + setPendingFormData(formData); + setShowUnsubscribeConfirmation(true); + return; + } + + // If this is an approved mailing, validate unsubscribe link before saving + if (approved) { + const validationResult = await validateUnsubscribeLink(formData); + if (!validationResult.isValid) { + if (validationResult.validationType === 'HasUrlInTemplateButNoUnsubscribeList') { + // Block save - show error dialog + setUnsubscribeValidationResult(validationResult); + setShowUnsubscribeValidationDialog(true); + return; + } else if (validationResult.validationType === 'HasUnsubscribeListButNoUrlInTemplate') { + // Show warning but allow continue + setUnsubscribeValidationResult(validationResult); + setPendingFormData(formData); + setShowUnsubscribeValidationDialog(true); + return; + } + } + } + + await performSave(formData); + }; + + const validateUnsubscribeLink = async (formData: Mailing): Promise => { + try { + const response = await customFetch("/api/mailings/validate-unsubscribe", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(formData), + }); + + if (!response.ok) { + throw new Error("Failed to validate unsubscribe link"); + } + + return await response.json(); + } catch (error) { + console.error("Validation error:", error); + // Return valid result on error to not block the save + return { isValid: true, validationMessage: "", validationType: "Valid" }; + } + }; + + const performSave = async (formData: Mailing) => { const apiUrl = isNew ? "/api/mailings" : `/api/mailings/${formData.id}`; const method = isNew ? "POST" : "PUT"; setLoading(true); @@ -397,6 +452,35 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => { } }; + const handleUnsubscribeConfirmationYes = () => { + setShowUnsubscribeConfirmation(false); + if (pendingFormData) { + performSave(pendingFormData); + setPendingFormData(null); + } + }; + + const handleUnsubscribeConfirmationNo = () => { + setShowUnsubscribeConfirmation(false); + setPendingFormData(null); + }; + + const handleUnsubscribeValidationOk = () => { + setShowUnsubscribeValidationDialog(false); + if (unsubscribeValidationResult?.validationType === 'HasUnsubscribeListButNoUrlInTemplate' && pendingFormData) { + // User confirmed they want to continue with auto-append + performSave(pendingFormData); + setPendingFormData(null); + } + setUnsubscribeValidationResult(null); + }; + + const handleUnsubscribeValidationCancel = () => { + setShowUnsubscribeValidationDialog(false); + setPendingFormData(null); + setUnsubscribeValidationResult(null); + }; + const handleEmailsChange = (newEmails: string[]) => { setEmails(newEmails); }; @@ -844,6 +928,58 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => { + + {/* Unsubscribe List Confirmation Dialog */} + + No Unsubscribe List Selected + + + You have not selected an unsubscribe list for this mailing. + Are you sure you want to continue without an unsubscribe list? + + + + + + + + + {/* Unsubscribe Link Validation Dialog */} + + + {unsubscribeValidationResult?.validationType === 'HasUrlInTemplateButNoUnsubscribeList' + ? 'Unsubscribe Link Error' + : 'Unsubscribe Link Warning' + } + + + + {unsubscribeValidationResult?.validationMessage} + + + + {unsubscribeValidationResult?.validationType === 'HasUrlInTemplateButNoUnsubscribeList' ? ( + // Error case - only allow going back + + ) : ( + // Warning case - allow continue or go back + <> + + + + )} + + ); }; diff --git a/Surge365.MassEmailReact.Web/src/components/modals/TemplateEdit.tsx b/Surge365.MassEmailReact.Web/src/components/modals/TemplateEdit.tsx index 801decb..20bcb2e 100644 --- a/Surge365.MassEmailReact.Web/src/components/modals/TemplateEdit.tsx +++ b/Surge365.MassEmailReact.Web/src/components/modals/TemplateEdit.tsx @@ -120,6 +120,39 @@ const TemplateEdit = ({ open, template, onClose, onSave }: TemplateEditProps) => } }; + // Check if unsubscribe URL already exists in the HTML (case insensitive) + const hasUnsubscribeUrl = (html: string): boolean => { + return /##unsubscribe_url##/i.test(html); + }; + + // Smart insertion of unsubscribe link + const insertUnsubscribeLink = () => { + const currentHtml = watch("htmlBody"); + + // Check if unsubscribe URL already exists + if (hasUnsubscribeUrl(currentHtml)) { + return; // Don't add if it already exists + } + + const unsubscribeHtml = '

Unsubscribe

'; + let newHtml = currentHtml; + + // Try to insert before closing tag (case insensitive) + if (/<\/body\s*>/i.test(newHtml)) { + newHtml = newHtml.replace(/<\/body\s*>/i, `\n${unsubscribeHtml}\n`); + } + // If no , try to insert before closing tag (case insensitive) + else if (/<\/html\s*>/i.test(newHtml)) { + newHtml = newHtml.replace(/<\/html\s*>/i, `\n${unsubscribeHtml}\n`); + } + // If no closing tags, just append to the end + else { + newHtml = currentHtml + '\n' + unsubscribeHtml; + } + + setValue("htmlBody", newHtml, { shouldValidate: true }); + }; + const renderEditorOrPreview = (height: string) => ( {isPreviewMode ? ( @@ -144,6 +177,9 @@ const TemplateEdit = ({ open, template, onClose, onSave }: TemplateEditProps) => ); const filteredEmailDomains = setupData.emailDomains.filter((domain) => domain.isActive); + const currentHtml = watch("htmlBody") || ""; + const unsubscribeExists = hasUnsubscribeUrl(currentHtml); + return ( justifyContent: "space-between", }} > - - + + + {renderEditorOrPreview("calc(100vh - 120px)")} @@ -290,16 +337,26 @@ const TemplateEdit = ({ open, template, onClose, onSave }: TemplateEditProps) => {/* Bottom Section: Single Column for HTML Control */} - - + - - + + + - - {renderEditorOrPreview("300px")} + + + + {renderEditorOrPreview("300px")} diff --git a/Surge365.MassEmailReact.Web/src/components/pages/CompletedMailings.tsx b/Surge365.MassEmailReact.Web/src/components/pages/CompletedMailings.tsx index 35081bc..458accc 100644 --- a/Surge365.MassEmailReact.Web/src/components/pages/CompletedMailings.tsx +++ b/Surge365.MassEmailReact.Web/src/components/pages/CompletedMailings.tsx @@ -54,7 +54,13 @@ function CompletedMailings() { <> {params.value ? new Date(params.value).toLocaleString() : ''} - )}, + ), + sortComparator: (v1, v2) => { + if (!v1 && !v2) return 0; + if (!v1) return 1; + if (!v2) return -1; + return new Date(v1).getTime() - new Date(v2).getTime(); + }}, { field: "emailCount", headerName: "Emails", width: 70 }, { field: "sendCount", headerName: "Active", width: 70 }, { field: "deliveredCount", headerName: "Delivered", width: 90 }, diff --git a/Surge365.MassEmailReact.Web/src/components/pages/ScheduledMailings.tsx b/Surge365.MassEmailReact.Web/src/components/pages/ScheduledMailings.tsx index 6669c38..d9dee87 100644 --- a/Surge365.MassEmailReact.Web/src/components/pages/ScheduledMailings.tsx +++ b/Surge365.MassEmailReact.Web/src/components/pages/ScheduledMailings.tsx @@ -68,7 +68,17 @@ function ScheduleMailings() { headerName: "Schedule Date", flex: 1, minWidth: 200, - valueGetter: (_: any, row: Mailing) => row.scheduleDate ? new Date(row.scheduleDate).toLocaleString() : 'N/A' + valueGetter: (_: any, row: Mailing) => row.scheduleDate ? new Date(row.scheduleDate).toLocaleString() : 'N/A', + sortComparator: (v1: any, v2: any, cellParams1: any, cellParams2: any) => { + const date1 = cellParams1.api.getRow(cellParams1.id)?.scheduleDate; + const date2 = cellParams2.api.getRow(cellParams2.id)?.scheduleDate; + + if (!date1 && !date2) return 0; + if (!date1) return 1; + if (!date2) return -1; + + return new Date(date1).getTime() - new Date(date2).getTime(); + } }, { field: "recurring", diff --git a/Surge365.MassEmailReact.Web/src/types/unsubscribeList.ts b/Surge365.MassEmailReact.Web/src/types/unsubscribeList.ts index 2105771..d1b0bc8 100644 --- a/Surge365.MassEmailReact.Web/src/types/unsubscribeList.ts +++ b/Surge365.MassEmailReact.Web/src/types/unsubscribeList.ts @@ -2,6 +2,7 @@ export interface UnsubscribeList { unsubscribeListCode: string; friendlyName: string; friendlyDescription: string | null; + url: string | null; isActive: boolean; displayOrder: number; createDate: string; diff --git a/Surge365.MassEmailReact.Web/src/types/unsubscribeValidationResult.ts b/Surge365.MassEmailReact.Web/src/types/unsubscribeValidationResult.ts new file mode 100644 index 0000000..c0047aa --- /dev/null +++ b/Surge365.MassEmailReact.Web/src/types/unsubscribeValidationResult.ts @@ -0,0 +1,7 @@ +export interface UnsubscribeValidationResult { + isValid: boolean; + validationMessage: string; + validationType: 'Valid' | 'HasUnsubscribeListButNoUrlInTemplate' | 'HasUrlInTemplateButNoUnsubscribeList'; +} + +export default UnsubscribeValidationResult; \ No newline at end of file