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.");
}
[HttpPost("validate-unsubscribe")]
public async Task<IActionResult> ValidateUnsubscribeLink([FromBody] MailingUpdateDto mailingUpdateDto)
{
ArgumentNullException.ThrowIfNull(mailingUpdateDto);
var validationResult = await _mailingService.ValidateUnsubscribeLinkAsync(mailingUpdateDto);
return Ok(validationResult);
}
[HttpPost]
public async Task<IActionResult> 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.");

View File

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

View File

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

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> CancelMailingAsync(int id);
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.Threading.Tasks;

View File

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

View File

@ -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");

View File

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

View File

@ -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<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 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<string>(), 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<string> 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 = $"<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;
}
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;
}
}
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 Surge365.MassEmailReact.Application.DTOs;
using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Domain.Entities;
using System;

View File

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

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 {
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<TargetSample | null>(null);
const [targetSampleLoading, setTargetSampleLoading] = useState(false);
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 = {
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<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 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) => {
</Button>
</DialogActions>
</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>
);
};

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) => (
<Box sx={{ flexGrow: 1, overflow: "hidden" }}>
{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 (
<Dialog
open={open}
@ -170,9 +206,20 @@ const TemplateEdit = ({ open, template, onClose, onSave }: TemplateEditProps) =>
justifyContent: "space-between",
}}
>
<Button onClick={() => setIsExpanded(false)}>Collapse</Button>
<Button onClick={() => setIsPreviewMode(!isPreviewMode)}>
{isPreviewMode ? "Edit" : "Preview"}
<Box>
<Button onClick={() => setIsExpanded(false)}>Collapse</Button>
<Button onClick={() => setIsPreviewMode(!isPreviewMode)}>
{isPreviewMode ? "Edit" : "Preview"}
</Button>
</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)")}
@ -290,16 +337,26 @@ const TemplateEdit = ({ open, template, onClose, onSave }: TemplateEditProps) =>
</Grid>
</Grid>
{/* Bottom Section: Single Column for HTML Control */}
<Grid item xs={12}>
<Grid item xs={12}>
<Box sx={{ display: "flex", justifyContent: "space-between", my: 1 }}>
<Button onClick={() => setIsExpanded(true)}>Expand Editor</Button>
<Button onClick={() => setIsPreviewMode(!isPreviewMode)}>
{isPreviewMode ? "Edit" : "Preview"}
</Button>
<Box>
<Button onClick={() => setIsExpanded(true)}>Expand Editor</Button>
<Button onClick={() => setIsPreviewMode(!isPreviewMode)}>
{isPreviewMode ? "Edit" : "Preview"}
</Button>
</Box>
<Paper variant="outlined" sx={{ p: 2 }}>
{renderEditorOrPreview("300px")}
<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 }}>
{renderEditorOrPreview("300px")}
</Paper>
</Grid>
</Grid>

View File

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

View File

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

View File

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

View File

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