@syncello/auth 4.1.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -297,6 +297,29 @@ A `verify` hook that throws is logged and treated as "not invited": invitation m
297
297
  OAuth sign-up in open mode goes ahead unlinked, and a password signup with the token answers
298
298
  `400`.
299
299
 
300
+ ## Custom Emails
301
+
302
+ `renderEmail` puts an app's own emails in the same template as the auth emails. Every value is
303
+ plain text and escaped; every URL must be http(s) on `appUrl`'s host or a subdomain.
304
+
305
+ ```typescript
306
+ import { renderEmail } from '@syncello/auth';
307
+
308
+ const html = renderEmail({
309
+ heading: 'Your daily digest',
310
+ paragraphs: ['Here is what changed today.'],
311
+ sections: [
312
+ { title: 'Acme', url: `${appUrl}/accounts/1`, rows: [{ text: 'PO 1001 shipped', url: `${appUrl}/orders/1001` }] },
313
+ ],
314
+ button: { label: 'Open the portal', url: `${appUrl}/orders` },
315
+ notice: 'This link expires in 7 days.',
316
+ footerNote: 'You get this because you follow Acme.',
317
+ appUrl,
318
+ appName: 'My App',
319
+ branding: emailBranding,
320
+ });
321
+ ```
322
+
300
323
  ## Direct Imports
301
324
 
302
325
  For advanced usage, you can import utilities directly:
@@ -319,6 +342,20 @@ import {
319
342
 
320
343
  ## Release Notes
321
344
 
345
+ ### 4.2.0
346
+
347
+ - `renderEmail` renders an app's own emails (invitations, notifications, digests) in the
348
+ template the auth emails use, from plain text: a heading, paragraphs, optional sections of
349
+ rows, a button, a notice, a footer note and `EmailBranding`. It escapes every value and
350
+ refuses links off the app's host (see Custom Emails).
351
+ - `emailBranding.logoWidth` and `logoHeight` (CSS px, positive integers, given together): the
352
+ header logo renders at that fixed size, with `width` and `height` attributes so mail clients
353
+ reserve its space and Outlook sizes it right, instead of filling half the email's width.
354
+ - A cleaner template for every email, with no new options needed: a white card on a light grey
355
+ page, a left-aligned heading and body, the notice as a grey panel edged in `headingColor`
356
+ instead of the cyan box, and the link to copy small and grey under a thin rule. URLs in the
357
+ markup are now HTML-escaped (`&` is written `&`), which browsers read as the same URL.
358
+
322
359
  ### 4.1.0
323
360
 
324
361
  - `emailBranding.marketingUrl`: the site the email footer's Privacy Policy (`/privacy`) and Get
package/dist/index.cjs CHANGED
@@ -121,6 +121,7 @@ __export(src_exports, {
121
121
  rateLimit: () => rateLimit,
122
122
  recentPasswordlessFailures: () => recentPasswordlessFailures,
123
123
  refreshSession: () => refreshSession,
124
+ renderEmail: () => renderEmail,
124
125
  requestEmailChange: () => requestEmailChange,
125
126
  requireAuth: () => requireAuth,
126
127
  requireVerifiedEmail: () => requireVerifiedEmail,
@@ -1417,36 +1418,140 @@ function escapeHtml(text) {
1417
1418
  }
1418
1419
 
1419
1420
  // src/core/email-template.ts
1420
- function generateEmailTemplate(options) {
1421
- const {
1422
- heading,
1423
- body,
1424
- buttonText,
1425
- buttonUrl,
1426
- welcomeMessage,
1427
- securityWarning,
1428
- footerNote,
1429
- appUrl,
1430
- marketingUrl,
1431
- appName = "App",
1432
- buttonBgColor = "#00fe9a",
1433
- buttonTextColor = "#000004",
1434
- buttonFontWeight = 500,
1435
- buttonRadius = "6px",
1436
- logoUrl,
1437
- footerColor = "#1e1b4b",
1438
- headingColor = "#1e1b4b"
1439
- } = options;
1440
- const safeAppName = escapeHtml(appName);
1441
- const safeButtonUrl = buttonUrl ? sanitizeEmailUrl(buttonUrl, appUrl) : "";
1442
- const safeAppUrl = appUrl ? sanitizeEmailUrl(appUrl, appUrl) : "";
1443
- const safeMarketingUrl = marketingUrl ? sanitizeEmailUrl(marketingUrl, marketingUrl).replace(/\/$/, "") : "";
1444
- const safeLogoUrl = logoUrl ? sanitizeEmailUrl(logoUrl, appUrl) : `${safeAppUrl.replace(/\/$/, "")}/email-header-image.png`;
1421
+ var FONT = "'Montserrat',Arial,sans-serif";
1422
+ var PAGE = "#f4f5f7";
1423
+ var BODY_TEXT = "#404040";
1424
+ var MUTED = "#737373";
1425
+ var RULE = "#e5e7eb";
1426
+ var TABLE = 'width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;"';
1427
+ function logoDimensions(width, height) {
1428
+ if (width === void 0 && height === void 0) return null;
1429
+ if (width === void 0 || height === void 0) {
1430
+ throw new Error("logoWidth and logoHeight must be given together");
1431
+ }
1432
+ return { width: pixels("logoWidth", width), height: pixels("logoHeight", height) };
1433
+ }
1434
+ function pixels(name, value) {
1435
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
1436
+ throw new Error(`${name} must be a positive integer`);
1437
+ }
1438
+ return escapeHtml(String(value));
1439
+ }
1440
+ function brandColours(branding) {
1441
+ return {
1442
+ buttonBgColor: escapeHtml(branding.buttonBgColor ?? "#00fe9a"),
1443
+ buttonTextColor: escapeHtml(branding.buttonTextColor ?? "#000004"),
1444
+ buttonFontWeight: escapeHtml(String(branding.buttonFontWeight ?? 500)),
1445
+ buttonRadius: escapeHtml(branding.buttonRadius ?? "6px"),
1446
+ footerColor: escapeHtml(branding.footerColor ?? "#1e1b4b"),
1447
+ headingColor: escapeHtml(branding.headingColor ?? "#1e1b4b")
1448
+ };
1449
+ }
1450
+ function logoUrlFor(logoUrl, appUrl) {
1451
+ if (logoUrl) return escapeHtml(sanitizeEmailUrl(logoUrl, appUrl));
1452
+ const base = appUrl ? sanitizeEmailUrl(appUrl, appUrl) : "";
1453
+ return escapeHtml(`${base.replace(/\/$/, "")}/email-header-image.png`);
1454
+ }
1455
+ function marketingUrlFor(marketingUrl) {
1456
+ return marketingUrl ? escapeHtml(sanitizeEmailUrl(marketingUrl, marketingUrl).replace(/\/$/, "")) : "";
1457
+ }
1458
+ var cardRow = (padding, content) => `
1459
+ <table ${TABLE}>
1460
+ <tr>
1461
+ <td class="pad" style="${padding}">
1462
+ ${content}
1463
+ </td>
1464
+ </tr>
1465
+ </table>`;
1466
+ function logoBlock({ logoUrl, logoSize, appName }) {
1467
+ const img = logoSize ? `<img src="${logoUrl}" width="${logoSize.width}" height="${logoSize.height}" style="width: ${logoSize.width}px; max-width: 100%; height: auto; display: block; border: 0;" alt="${appName}" title="${appName}">` : `<div class="fullWidth" style="max-width: 50%;">
1468
+ <img src="${logoUrl}" style="display: block; height: auto; border: 0; width: 100%;" alt="${appName}" title="${appName}" height="auto">
1469
+ </div>`;
1470
+ return `
1471
+ <!-- Header Image -->
1472
+ <table class="image_block" ${TABLE}>
1473
+ <tr>
1474
+ <td class="pad" style="padding-top:40px;padding-left:40px;padding-right:40px;padding-bottom:8px;">
1475
+ <div class="alignment" align="center">
1476
+ ${img}
1477
+ </div>
1478
+ </td>
1479
+ </tr>
1480
+ </table>`;
1481
+ }
1482
+ function buttonBlock({ label, url }, brand) {
1483
+ const font = `font-family: ${FONT}; font-size: 16px; font-weight: ${brand.buttonFontWeight};`;
1484
+ return `
1485
+ <!-- Button -->${cardRow(
1486
+ "padding-top:8px;padding-bottom:32px;padding-left:40px;padding-right:40px;text-align:center;",
1487
+ `<div class="alignment" align="center">
1488
+ <!--[if mso]>
1489
+ <v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="${url}" style="height:48px;width:auto;v-text-anchor:middle;" arcsize="12%" fillcolor="${brand.buttonBgColor}">
1490
+ <v:stroke dashstyle="Solid" weight="0px" color="${brand.buttonBgColor}"/>
1491
+ <w:anchorlock/>
1492
+ <v:textbox inset="0px,0px,0px,0px">
1493
+ <center dir="false" style="color:${brand.buttonTextColor};${font}">
1494
+ <![endif]-->
1495
+ <a href="${url}" style="background-color: ${brand.buttonBgColor}; border: 0px solid transparent; border-radius: ${brand.buttonRadius}; color: ${brand.buttonTextColor}; display: inline-block; ${font} line-height: 24px; mso-border-alt: none; padding: 12px 28px; text-align: center; text-decoration: none; word-break: keep-all;">${label}</a>
1496
+ <!--[if mso]>
1497
+ </center>
1498
+ </v:textbox>
1499
+ </v:roundrect>
1500
+ <![endif]-->
1501
+ </div>`
1502
+ )}
1503
+
1504
+ <!-- Copy Link -->${cardRow(
1505
+ "padding-bottom:24px;padding-left:40px;padding-right:40px;",
1506
+ `<div style="border-top: 1px solid ${RULE}; padding-top: 16px; color:${MUTED};font-family:${FONT};font-size:12px;line-height:1.5;text-align:left;mso-line-height-alt:18px;">
1507
+ <p style="margin: 0 0 4px 0; word-break: break-word;">Or copy and paste this link into your browser:</p>
1508
+ <a href="${url}" style="color:${MUTED};text-decoration:underline;word-break:break-all;">${url}</a>
1509
+ </div>`
1510
+ )}`;
1511
+ }
1512
+ function renderLayout(parts) {
1513
+ const { brand } = parts;
1514
+ const welcome = parts.welcome ? `
1515
+ <!-- Welcome Message -->${cardRow(
1516
+ "padding-top:24px;padding-left:40px;padding-right:40px;",
1517
+ `<div style="color:${brand.headingColor};font-family:${FONT};font-size:20px;font-weight:600;line-height:1.4;text-align:left;mso-line-height-alt:28px;">
1518
+ <p style="margin: 0; word-break: break-word;">${parts.welcome}</p>
1519
+ </div>`
1520
+ )}` : "";
1521
+ const heading = parts.heading ? `
1522
+ <!-- Heading -->${cardRow(
1523
+ "padding-top:24px;padding-left:40px;padding-right:40px;",
1524
+ `<div style="color:${brand.headingColor};font-family:${FONT};font-size:24px;font-weight:700;line-height:1.3;text-align:left;mso-line-height-alt:31px;">
1525
+ <p style="margin: 0; word-break: break-word;">${parts.heading}</p>
1526
+ </div>`
1527
+ )}` : "";
1528
+ const notice = parts.notice ? `
1529
+ <!-- Notice -->${cardRow(
1530
+ "padding-bottom:24px;padding-left:40px;padding-right:40px;",
1531
+ `<table ${TABLE.replace('style="', `style="background-color: ${PAGE}; border-left: 3px solid ${brand.headingColor}; border-radius: 4px; `)}>
1532
+ <tr>
1533
+ <td style="padding: 14px 16px; color: ${BODY_TEXT}; font-family: ${FONT}; font-size: 13px; line-height: 1.5; text-align: left;">
1534
+ ${parts.notice}
1535
+ </td>
1536
+ </tr>
1537
+ </table>`
1538
+ )}` : "";
1539
+ const footerNote = parts.footerNote ? `
1540
+ <!-- Footer Note -->${cardRow(
1541
+ "padding-bottom:32px;padding-left:40px;padding-right:40px;",
1542
+ `<div style="color:${MUTED};font-family:${FONT};font-size:13px;line-height:1.5;text-align:left;mso-line-height-alt:20px;">
1543
+ <p style="margin: 0; word-break: break-word;">${parts.footerNote}</p>
1544
+ </div>`
1545
+ )}` : "";
1546
+ const links = parts.marketingUrl ? `
1547
+ <p style="margin: 0 0 12px 0; word-break: break-word;">
1548
+ <a href="${parts.marketingUrl}/privacy" style="color: #ffffff; text-decoration: underline;">Privacy Policy</a> &nbsp;|&nbsp; <a href="${parts.marketingUrl}/contact" style="color: #ffffff; text-decoration: underline;">Get in Touch</a>
1549
+ </p>` : "";
1445
1550
  return `
1446
1551
  <!DOCTYPE html>
1447
1552
  <html xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" lang="en">
1448
1553
  <head>
1449
- <title>${heading}</title>
1554
+ <title>${parts.title}</title>
1450
1555
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
1451
1556
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1452
1557
  <!--[if mso]>
@@ -1461,7 +1566,7 @@ function generateEmailTemplate(options) {
1461
1566
  </xml>
1462
1567
  <![endif]-->
1463
1568
  <!--[if !mso]><!-->
1464
- <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap" rel="stylesheet" type="text/css">
1569
+ <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap" rel="stylesheet" type="text/css">
1465
1570
  <!--<![endif]-->
1466
1571
  <style>
1467
1572
  * {
@@ -1482,13 +1587,6 @@ function generateEmailTemplate(options) {
1482
1587
  p {
1483
1588
  line-height: inherit;
1484
1589
  }
1485
- .desktop_hide,
1486
- .desktop_hide table {
1487
- mso-hide: all;
1488
- display: none;
1489
- max-height: 0px;
1490
- overflow: hidden;
1491
- }
1492
1590
  .image_block img+div {
1493
1591
  display: none;
1494
1592
  }
@@ -1497,41 +1595,16 @@ function generateEmailTemplate(options) {
1497
1595
  line-height: 0;
1498
1596
  }
1499
1597
  @media (max-width:660px) {
1500
- .desktop_hide table.icons-inner {
1501
- display: inline-block !important;
1502
- }
1503
- .icons-inner {
1504
- text-align: center;
1505
- }
1506
- .icons-inner td {
1507
- margin: 0 auto;
1508
- }
1509
- .mobile_hide {
1510
- display: none;
1598
+ .page {
1599
+ padding: 16px 8px !important;
1511
1600
  }
1512
1601
  .row-content {
1513
1602
  width: 100% !important;
1514
1603
  max-width: 100% !important;
1515
1604
  }
1516
- .stack .column {
1517
- width: 100%;
1518
- display: block;
1519
- }
1520
- .mobile_hide {
1521
- min-height: 0;
1522
- max-height: 0;
1523
- max-width: 0;
1524
- overflow: hidden;
1525
- font-size: 0px;
1526
- }
1527
- .desktop_hide,
1528
- .desktop_hide table {
1529
- display: table !important;
1530
- max-height: none !important;
1531
- }
1532
1605
  .pad {
1533
- padding-left: 20px !important;
1534
- padding-right: 20px !important;
1606
+ padding-left: 24px !important;
1607
+ padding-right: 24px !important;
1535
1608
  }
1536
1609
  }
1537
1610
  </style>
@@ -1543,235 +1616,46 @@ function generateEmailTemplate(options) {
1543
1616
  </style>
1544
1617
  <![endif]-->
1545
1618
  </head>
1546
- <body class="body" style="background-color: #f8f9fa; margin: 0; padding: 0; -webkit-text-size-adjust: none; text-size-adjust: none;">
1547
- <table class="nl-container" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #f8f9fa;">
1619
+ <body class="body" style="background-color: ${PAGE}; margin: 0; padding: 0; -webkit-text-size-adjust: none; text-size-adjust: none;">
1620
+ <table class="nl-container" ${TABLE.replace('style="', `style="background-color: ${PAGE}; `)}>
1548
1621
  <tbody>
1549
1622
  <tr>
1550
- <td>
1551
- <!-- Main Content Row -->
1552
- <table class="row row-1" align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;">
1623
+ <td class="page" style="padding: 32px 16px;">
1624
+ <!-- Card -->
1625
+ <table class="row-content" align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #ffffff; border: 1px solid ${RULE}; border-radius: 12px; max-width: 640px; width: 100%; margin: 0 auto;">
1553
1626
  <tbody>
1554
1627
  <tr>
1555
- <td>
1556
- <table class="row-content stack" align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #ffffff; color: #000000; max-width: 640px; width: 100%; margin: 0 auto;">
1557
- <tbody>
1558
- <tr>
1559
- <td class="column column-1" width="100%" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; font-weight: 400; text-align: left; vertical-align: top;">
1560
-
1561
- <!-- Top Spacer -->
1562
- <table class="divider_block block-1" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;">
1563
- <tr>
1564
- <td class="pad" style="padding-bottom:12px;padding-top:30px;">
1565
- <div class="alignment" align="center">
1566
- <table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;">
1567
- <tr>
1568
- <td class="divider_inner" style="font-size: 1px; line-height: 1px; border-top: 0px solid #BBBBBB;">
1569
- <span style="word-break: break-word;">&#8202;</span>
1570
- </td>
1571
- </tr>
1572
- </table>
1573
- </div>
1574
- </td>
1575
- </tr>
1576
- </table>
1628
+ <td width="100%" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; text-align: left; vertical-align: top;">
1629
+ ${logoBlock(parts)}
1630
+ ${welcome}
1631
+ ${heading}
1632
+
1633
+ <!-- Body Text -->${cardRow(
1634
+ "padding-top:16px;padding-bottom:24px;padding-left:40px;padding-right:40px;",
1635
+ `<div style="color:${BODY_TEXT};font-family:${FONT};font-size:15px;line-height:1.6;text-align:left;mso-line-height-alt:24px;">
1636
+ ${parts.body}
1637
+ </div>`
1638
+ )}
1639
+ ${parts.button ? buttonBlock(parts.button, brand) : ""}
1640
+ ${notice}
1641
+ ${footerNote}
1577
1642
 
1578
- <!-- Header Image -->
1579
- <table class="image_block block-2" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;">
1580
- <tr>
1581
- <td class="pad" style="padding-left:40px;padding-right:40px;width:100%;">
1582
- <div class="alignment" align="center">
1583
- <div class="fullWidth" style="max-width: 50%;">
1584
- <img src="${safeLogoUrl}" style="display: block; height: auto; border: 0; width: 100%;" alt="${safeAppName}" title="${safeAppName}" height="auto">
1585
- </div>
1586
- </div>
1587
- </td>
1588
- </tr>
1589
- </table>
1590
-
1591
- ${welcomeMessage ? `
1592
- <!-- Welcome Message -->
1593
- <table class="paragraph_block" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; word-break: break-word;">
1594
- <tr>
1595
- <td class="pad" style="padding-top:30px;padding-left:40px;padding-right:40px;">
1596
- <div style="color:${headingColor};font-family:'Montserrat',Arial,sans-serif;font-size:22px;font-weight:600;line-height:1.4;text-align:center;mso-line-height-alt:31px;">
1597
- <p style="margin: 0; word-break: break-word;">${welcomeMessage}</p>
1598
- </div>
1599
- </td>
1600
- </tr>
1601
- </table>
1602
- ` : ""}
1603
-
1604
- ${heading ? `
1605
- <!-- Heading -->
1606
- <table class="paragraph_block block-4" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; word-break: break-word;">
1607
- <tr>
1608
- <td class="pad" style="padding-bottom:10px;padding-left:40px;padding-right:40px;padding-top:10px;">
1609
- <div style="color:${headingColor};font-family:'Montserrat',Arial,sans-serif;font-size:30px;font-weight:700;line-height:1.2;text-align:center;mso-line-height-alt:36px;">
1610
- <p style="margin: 0; word-break: break-word;">${heading}</p>
1611
- </div>
1612
- </td>
1613
- </tr>
1614
- </table>
1615
- ` : ""}
1616
-
1617
- <!-- Body Text -->
1618
- <table class="paragraph_block block-5" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; word-break: break-word;">
1619
- <tr>
1620
- <td class="pad" style="padding-bottom:30px;padding-left:40px;padding-right:40px;padding-top:30px;">
1621
- <div style="color:#404040;font-family:'Montserrat',Arial,sans-serif;font-size:15px;line-height:1.6;text-align:left;mso-line-height-alt:24px;">
1622
- <p style="margin: 0; word-break: break-word;">${body}</p>
1623
- </div>
1624
- </td>
1625
- </tr>
1626
- </table>
1627
-
1628
- ${safeButtonUrl ? `
1629
- <!-- Button -->
1630
- <table class="button_block block-6" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;">
1631
- <tr>
1632
- <td class="pad" style="padding-left:10px;padding-right:10px;padding-bottom:10px;text-align:center;">
1633
- <div class="alignment" align="center">
1634
- <!--[if mso]>
1635
- <v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="${safeButtonUrl}" style="height:52px;width:auto;v-text-anchor:middle;" arcsize="12%" fillcolor="${buttonBgColor}">
1636
- <v:stroke dashstyle="Solid" weight="0px" color="${buttonBgColor}"/>
1637
- <w:anchorlock/>
1638
- <v:textbox inset="0px,0px,0px,0px">
1639
- <center dir="false" style="color:${buttonTextColor};font-family:'Montserrat',Arial,sans-serif;font-size:16px;font-weight:${buttonFontWeight};">
1640
- <![endif]-->
1641
- <a href="${safeButtonUrl}" style="background-color: ${buttonBgColor}; border: 0px solid transparent; border-radius: ${buttonRadius}; color: ${buttonTextColor}; display: inline-block; font-family: 'Montserrat', Arial, sans-serif; font-size: 16px; font-weight: ${buttonFontWeight}; mso-border-alt: none; padding: 10px 20px; text-align: center; text-decoration: none; text-transform: capitalize; word-break: keep-all;">
1642
- ${buttonText}
1643
- </a>
1644
- <!--[if mso]>
1645
- </center>
1646
- </v:textbox>
1647
- </v:roundrect>
1648
- <![endif]-->
1649
- </div>
1650
- </td>
1651
- </tr>
1652
- </table>
1653
-
1654
- <!-- Copy Link -->
1655
- <table class="paragraph_block" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; word-break: break-word;">
1656
- <tr>
1657
- <td class="pad" style="padding-bottom:30px;padding-left:40px;padding-right:40px;">
1658
- <div style="color:#737373;font-family:'Montserrat',Arial,sans-serif;font-size:12px;line-height:1.5;text-align:center;mso-line-height-alt:18px;">
1659
- <p style="margin: 0 0 5px 0; word-break: break-word;">or copy and paste this link into your browser:</p>
1660
- <a href="${safeButtonUrl}" style="color:#4a90d9;text-decoration:underline;word-break:break-all;">${safeButtonUrl}</a>
1661
- </div>
1662
- </td>
1663
- </tr>
1664
- </table>
1665
- ` : ""}
1666
-
1667
- ${securityWarning ? `
1668
- <!-- Security Warning -->
1669
- <table class="paragraph_block" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; word-break: break-word;">
1670
- <tr>
1671
- <td class="pad" style="padding-left:40px;padding-right:40px;padding-bottom:20px;">
1672
- <div style="background-color: #a1f1ff; border-radius: 8px; padding: 16px; display: flex; align-items: center; justify-content: center;">
1673
- <p style="margin: 0; color: #01001b; font-family: 'Montserrat', Arial, sans-serif; font-size: 13px; line-height: 1.5; text-align: center;">
1674
- ${securityWarning}
1675
- </p>
1676
- </div>
1677
- </td>
1678
- </tr>
1679
- </table>
1680
- ` : ""}
1681
-
1682
- <!-- Footer Note -->
1683
- <table class="paragraph_block" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; word-break: break-word;">
1684
- <tr>
1685
- <td class="pad" style="padding-bottom:30px;padding-left:40px;padding-right:40px;">
1686
- <div style="color:#737373;font-family:'Montserrat',Arial,sans-serif;font-size:13px;line-height:1.5;text-align:center;mso-line-height-alt:20px;">
1687
- <p style="margin: 0; word-break: break-word;">${footerNote}</p>
1688
- </div>
1689
- </td>
1690
- </tr>
1691
- </table>
1692
-
1693
- </td>
1694
- </tr>
1695
- </tbody>
1696
- </table>
1697
1643
  </td>
1698
1644
  </tr>
1699
1645
  </tbody>
1700
1646
  </table>
1701
1647
 
1702
- <!-- Spacer Row -->
1703
- <table class="row row-2" align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;">
1648
+ <!-- Footer -->
1649
+ <table class="row-content" align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: ${brand.footerColor}; border-radius: 12px; max-width: 640px; width: 100%; margin: 16px auto 0 auto;">
1704
1650
  <tbody>
1705
1651
  <tr>
1706
- <td>
1707
- <table class="row-content stack" align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: #f8f9fa; color: #000000; max-width: 640px; width: 100%; margin: 0 auto;">
1708
- <tbody>
1709
- <tr>
1710
- <td class="column column-1" width="100%" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; font-weight: 400; text-align: left; padding-bottom: 5px; vertical-align: top;">
1711
- <table class="empty_block block-1" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;">
1712
- <tr>
1713
- <td class="pad">
1714
- <div></div>
1715
- </td>
1716
- </tr>
1717
- </table>
1718
- </td>
1719
- </tr>
1720
- </tbody>
1721
- </table>
1722
- </td>
1723
- </tr>
1724
- </tbody>
1725
- </table>
1726
-
1727
- <!-- Footer Row -->
1728
- <table class="row row-3" align="center" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;">
1729
- <tbody>
1730
- <tr>
1731
- <td>
1732
- <table class="row-content stack" align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; background-color: ${footerColor}; color: #000000; max-width: 640px; width: 100%; margin: 0 auto;">
1733
- <tbody>
1734
- <tr>
1735
- <td class="column column-1" width="100%" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; font-weight: 400; text-align: left; vertical-align: top; padding: 20px;">
1736
-
1737
- ${safeMarketingUrl ? `
1738
- <!-- Policy Links -->
1739
- <table class="paragraph_block" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; word-break: break-word;">
1740
- <tr>
1741
- <td class="pad" style="padding-bottom:20px;padding-left:40px;padding-right:40px;padding-top:20px;">
1742
- <div style="color:#ffffff;font-family:'Montserrat',Arial,sans-serif;font-size:13px;line-height:1.5;text-align:center;mso-line-height-alt:20px;">
1743
- <p style="margin: 0; word-break: break-word;">
1744
- <a href="${safeMarketingUrl}/privacy" style="color: #ffffff; text-decoration: underline;">Privacy Policy</a> | <a href="${safeMarketingUrl}/contact" style="color: #ffffff; text-decoration: underline;">Get in Touch</a>
1745
- </p>
1746
- </div>
1747
- </td>
1748
- </tr>
1749
- </table>
1750
- ` : ""}
1751
-
1752
- <!-- Copyright & Disclaimer -->
1753
- <table class="paragraph_block block-1" width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; word-break: break-word;">
1754
- <tr>
1755
- <td class="pad" style="padding-bottom:15px;padding-left:40px;padding-right:40px;">
1756
- <div style="color:#a3a3a3;font-family:'Montserrat',Arial,sans-serif;font-size:12px;line-height:1.5;text-align:center;mso-line-height-alt:18px;">
1757
- <p style="margin: 0 0 12px 0; word-break: break-word;">\xA9 ${(/* @__PURE__ */ new Date()).getFullYear()} ${safeAppName}. All rights reserved.</p>
1758
- <p style="margin: 0; font-size: 11px; line-height: 1.5; word-break: break-word;">
1759
- Please do not reply to this email as this address does not accept incoming messages. This service email contains essential information relating to your ${safeAppName} account.
1760
- </p>
1761
- </div>
1762
- </td>
1763
- </tr>
1764
- </table>
1765
-
1766
- </td>
1767
- </tr>
1768
- </tbody>
1769
- </table>
1652
+ <td class="pad" style="padding: 24px 40px; color:#a3a3a3; font-family:${FONT}; font-size:12px; line-height:1.5; text-align:center; mso-line-height-alt:18px;">${links}
1653
+ <p style="margin: 0 0 8px 0; word-break: break-word;">\xA9 ${(/* @__PURE__ */ new Date()).getFullYear()} ${parts.appName}. All rights reserved.</p>
1654
+ <p style="margin: 0; font-size: 11px; word-break: break-word;">Please do not reply to this email as this address does not accept incoming messages. This service email contains essential information relating to your ${parts.appName} account.</p>
1770
1655
  </td>
1771
1656
  </tr>
1772
1657
  </tbody>
1773
1658
  </table>
1774
-
1775
1659
  </td>
1776
1660
  </tr>
1777
1661
  </tbody>
@@ -1780,6 +1664,55 @@ function generateEmailTemplate(options) {
1780
1664
  </html>
1781
1665
  `;
1782
1666
  }
1667
+ function generateEmailTemplate(options) {
1668
+ const { appUrl, buttonUrl } = options;
1669
+ const safeButtonUrl = buttonUrl ? escapeHtml(sanitizeEmailUrl(buttonUrl, appUrl)) : "";
1670
+ return renderLayout({
1671
+ title: options.heading,
1672
+ logoUrl: logoUrlFor(options.logoUrl, appUrl),
1673
+ logoSize: logoDimensions(options.logoWidth, options.logoHeight),
1674
+ appName: escapeHtml(options.appName ?? "App"),
1675
+ welcome: options.welcomeMessage ?? "",
1676
+ heading: options.heading,
1677
+ body: options.body,
1678
+ button: safeButtonUrl ? { label: options.buttonText, url: safeButtonUrl } : null,
1679
+ notice: options.securityWarning?.trim() ?? "",
1680
+ footerNote: options.footerNote,
1681
+ marketingUrl: marketingUrlFor(options.marketingUrl),
1682
+ brand: brandColours(options)
1683
+ });
1684
+ }
1685
+ function renderEmail(content) {
1686
+ const { appUrl, branding = {} } = content;
1687
+ const link = (url) => escapeHtml(sanitizeEmailUrl(url, appUrl));
1688
+ const text = (value) => escapeHtml(value).replace(/\r?\n/g, "<br>");
1689
+ const brand = brandColours(branding);
1690
+ const paragraphs = content.paragraphs.map(
1691
+ (p) => `<p style="margin: 0 0 16px 0; word-break: break-word;">${text(p)}</p>`
1692
+ );
1693
+ const sections = (content.sections ?? []).map((section) => {
1694
+ const title = section.url ? `<a href="${link(section.url)}" style="color: ${brand.headingColor}; font-weight: 600; text-decoration: none;">${text(section.title)}</a>` : `<span style="color: ${brand.headingColor}; font-weight: 600;">${text(section.title)}</span>`;
1695
+ const rows = section.rows.map(
1696
+ (row) => `<li style="margin: 0 0 4px 0;">${row.url ? `<a href="${link(row.url)}" style="color: ${BODY_TEXT}; text-decoration: underline;">${text(row.text)}</a>` : text(row.text)}</li>`
1697
+ );
1698
+ return `<p style="margin: 8px 0 4px 0; word-break: break-word;">${title}</p>
1699
+ <ul style="margin: 0 0 16px 0; padding-left: 20px;">${rows.join("")}</ul>`;
1700
+ });
1701
+ return renderLayout({
1702
+ title: escapeHtml(content.heading),
1703
+ logoUrl: logoUrlFor(branding.logoUrl, appUrl),
1704
+ logoSize: logoDimensions(branding.logoWidth, branding.logoHeight),
1705
+ appName: escapeHtml(content.appName ?? "App"),
1706
+ welcome: "",
1707
+ heading: escapeHtml(content.heading),
1708
+ body: [...paragraphs, ...sections].join("\n "),
1709
+ button: content.button ? { label: escapeHtml(content.button.label), url: link(content.button.url) } : null,
1710
+ notice: content.notice ? text(content.notice) : "",
1711
+ footerNote: content.footerNote ? text(content.footerNote) : "",
1712
+ marketingUrl: marketingUrlFor(branding.marketingUrl),
1713
+ brand
1714
+ });
1715
+ }
1783
1716
 
1784
1717
  // src/core/passwordless.ts
1785
1718
  var PASSWORDLESS_TTL_MS = 10 * 60 * 1e3;
@@ -6461,6 +6394,7 @@ function verifyWebhookSignature(payload, headers, secret) {
6461
6394
  rateLimit,
6462
6395
  recentPasswordlessFailures,
6463
6396
  refreshSession,
6397
+ renderEmail,
6464
6398
  requestEmailChange,
6465
6399
  requireAuth,
6466
6400
  requireVerifiedEmail,