PluginProbe
SureMail – SMTP and Email Logs Plugin with Amazon SES, Postmark, and Other Providers / 2.0.0
SureMail – SMTP and Email Logs Plugin with Amazon SES, Postmark, and Other Providers v2.0.0
2.0.2 2.0.1 2.0.0 1.9.5 trunk 0.0.4 0.0.5 0.0.6 1.0.0 1.0.1 1.1.0 1.2.0 1.3.0 1.4.0 1.4.1 1.4.2 1.5.0 1.6.0 1.6.1 1.6.2 1.7.0 1.7.1 1.7.2 1.8.0 1.8.1 All 31 releases
suremails / src / utils / utils.js

utils.js in SureMail – SMTP and Email Logs Plugin with Amazon SES, Postmark, and Other Providers 2.0.0, at src/utils/utils.js

683 lines 18.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import { __ } from '@wordpress/i18n';
2 import clsx from 'clsx';
3 import { format as format_date } from 'date-fns';
4 import { twMerge } from 'tailwind-merge';
5 import DOMPurify from 'dompurify';
6
7 /**
8 * Formats a given date string based on the provided options.
9 *
10 * @param {string} dateString - The date string to format.
11 * @param {Object} options - Formatting options to customize the output.
12 * @param {boolean} [options.day] - Whether to include the day in the output.
13 * @param {boolean} [options.month] - Whether to include the month in the output.
14 * @param {boolean} [options.year] - Whether to include the year in the output.
15 * @param {boolean} [options.hour] - Whether to include the hour in the output.
16 * @param {boolean} [options.minute] - Whether to include the minute in the output.
17 * @param {boolean} [options.hour12] - Whether to use a 12-hour clock format.
18 * @return {string} - The formatted date string or a fallback if the input is invalid.
19 */
20 export const formatDate = ( dateString, options = {} ) => {
21 if ( ! dateString || isNaN( new Date( dateString ).getTime() ) ) {
22 return __( 'No Date', 'suremails' );
23 }
24
25 const optionMap = {
26 day: '2-digit',
27 month: 'short',
28 year: 'numeric',
29 hour: '2-digit',
30 minute: '2-digit',
31 hour12: true, // Note: hour12 is a boolean directly
32 };
33
34 const formattingOptions = Object.keys( optionMap ).reduce( ( acc, key ) => {
35 if ( options[ key ] === true ) {
36 acc[ key ] = optionMap[ key ];
37 } else if ( options[ key ] === false ) {
38 } else if ( options[ key ] !== undefined ) {
39 acc[ key ] = options[ key ];
40 }
41 return acc;
42 }, {} );
43
44 return new Intl.DateTimeFormat( 'en-US', formattingOptions ).format(
45 new Date( dateString )
46 );
47 };
48
49 /**
50 *
51 * @return {string} - The formatted date string.
52 */
53
54 export const getDatePlaceholder = () => {
55 const currentDate = new Date();
56 const pastDate = new Date();
57 pastDate.setDate( currentDate.getDate() - 30 ); // Set to 30 days ago
58
59 const formattedPastDate = format( pastDate, 'MM/dd/yyyy' );
60 const formattedCurrentDate = format( currentDate, 'MM/dd/yyyy' );
61
62 return `${ formattedPastDate } - ${ formattedCurrentDate }`;
63 };
64
65 /**
66 *
67 * @return {string} - The formatted date string.
68 */
69
70 export const getLastNDays = ( days ) => {
71 if ( isNaN( days ) ) {
72 return {
73 from: null,
74 to: null,
75 };
76 }
77 const currentDate = new Date();
78 const pastDate = new Date();
79 pastDate.setDate( currentDate.getDate() - days ); // Set to 30 days ago
80
81 return {
82 from: pastDate,
83 to: currentDate,
84 };
85 };
86
87 /**
88 * Returns selected date in string format.
89 *
90 * @param {Object} selectedDates - Object containing `from` and `to` Date objects.
91 * @return {string} - Formatted string.
92 */
93 export const getSelectedDate = ( selectedDates ) => {
94 if ( ! selectedDates.from || isNaN( selectedDates.from.getTime() ) ) {
95 return '';
96 }
97 if ( ! selectedDates.to || isNaN( selectedDates.to.getTime() ) ) {
98 return format( selectedDates.from, 'MM/dd/yyyy' );
99 }
100 return `${ format( selectedDates.from, 'MM/dd/yyyy' ) } - ${ format(
101 selectedDates.to,
102 'MM/dd/yyyy'
103 ) }`;
104 };
105
106 /**
107 * Utility function to sort an array of objects based on a specified key.
108 *
109 * @param {Array} data - The array of objects to sort.
110 * @param {string} key - The key in the objects to sort by.
111 * @param {string} direction - Sort direction: 'asc' for ascending, 'desc' for descending.
112 * @return {Array} - The sorted array of objects.
113 */
114 export const sortData = ( data, key, direction = 'asc' ) => {
115 if ( ! Array.isArray( data ) || ! key ) {
116 return data; // Return data as is if invalid
117 }
118
119 const sortedData = [ ...data ].sort( ( a, b ) => {
120 const valueA =
121 new Date( a[ key ] ) instanceof Date &&
122 ! isNaN( new Date( a[ key ] ).getTime() )
123 ? new Date( a[ key ] )
124 : a[ key ];
125 const valueB =
126 new Date( b[ key ] ) instanceof Date &&
127 ! isNaN( new Date( b[ key ] ).getTime() )
128 ? new Date( b[ key ] )
129 : b[ key ];
130
131 if ( valueA < valueB ) {
132 return direction === 'asc' ? -1 : 1;
133 }
134 if ( valueA > valueB ) {
135 return direction === 'asc' ? 1 : -1;
136 }
137 return 0;
138 } );
139
140 return sortedData;
141 };
142
143 /**
144 * Formats a given date string based on the provided options.
145 * If no options are provided, it defaults to 'yyyy-MM-dd' format.
146 *
147 * @param {string|Date} date - The date string or Date object to format.
148 * @param {string} [dateFormat='yyyy-MM-dd'] - The date format string for `date-fns`.
149 * @return {string} - The formatted date string or a fallback if the input is invalid.
150 */
151 export const format = ( date, dateFormat = 'yyyy-MM-dd' ) => {
152 try {
153 if ( ! date || isNaN( new Date( date ).getTime() ) ) {
154 throw new Error( __( 'Invalid Date', 'suremails' ) );
155 }
156 return format_date( new Date( date ), dateFormat );
157 } catch ( error ) {
158 return __( 'No Date', 'suremails' );
159 }
160 };
161
162 /**
163 * Parses headers provided either as a raw string or an array of strings into a structured object.
164 *
165 * Each header line is expected to be in the format "Header-Name: header value".
166 * This function handles:
167 * - Numerical prefixes (e.g., "0: From: ...")
168 * - Multiple header values for the same header name.
169 *
170 * @param {string | string[]} headersInput - The raw headers string or an array of header strings.
171 * @return {Object} - An object where the keys are normalized header names and the values are arrays of header values.
172 */
173 export const parseHeaders = ( headersInput ) => {
174 const headers = {};
175
176 let headerLines = [];
177 if ( Array.isArray( headersInput ) ) {
178 headerLines = headersInput;
179 } else if ( typeof headersInput === 'string' ) {
180 headerLines = headersInput.split( /\r?\n/ );
181 } else {
182 return headers;
183 }
184
185 headerLines.forEach( ( line ) => {
186 if ( ! line.trim() ) {
187 return;
188 }
189
190 // Remove a leading numerical prefix if present (e.g., "0: From: ...").
191 const prefixMatch = line.match( /^\d+:\s*(.*)$/ );
192 const cleanedLine = prefixMatch ? prefixMatch[ 1 ] : line;
193
194 // Find the first colon (:) that separates the header name from its value.
195 const separatorIndex = cleanedLine.indexOf( ':' );
196 if ( separatorIndex === -1 ) {
197 return;
198 }
199
200 // Extract and trim the header name.
201 const name = cleanedLine.slice( 0, separatorIndex ).trim();
202 if ( ! name ) {
203 return;
204 }
205
206 const value = cleanedLine.slice( separatorIndex + 1 ).trim();
207
208 const normalizedName = normalizeHeaderName( name );
209
210 // Initialize the header's value array if it doesn't exist.
211 if ( ! headers[ normalizedName ] ) {
212 headers[ normalizedName ] = [];
213 }
214
215 headers[ normalizedName ].push( value );
216 } );
217
218 return headers;
219 };
220
221 /**
222 * Normalizes header names to a standard format.
223 *
224 * E.g., 'reply-to' => 'Reply-To'
225 *
226 * @param {string} name - The header name to normalize.
227 * @return {string} - The normalized header name.
228 */
229 const normalizeHeaderName = ( name ) => {
230 return name
231 .split( '-' )
232 .map(
233 ( word ) =>
234 word.charAt( 0 ).toUpperCase() + word.slice( 1 ).toLowerCase()
235 )
236 .join( '-' );
237 };
238
239 /**
240 * Utility function to merge Tailwind CSS and conditional class names.
241 *
242 * @param {...any} args
243 * @return {string} - The concatenated class string.
244 */
245 export const cn = ( ...args ) => twMerge( clsx( ...args ) );
246
247 /**
248 * Generates a range of page numbers and ellipses for pagination.
249 *
250 * @param {number} currentPage - The current active page.
251 * @param {number} totalPages - The total number of pages.
252 * @param {number} siblingCount - Number of pages to show on each side of the current page.
253 * @return {Array} An array containing page numbers and 'ellipsis' strings.
254 */
255 export const getPaginationRange = (
256 currentPage,
257 totalPages,
258 siblingCount = 1
259 ) => {
260 // Calculate common values
261 const siblingFactor = siblingCount * 2; // Sibling count multiplied by 2
262 const totalPageNumbers = siblingFactor + 5; // Total numbers including ellipses and edges
263
264 if ( totalPageNumbers >= totalPages ) {
265 // If all pages can fit within the range
266 return Array.from( { length: totalPages }, ( _, i ) => i + 1 );
267 }
268
269 // Calculate indices
270 const leftSiblingIndex = Math.max( currentPage - siblingCount, 1 ); // Left sibling index
271 const rightSiblingIndex = Math.min(
272 currentPage + siblingCount,
273 totalPages
274 );
275
276 const showLeftEllipsis = leftSiblingIndex > 2;
277 const showRightEllipsis = rightSiblingIndex < totalPages - 1;
278
279 // Constants for the first and last pages
280 const firstPage = 1;
281 const lastPage = totalPages;
282
283 const pages = [];
284
285 if ( ! showLeftEllipsis && showRightEllipsis ) {
286 // Calculate range for the left side
287 const leftItemCount = 3 + siblingFactor; // Number of items on the left
288 const leftRange = Array.from(
289 { length: leftItemCount },
290 ( _, i ) => i + 1
291 );
292 pages.push( ...leftRange, 'ellipsis', lastPage );
293 } else if ( showLeftEllipsis && ! showRightEllipsis ) {
294 // Calculate range for the right side
295 const rightItemCount = 3 + siblingFactor; // Number of items on the right
296 const rightRange = Array.from(
297 { length: rightItemCount },
298 ( _, i ) => totalPages - rightItemCount + i + 1
299 );
300 pages.push( firstPage, 'ellipsis', ...rightRange );
301 } else if ( showLeftEllipsis && showRightEllipsis ) {
302 // Calculate middle range
303 const middleRange = Array.from(
304 { length: siblingFactor + 1 },
305 ( _, i ) => currentPage - siblingCount + i
306 );
307 pages.push(
308 firstPage,
309 'ellipsis',
310 ...middleRange,
311 'ellipsis',
312 lastPage
313 );
314 }
315
316 return pages;
317 };
318
319 /**
320 * Get the label for the log status
321 *
322 * @param {string} status - The status of the log
323 * @param {Array} response - Array of response objects.
324 * @return {string} - The label for the status
325 */
326 export const getStatusLabel = ( status, response ) => {
327 const simulated = isResponseSimulated( response );
328
329 if ( simulated ) {
330 return __( 'Simulated', 'suremails' );
331 }
332 switch ( status ) {
333 case 'sent':
334 return __( 'Successful', 'suremails' );
335 case 'failed':
336 return __( 'Failed', 'suremails' );
337 case 'pending':
338 return __( 'In Progress', 'suremails' );
339 case 'blocked':
340 return __( 'Blocked', 'suremails' );
341 default:
342 return __( 'Unknown', 'suremails' );
343 }
344 };
345
346 /**
347 * Get the variant for the log status badge
348 *
349 * @param {string} status - The status of the log
350 * @param {Array} response - Array of response objects.
351 * @return {string} - The variant for the badge
352 */
353 export const getStatusVariant = ( status, response ) => {
354 const simulated = isResponseSimulated( response );
355
356 if ( simulated ) {
357 return 'yellow';
358 }
359 switch ( status ) {
360 case 'sent':
361 return 'green';
362 case 'failed':
363 return 'red';
364 case 'pending':
365 return 'yellow';
366 case 'blocked':
367 return 'red';
368 default:
369 return 'gray'; // Fallback color for unknown statuses
370 }
371 };
372
373 /**
374 * Determines if the response indicates a simulated log.
375 *
376 * It finds the response element with the highest "retry" value and returns its "simulated" flag.
377 *
378 * @param {Array} response - Array of response objects.
379 * @return {boolean} - True if the element with the highest retry has simulated set to true, false otherwise.
380 */
381 const isResponseSimulated = ( response ) => {
382 if ( ! Array.isArray( response ) || response.length === 0 ) {
383 return false;
384 }
385 // Find the response object with the maximum "retry" value.
386 const maxRetryEntry = response.reduce( ( prev, curr ) =>
387 curr.retry >= prev.retry ? curr : prev
388 );
389 return maxRetryEntry.simulated;
390 };
391
392 /**
393 * A utility class to manipulate shadow DOM elements.
394 */
395 export class ShadowDOM {
396 element = null;
397 shadowRoot = null;
398 mode = 'open';
399
400 /**
401 * Constructor for the ShadowDOM class.
402 *
403 * @param {HTMLElement} element - The element to attach the shadow DOM to.
404 * @param {string} mode - The mode of the shadow root: 'open' or 'closed'.
405 */
406 constructor( element, mode = 'open' ) {
407 this.element = element;
408 this.mode = mode;
409 this.shadowRoot = this.attachShadow( mode );
410 }
411
412 /**
413 * Update the element to attach the shadow DOM to.
414 *
415 * @param {HTMLElement} element - The element to attach the shadow DOM to.
416 */
417 updateElement( element ) {
418 this.element = element;
419 }
420
421 /**
422 * Check if the element has a shadow root.
423 *
424 * @return {boolean} - Whether the element has a shadow root.
425 */
426 hasShadowRoot() {
427 if ( ! this.element ) {
428 return false;
429 }
430 if ( this.mode === 'closed' ) {
431 return this.shadowRoot !== null;
432 }
433 return this.element.shadowRoot !== null;
434 }
435
436 /**
437 * Attach a shadow root to the element.
438 *
439 * @param {string} mode - The mode of the shadow root: 'open' or 'closed'.
440 * @return {ShadowRoot} - The shadow root.
441 */
442 attachShadow( mode = 'open' ) {
443 if ( this.hasShadowRoot() ) {
444 return this.element.shadowRoot;
445 }
446 return this.element.attachShadow( { mode } );
447 }
448
449 /**
450 * Append a child to the shadow DOM.
451 *
452 * @param {HTMLElement} child - The child element to append.
453 * @return {HTMLElement} - The appended child element.
454 */
455 appendChild( child ) {
456 if ( ! this.hasShadowRoot() ) {
457 return;
458 }
459 if ( this.mode === 'closed' ) {
460 return this.shadowRoot.appendChild( child );
461 }
462 return this.element.shadowRoot.appendChild( child );
463 }
464
465 /**
466 * Check if the shadow DOM has child nodes.
467 *
468 * @return {boolean} - Whether the shadow DOM has child nodes.
469 */
470 hasChildNodes() {
471 if ( ! this.hasShadowRoot() ) {
472 return false;
473 }
474 if ( this.mode === 'closed' ) {
475 return this.shadowRoot.hasChildNodes();
476 }
477 return this.element.shadowRoot.hasChildNodes();
478 }
479
480 /**
481 * Set the inner HTML of the shadow DOM.
482 *
483 * @param {string} content - The content to set as the inner HTML.
484 */
485 innerHTML( content ) {
486 if ( ! this.hasShadowRoot() ) {
487 return;
488 }
489 if ( this.mode === 'closed' ) {
490 this.shadowRoot.innerHTML = content;
491 return;
492 }
493 this.element.shadowRoot.innerHTML = content;
494 }
495 }
496
497 /**
498 * Check if the string contains an HTML tag
499 *
500 * @param {string} str - The string to check
501 * @return {boolean} - Whether the string contains an HTML tag
502 */
503 export const containsHtmlTag = ( str ) => {
504 return /<[^>]*>/.test( str );
505 };
506
507 /**
508 * Converts newlines to <br/> tags
509 *
510 * @param {string} str - The string to convert
511 * @param {boolean} is_xhtml - Whether to use XHTML compatible tags
512 * @return {string} - The converted string
513 */
514 const nl2br = ( str, is_xhtml = false ) => {
515 if ( typeof str === 'undefined' || str === null ) {
516 return '';
517 }
518 const breakTag = is_xhtml ? '<br />' : '<br>';
519 return ( str + '' ).replace(
520 /([^>\r\n]?)(\r\n|\n\r|\r|\n)/g,
521 '$1' + breakTag + '$2'
522 );
523 };
524
525 /**
526 * Converts plain text to HTML with proper formatting.
527 * - Converts newlines to <br/> tags
528 * - Converts URLs to clickable links
529 * - Sanitizes output to prevent XSS
530 *
531 * @param {string} text - The plain text to convert
532 * @return {string} - Sanitized HTML string
533 */
534 export const stringToHtml = ( text ) => {
535 if ( ! text ) {
536 return '';
537 }
538 let parsedText = text;
539
540 // Check if string contains an HTML tag
541 const hasHtmlTag = containsHtmlTag( text );
542
543 // If the text/string is not HTML, convert it to HTML
544 if ( ! hasHtmlTag ) {
545 // Convert URLs to clickable links
546 const urlRegex = /(https?:\/\/[^\s]+)/g;
547 parsedText = parsedText.replace(
548 urlRegex,
549 '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>'
550 );
551
552 // Convert newlines to <br/> tags
553 parsedText = nl2br( parsedText.trim() );
554 }
555
556 // For security, override target and rel attributes to links
557 DOMPurify.addHook( 'afterSanitizeAttributes', function ( node ) {
558 // set all elements owning target to target=_blank and rel=noopener noreferrer
559 if ( 'target' in node ) {
560 node.setAttribute( 'target', '_blank' );
561 node.setAttribute( 'rel', 'noopener noreferrer' );
562 }
563 } );
564
565 // Sanitize the final HTML
566 return DOMPurify.sanitize( parsedText );
567 };
568
569 /**
570 * Get the query params from the URL
571 *
572 * @return {Object} - The query params
573 */
574 export const getQueryParams = () => {
575 try {
576 const url = new URL( window.location.href );
577 return url.searchParams;
578 } catch ( error ) {
579 return null;
580 }
581 };
582
583 /**
584 * Check if the query param is in the URL
585 *
586 * @param {string} param - The query param to check
587 * @return {boolean} - Whether the query param is in the URL
588 */
589 export const hasInQueryParams = ( param ) => {
590 try {
591 const queryParams = getQueryParams();
592 return queryParams?.has( param );
593 } catch ( error ) {
594 return false;
595 }
596 };
597
598 /**
599 * Remove the query param from the URL and replace the URL
600 *
601 * @param {string} param - The query param to remove
602 * @return {boolean} - Whether the query param was removed
603 */
604 export const removeQueryParam = ( param ) => {
605 try {
606 const url = new URL( window.location.href );
607 const queryParams = url.searchParams;
608 queryParams.delete( param );
609
610 // Construct new URL with original path and hash
611 const newUrl = `${ url.origin }${ url.pathname }`;
612 const searchString = queryParams.toString();
613 const finalUrl = searchString
614 ? `${ newUrl }?${ searchString }`
615 : newUrl;
616
617 // Append hash if it exists
618 const urlWithHash = url.hash ? `${ finalUrl }${ url.hash }` : finalUrl;
619
620 window.history.replaceState( null, '', urlWithHash );
621 } catch ( error ) {
622 return false;
623 }
624 };
625
626 /**
627 * Convert a Connection string from UTC to the browser's local time.
628 * Expects a string in the format: "Used {connection_title}, at {utcTimestamp}"
629 *
630 * @param {string} connectionStr
631 */
632 export const convertUTCConnection = ( connectionStr ) => {
633 const lastAtIndex = connectionStr.lastIndexOf( ', at ' );
634 if ( lastAtIndex === -1 ) {
635 return connectionStr;
636 }
637
638 const utcTimestamp = connectionStr.substring( lastAtIndex + 5 ).trim();
639 const utcDate = new Date( utcTimestamp + ' UTC' );
640
641 if ( isNaN( utcDate.getTime() ) ) {
642 return connectionStr;
643 }
644
645 const localTimestamp = utcDate.toLocaleString( 'en-US', {
646 month: 'short',
647 day: 'numeric',
648 year: 'numeric',
649 hour: 'numeric',
650 minute: '2-digit',
651 hour12: true,
652 } );
653
654 return (
655 connectionStr.substring( 0, lastAtIndex ) + `, at ${ localTimestamp }`
656 );
657 };
658
659 export const get_connection_message = ( connection_title, timeStamp ) => {
660 const formattedTimeStamp = formatDate( timeStamp, {
661 day: true,
662 month: true,
663 year: true,
664 hour: true,
665 minute: true,
666 hour12: true,
667 } );
668 return `Used ${ connection_title }, at ${ formattedTimeStamp }`;
669 };
670
671 /**
672 * Check if the status is pending or not
673 *
674 * @param {string} status - The status of the log
675 * @return {boolean} - Whether the status is pending
676 */
677 export const get_pending_status = ( status ) => {
678 if ( status && status === 'pending' ) {
679 return true;
680 }
681 return false;
682 };
683