Add unsubscribe link validation and related enhancements

- Introduced `ValidateUnsubscribeLink` endpoint in `MailingsController`.
- Refactored `UnsubscribeListsController` to inherit from `BaseController`.
- Added `Url` property to `UnsubscribeList` and updated related mappings.
- Enhanced `IMailingService` and `MailingService` for unsubscribe link handling.
- Implemented validation logic in `MailingEdit.tsx` with confirmation dialogs.
- Updated `TemplateEdit.tsx` for inserting unsubscribe links in templates.
- Improved sorting functionality in `CompletedMailings` and `ScheduledMailings`.
- Added `UnsubscribeValidationResult` interface for validation results.
This commit is contained in:
David Headrick 2025-08-31 06:48:25 -05:00
parent 651b33171b
commit 9f6b1ae74c
19 changed files with 407 additions and 50 deletions

View File

@ -82,6 +82,15 @@ namespace Surge365.MassEmailReact.API.Controllers
return template is not null ? Ok(template) : NotFound($"Mailing template with id '{id}' not found."); return template is not null ? Ok(template) : NotFound($"Mailing template with id '{id}' not found.");
} }
[HttpPost("validate-unsubscribe")]
public async Task<IActionResult> ValidateUnsubscribeLink([FromBody] MailingUpdateDto mailingUpdateDto)
{
ArgumentNullException.ThrowIfNull(mailingUpdateDto);
var validationResult = await _mailingService.ValidateUnsubscribeLinkAsync(mailingUpdateDto);
return Ok(validationResult);
}
[HttpPost] [HttpPost]
public async Task<IActionResult> CreateMailing([FromBody] MailingUpdateDto mailingUpdateDto) public async Task<IActionResult> CreateMailing([FromBody] MailingUpdateDto mailingUpdateDto)
{ {

View File

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Surge365.Core.Controllers;
using Surge365.MassEmailReact.Application.DTOs; using Surge365.MassEmailReact.Application.DTOs;
using Surge365.MassEmailReact.Application.Interfaces; using Surge365.MassEmailReact.Application.Interfaces;
using System; using System;
@ -7,10 +8,7 @@ using System.Threading.Tasks;
namespace Surge365.MassEmailReact.API.Controllers namespace Surge365.MassEmailReact.API.Controllers
{ {
[ApiController] public class UnsubscribeListsController : BaseController
[Route("api/[controller]")]
[Authorize]
public class UnsubscribeListsController : ControllerBase
{ {
private readonly IUnsubscribeListService _unsubscribeListService; private readonly IUnsubscribeListService _unsubscribeListService;

View File

@ -7,6 +7,7 @@ namespace Surge365.MassEmailReact.Application.DTOs
public string UnsubscribeListCode { get; set; } = ""; public string UnsubscribeListCode { get; set; } = "";
public string FriendlyName { get; set; } = ""; public string FriendlyName { get; set; } = "";
public string? FriendlyDescription { get; set; } public string? FriendlyDescription { get; set; }
public string? Url { get; set; }
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
public short DisplayOrder { get; set; } = 0; public short DisplayOrder { get; set; } = 0;
} }

View File

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

View File

@ -23,5 +23,6 @@ namespace Surge365.MassEmailReact.Application.Interfaces
Task<bool> UpdateAsync(MailingUpdateDto mailingDto); Task<bool> UpdateAsync(MailingUpdateDto mailingDto);
Task<bool> CancelMailingAsync(int id); Task<bool> CancelMailingAsync(int id);
Task<bool> TestMailing(MailingUpdateDto mailingUpdateDto, List<string> emails); Task<bool> TestMailing(MailingUpdateDto mailingUpdateDto, List<string> emails);
Task<UnsubscribeValidationResult> ValidateUnsubscribeLinkAsync(MailingUpdateDto mailingDto);
} }
} }

View File

@ -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.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;

View File

@ -7,6 +7,7 @@ namespace Surge365.MassEmailReact.Domain.Entities
public string UnsubscribeListCode { get; private set; } = ""; public string UnsubscribeListCode { get; private set; } = "";
public string FriendlyName { get; set; } = ""; public string FriendlyName { get; set; } = "";
public string? FriendlyDescription { get; set; } public string? FriendlyDescription { get; set; }
public string? Url { get; set; }
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
public short DisplayOrder { get; set; } = 0; public short DisplayOrder { get; set; } = 0;
public DateTime CreateDate { get; set; } = DateTime.Now; public DateTime CreateDate { get; set; } = DateTime.Now;
@ -15,11 +16,12 @@ namespace Surge365.MassEmailReact.Domain.Entities
public UnsubscribeList() { } public UnsubscribeList() { }
private UnsubscribeList(string unsubscribeListCode, string friendlyName, string? friendlyDescription, 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; UnsubscribeListCode = unsubscribeListCode;
FriendlyName = friendlyName; FriendlyName = friendlyName;
FriendlyDescription = friendlyDescription; FriendlyDescription = friendlyDescription;
Url = url;
IsActive = isActive; IsActive = isActive;
DisplayOrder = displayOrder; DisplayOrder = displayOrder;
CreateDate = createDate; CreateDate = createDate;
@ -27,9 +29,9 @@ namespace Surge365.MassEmailReact.Domain.Entities
} }
public static UnsubscribeList Create(string unsubscribeListCode, string friendlyName, string? friendlyDescription, 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); displayOrder, createDate, updateDate);
} }
} }

View File

@ -10,6 +10,7 @@ namespace Surge365.MassEmailReact.Infrastructure.EntityMaps
Map(u => u.UnsubscribeListCode).ToColumn("unsubscribe_list_code"); Map(u => u.UnsubscribeListCode).ToColumn("unsubscribe_list_code");
Map(u => u.FriendlyName).ToColumn("friendly_name"); Map(u => u.FriendlyName).ToColumn("friendly_name");
Map(u => u.FriendlyDescription).ToColumn("friendly_description"); Map(u => u.FriendlyDescription).ToColumn("friendly_description");
Map(u => u.Url).ToColumn("url");
Map(u => u.IsActive).ToColumn("is_active"); Map(u => u.IsActive).ToColumn("is_active");
Map(u => u.DisplayOrder).ToColumn("display_order"); Map(u => u.DisplayOrder).ToColumn("display_order");
Map(u => u.CreateDate).ToColumn("create_date"); Map(u => u.CreateDate).ToColumn("create_date");

View File

@ -79,6 +79,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
new SqlParameter("@unsubscribe_list_code", unsubscribeList.UnsubscribeListCode), new SqlParameter("@unsubscribe_list_code", unsubscribeList.UnsubscribeListCode),
new SqlParameter("@friendly_name", unsubscribeList.FriendlyName), new SqlParameter("@friendly_name", unsubscribeList.FriendlyName),
new SqlParameter("@friendly_description", unsubscribeList.FriendlyDescription ?? (object)DBNull.Value), 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("@is_active", unsubscribeList.IsActive),
new SqlParameter("@display_order", unsubscribeList.DisplayOrder), new SqlParameter("@display_order", unsubscribeList.DisplayOrder),
pmSuccess pmSuccess
@ -107,6 +108,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
new SqlParameter("@unsubscribe_list_code", unsubscribeList.UnsubscribeListCode), new SqlParameter("@unsubscribe_list_code", unsubscribeList.UnsubscribeListCode),
new SqlParameter("@friendly_name", unsubscribeList.FriendlyName), new SqlParameter("@friendly_name", unsubscribeList.FriendlyName),
new SqlParameter("@friendly_description", unsubscribeList.FriendlyDescription ?? (object)DBNull.Value), 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("@is_active", unsubscribeList.IsActive),
new SqlParameter("@display_order", unsubscribeList.DisplayOrder), new SqlParameter("@display_order", unsubscribeList.DisplayOrder),
pmSuccess pmSuccess

View File

@ -1,4 +1,4 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Surge365.MassEmailReact.Application.DTOs; using Surge365.MassEmailReact.Application.DTOs;
using Surge365.MassEmailReact.Application.Interfaces; using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Domain.Entities; using Surge365.MassEmailReact.Domain.Entities;
@ -20,13 +20,14 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
private readonly ITargetService _targetService; private readonly ITargetService _targetService;
private readonly ITemplateService _templateService; private readonly ITemplateService _templateService;
private readonly IEmailDomainService _emailDomainService; private readonly IEmailDomainService _emailDomainService;
private readonly IUnsubscribeListService _unsubscribeListService;
private readonly IMailingRepository _mailingRepository; private readonly IMailingRepository _mailingRepository;
private readonly IConfiguration _config; private readonly IConfiguration _config;
private string DefaultUnsubscribeUrl private string DefaultUnsubscribeUrl
{ {
get get
{ {
return _config["DefaultUnsubscribeUrl"] ?? ""; return _config["MassEmail_UnsubscribeProcessingUrl"] ?? "";
} }
} }
private bool SendGridTestMode private bool SendGridTestMode
@ -43,13 +44,14 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
return _config["RegularExpression_Email"] ?? ""; 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; _httpClientFactory = httpClientFactory;
_mailingRepository = mailingRepository; _mailingRepository = mailingRepository;
_targetService = targetService; _targetService = targetService;
_templateService = templateService; _templateService = templateService;
_emailDomainService = emailDomainService; _emailDomainService = emailDomainService;
_unsubscribeListService = unsubscribeListService;
_config = config; _config = config;
} }
@ -177,11 +179,11 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
return false; return false;
foreach (string email in emails) foreach (string email in emails)
await SendTestEmailLocal(template, domain, targetSample, email); await SendTestEmailLocal(template, domain, targetSample, email, mailing.UnsubscribeListCode);
return true; return true;
} }
private async Task<bool> SendTestEmailLocal(Template template, EmailDomain emailDomain, TargetSample targetSample, string emailAddress) private async Task<bool> SendTestEmailLocal(Template template, EmailDomain emailDomain, TargetSample targetSample, string emailAddress, string? unsubscribeListCode = null)
{ {
string html = template.HtmlBody; string html = template.HtmlBody;
string tokenTemplate = "(?i)##{0}##"; string tokenTemplate = "(?i)##{0}##";
@ -197,7 +199,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
toName = r.Replace(toName, targetSample.Rows[0][columnName]); toName = r.Replace(toName, targetSample.Rows[0][columnName]);
} }
html = RemoveOpenTag(html); 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<string>(), null); return await SendEmailLocal(emailDomain, emailAddress, toName, html, subject, template.FromName, template.FromEmail, template.ReplyToEmail, new List<string>(), null);
} }
@ -207,27 +209,64 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
html = r.Replace(html, ""); html = r.Replace(html, "");
return html; return html;
} }
private string MergeWithUnsubscribe(string html, Template template) private async Task<string> MergeWithUnsubscribeAsync(string html, Template template, string? unsubscribeListCode = null, string? emailAddress = null, int? blastEmailID = null)
{ {
string url = DefaultUnsubscribeUrl; string baseUrl = ""; // Default to empty, will use default only if list found without URL.
//if (template.UnsubscribeUrl != null)
//{ // If unsubscribe list is provided, try to get its URL
// url = template.UnsubscribeUrl.Url; if (!string.IsNullOrWhiteSpace(unsubscribeListCode))
//} {
//else if (blast.BlastTemplate != null && blast.BlastTemplate.BlastUnsubscribeUrl != null) var unsubscribeList = await _unsubscribeListService.GetByCodeAsync(unsubscribeListCode);
//{ if (unsubscribeList != null && !string.IsNullOrWhiteSpace(unsubscribeList.Url))
// url = blast.BlastTemplate.BlastUnsubscribeUrl.Url; {
//} baseUrl = unsubscribeList.Url;
//else if (blast.BlastTemplate != null && blast.BlastTemplate.UnsubscribeUrl != null) }
//{ else if(unsubscribeList != null)
// url = blast.BlastTemplate.UnsubscribeUrl.Url; {
//} baseUrl = DefaultUnsubscribeUrl;
//else }
//{ }
// url = Utilities.DefaultUnsubscribeUrl;
//} // Format the URL with the required parameters: {baseUrl}?emailid={blastEmailID ?? 0}&emailaddress={emailAddress}
Regex r = new Regex("(?i)##unsubscribeurl##", RegexOptions.IgnoreCase); string url = "";
html = r.Replace(html, 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 = $"<p style=\"text-align: center; font-size: 12px; color: #666;\"><a href=\"{url}\">Unsubscribe</a></p>";
// Smart insertion logic: before </body>, then </html>, then at end
if (Regex.IsMatch(html, @"</body\s*>", RegexOptions.IgnoreCase))
{
html = Regex.Replace(html, @"</body\s*>", $"\n{unsubscribeFooter}\n</body>", RegexOptions.IgnoreCase);
}
else if (Regex.IsMatch(html, @"</html\s*>", RegexOptions.IgnoreCase))
{
html = Regex.Replace(html, @"</html\s*>", $"\n{unsubscribeFooter}\n</html>", RegexOptions.IgnoreCase);
}
else
{
html += "\n" + unsubscribeFooter;
}
}
return html; return html;
} }
private async Task<bool> SendEmailLocal(EmailDomain domain, string toAddress, string toName, string htmlBody, string subject, string fromName, string fromAddress, string replyToAddress, List<string> categories, int? blastEmailID) private async Task<bool> SendEmailLocal(EmailDomain domain, string toAddress, string toName, string htmlBody, string subject, string fromName, string fromAddress, string replyToAddress, List<string> categories, int? blastEmailID)
@ -314,5 +353,66 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
throw; throw;
} }
} }
public async Task<UnsubscribeValidationResult> 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);
}
} }
} }

View File

@ -1,4 +1,5 @@
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Surge365.MassEmailReact.Application.DTOs;
using Surge365.MassEmailReact.Application.Interfaces; using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Domain.Entities; using Surge365.MassEmailReact.Domain.Entities;
using System; using System;

View File

@ -39,6 +39,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
{ {
FriendlyName = unsubscribeListDto.FriendlyName, FriendlyName = unsubscribeListDto.FriendlyName,
FriendlyDescription = unsubscribeListDto.FriendlyDescription, FriendlyDescription = unsubscribeListDto.FriendlyDescription,
Url = unsubscribeListDto.Url,
IsActive = unsubscribeListDto.IsActive, IsActive = unsubscribeListDto.IsActive,
DisplayOrder = unsubscribeListDto.DisplayOrder DisplayOrder = unsubscribeListDto.DisplayOrder
}; };
@ -57,6 +58,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
unsubscribeList.FriendlyName = unsubscribeListDto.FriendlyName; unsubscribeList.FriendlyName = unsubscribeListDto.FriendlyName;
unsubscribeList.FriendlyDescription = unsubscribeListDto.FriendlyDescription; unsubscribeList.FriendlyDescription = unsubscribeListDto.FriendlyDescription;
unsubscribeList.Url = unsubscribeListDto.Url;
unsubscribeList.IsActive = unsubscribeListDto.IsActive; unsubscribeList.IsActive = unsubscribeListDto.IsActive;
unsubscribeList.DisplayOrder = unsubscribeListDto.DisplayOrder; unsubscribeList.DisplayOrder = unsubscribeListDto.DisplayOrder;

View File

@ -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.

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { import {
Dialog, Dialog,
DialogTitle, DialogTitle,
@ -24,6 +24,7 @@ import Mailing from "@/types/mailing";
import TargetSample from "@/types/targetSample"; import TargetSample from "@/types/targetSample";
import Target from "@/types/target"; import Target from "@/types/target";
import UnsubscribeList from "@/types/unsubscribeList"; import UnsubscribeList from "@/types/unsubscribeList";
import UnsubscribeValidationResult from "@/types/unsubscribeValidationResult";
//import MailingTemplate from "@/types/mailingTemplate"; //import MailingTemplate from "@/types/mailingTemplate";
//import MailingTarget from "@/types/mailingTarget"; //import MailingTarget from "@/types/mailingTarget";
import EmailList from "@/components/forms/EmailList"; import EmailList from "@/components/forms/EmailList";
@ -84,6 +85,10 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
const [targetSample, setTargetSample] = useState<TargetSample | null>(null); const [targetSample, setTargetSample] = useState<TargetSample | null>(null);
const [targetSampleLoading, setTargetSampleLoading] = useState(false); const [targetSampleLoading, setTargetSampleLoading] = useState(false);
const [availableUnsubscribeLists, setAvailableUnsubscribeLists] = useState<UnsubscribeList[]>([]); const [availableUnsubscribeLists, setAvailableUnsubscribeLists] = useState<UnsubscribeList[]>([]);
const [showUnsubscribeConfirmation, setShowUnsubscribeConfirmation] = useState<boolean>(false);
const [pendingFormData, setPendingFormData] = useState<Mailing | null>(null);
const [showUnsubscribeValidationDialog, setShowUnsubscribeValidationDialog] = useState<boolean>(false);
const [unsubscribeValidationResult, setUnsubscribeValidationResult] = useState<UnsubscribeValidationResult | null>(null);
const defaultMailing: Mailing = { const defaultMailing: Mailing = {
id: 0, id: 0,
@ -357,6 +362,56 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
}, [open, mailing, reset, setupData.testEmailLists, setupData.targets, setupData.templates, setupData.unsubscribeLists]); }, [open, mailing, reset, setupData.testEmailLists, setupData.targets, setupData.templates, setupData.unsubscribeLists]);
const handleSave = async (formData: Mailing) => { 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<UnsubscribeValidationResult> => {
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 apiUrl = isNew ? "/api/mailings" : `/api/mailings/${formData.id}`;
const method = isNew ? "POST" : "PUT"; const method = isNew ? "POST" : "PUT";
setLoading(true); 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[]) => { const handleEmailsChange = (newEmails: string[]) => {
setEmails(newEmails); setEmails(newEmails);
}; };
@ -844,6 +928,58 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
{/* Unsubscribe List Confirmation Dialog */}
<Dialog open={showUnsubscribeConfirmation} onClose={handleUnsubscribeConfirmationNo}>
<DialogTitle>No Unsubscribe List Selected</DialogTitle>
<DialogContent>
<Typography>
You have not selected an unsubscribe list for this mailing.
Are you sure you want to continue without an unsubscribe list?
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={handleUnsubscribeConfirmationNo} color="secondary">
No, Go Back
</Button>
<Button onClick={handleUnsubscribeConfirmationYes} color="primary" autoFocus>
Yes, Continue
</Button>
</DialogActions>
</Dialog>
{/* Unsubscribe Link Validation Dialog */}
<Dialog open={showUnsubscribeValidationDialog} onClose={handleUnsubscribeValidationCancel}>
<DialogTitle>
{unsubscribeValidationResult?.validationType === 'HasUrlInTemplateButNoUnsubscribeList'
? 'Unsubscribe Link Error'
: 'Unsubscribe Link Warning'
}
</DialogTitle>
<DialogContent>
<Typography>
{unsubscribeValidationResult?.validationMessage}
</Typography>
</DialogContent>
<DialogActions>
{unsubscribeValidationResult?.validationType === 'HasUrlInTemplateButNoUnsubscribeList' ? (
// Error case - only allow going back
<Button onClick={handleUnsubscribeValidationCancel} color="primary" autoFocus>
OK
</Button>
) : (
// Warning case - allow continue or go back
<>
<Button onClick={handleUnsubscribeValidationCancel} color="secondary">
Cancel
</Button>
<Button onClick={handleUnsubscribeValidationOk} color="primary" autoFocus>
Continue
</Button>
</>
)}
</DialogActions>
</Dialog>
</LocalizationProvider> </LocalizationProvider>
); );
}; };

View File

@ -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 = '<p style="text-align: center; font-size: 12px; color: #666;"><a href="##unsubscribe_url##">Unsubscribe</a></p>';
let newHtml = currentHtml;
// Try to insert before closing </body> tag (case insensitive)
if (/<\/body\s*>/i.test(newHtml)) {
newHtml = newHtml.replace(/<\/body\s*>/i, `\n${unsubscribeHtml}\n</body>`);
}
// If no </body>, try to insert before closing </html> tag (case insensitive)
else if (/<\/html\s*>/i.test(newHtml)) {
newHtml = newHtml.replace(/<\/html\s*>/i, `\n${unsubscribeHtml}\n</html>`);
}
// If no closing tags, just append to the end
else {
newHtml = currentHtml + '\n' + unsubscribeHtml;
}
setValue("htmlBody", newHtml, { shouldValidate: true });
};
const renderEditorOrPreview = (height: string) => ( const renderEditorOrPreview = (height: string) => (
<Box sx={{ flexGrow: 1, overflow: "hidden" }}> <Box sx={{ flexGrow: 1, overflow: "hidden" }}>
{isPreviewMode ? ( {isPreviewMode ? (
@ -144,6 +177,9 @@ const TemplateEdit = ({ open, template, onClose, onSave }: TemplateEditProps) =>
); );
const filteredEmailDomains = setupData.emailDomains.filter((domain) => domain.isActive); const filteredEmailDomains = setupData.emailDomains.filter((domain) => domain.isActive);
const currentHtml = watch("htmlBody") || "";
const unsubscribeExists = hasUnsubscribeUrl(currentHtml);
return ( return (
<Dialog <Dialog
open={open} open={open}
@ -170,11 +206,22 @@ const TemplateEdit = ({ open, template, onClose, onSave }: TemplateEditProps) =>
justifyContent: "space-between", justifyContent: "space-between",
}} }}
> >
<Box>
<Button onClick={() => setIsExpanded(false)}>Collapse</Button> <Button onClick={() => setIsExpanded(false)}>Collapse</Button>
<Button onClick={() => setIsPreviewMode(!isPreviewMode)}> <Button onClick={() => setIsPreviewMode(!isPreviewMode)}>
{isPreviewMode ? "Edit" : "Preview"} {isPreviewMode ? "Edit" : "Preview"}
</Button> </Button>
</Box> </Box>
<Button
variant="outlined"
color="primary"
onClick={insertUnsubscribeLink}
disabled={isPreviewMode || unsubscribeExists}
title={unsubscribeExists ? "Unsubscribe link already exists in template" : "Add unsubscribe link to template"}
>
{unsubscribeExists ? "Unsubscribe Link Exists" : "Add Unsubscribe Link"}
</Button>
</Box>
{renderEditorOrPreview("calc(100vh - 120px)")} {renderEditorOrPreview("calc(100vh - 120px)")}
</Box> </Box>
) : ( ) : (
@ -290,14 +337,24 @@ const TemplateEdit = ({ open, template, onClose, onSave }: TemplateEditProps) =>
</Grid> </Grid>
</Grid> </Grid>
{/* Bottom Section: Single Column for HTML Control */} {/* Bottom Section: Single Column for HTML Control */}
<Grid item xs={12}> <Grid item xs={12}>
<Box sx={{ display: "flex", justifyContent: "space-between", my: 1 }}> <Box sx={{ display: "flex", justifyContent: "space-between", my: 1 }}>
<Box>
<Button onClick={() => setIsExpanded(true)}>Expand Editor</Button> <Button onClick={() => setIsExpanded(true)}>Expand Editor</Button>
<Button onClick={() => setIsPreviewMode(!isPreviewMode)}> <Button onClick={() => setIsPreviewMode(!isPreviewMode)}>
{isPreviewMode ? "Edit" : "Preview"} {isPreviewMode ? "Edit" : "Preview"}
</Button> </Button>
</Box> </Box>
<Button
variant="outlined"
color="primary"
onClick={insertUnsubscribeLink}
disabled={isPreviewMode || unsubscribeExists}
title={unsubscribeExists ? "Unsubscribe link already exists in template" : "Add unsubscribe link to template"}
>
{unsubscribeExists ? "Unsubscribe Link Exists" : "Add Unsubscribe Link"}
</Button>
</Box>
<Paper variant="outlined" sx={{ p: 2 }}> <Paper variant="outlined" sx={{ p: 2 }}>
{renderEditorOrPreview("300px")} {renderEditorOrPreview("300px")}
</Paper> </Paper>

View File

@ -54,7 +54,13 @@ function CompletedMailings() {
<> <>
{params.value ? new Date(params.value).toLocaleString() : ''} {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: "emailCount", headerName: "Emails", width: 70 },
{ field: "sendCount", headerName: "Active", width: 70 }, { field: "sendCount", headerName: "Active", width: 70 },
{ field: "deliveredCount", headerName: "Delivered", width: 90 }, { field: "deliveredCount", headerName: "Delivered", width: 90 },

View File

@ -68,7 +68,17 @@ function ScheduleMailings() {
headerName: "Schedule Date", headerName: "Schedule Date",
flex: 1, flex: 1,
minWidth: 200, 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", field: "recurring",

View File

@ -2,6 +2,7 @@ export interface UnsubscribeList {
unsubscribeListCode: string; unsubscribeListCode: string;
friendlyName: string; friendlyName: string;
friendlyDescription: string | null; friendlyDescription: string | null;
url: string | null;
isActive: boolean; isActive: boolean;
displayOrder: number; displayOrder: number;
createDate: string; createDate: string;

View File

@ -0,0 +1,7 @@
export interface UnsubscribeValidationResult {
isValid: boolean;
validationMessage: string;
validationType: 'Valid' | 'HasUnsubscribeListButNoUrlInTemplate' | 'HasUrlInTemplateButNoUnsubscribeList';
}
export default UnsubscribeValidationResult;