@syncello/auth 4.0.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/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,
@@ -680,6 +681,9 @@ var AUTH_DEFAULTS = {
680
681
  LOCKOUT_DURATION_MINUTES: 30,
681
682
  LOCKOUT_MAX_ATTEMPTS: 5,
682
683
  PASSWORD_RESET_TTL_MINUTES: 15,
684
+ EMAIL_VERIFICATION_TTL_HOURS: 24,
685
+ EMAIL_CHANGE_TTL_HOURS: 24,
686
+ TWO_FACTOR_EMAIL_CODE_TTL_MINUTES: 5,
683
687
  TWO_FACTOR_CHALLENGE_TTL_SECONDS: 300,
684
688
  // 5 minutes
685
689
  APP_NAME: "App"
@@ -1169,7 +1173,7 @@ async function verifyTurnstileToken(token, secretKey, remoteIp, environment) {
1169
1173
 
1170
1174
  // src/core/email-change.ts
1171
1175
  var import_drizzle_orm3 = require("drizzle-orm");
1172
- var TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1e3;
1176
+ var TOKEN_EXPIRY_MS = AUTH_DEFAULTS.EMAIL_CHANGE_TTL_HOURS * 60 * 60 * 1e3;
1173
1177
  async function requestEmailChange(params) {
1174
1178
  const { userId, password, newEmail, pepper, db, tables: tables2 } = params;
1175
1179
  const { users, emailChangeTokens } = tables2;
@@ -1414,37 +1418,140 @@ function escapeHtml(text) {
1414
1418
  }
1415
1419
 
1416
1420
  // src/core/email-template.ts
1417
- function generateEmailTemplate(options) {
1418
- const {
1419
- heading,
1420
- body,
1421
- buttonText,
1422
- buttonUrl,
1423
- welcomeMessage,
1424
- securityWarning,
1425
- footerNote,
1426
- appUrl,
1427
- marketingUrl = appUrl,
1428
- // Default to appUrl if no marketing site
1429
- appName = "App",
1430
- buttonBgColor = "#00fe9a",
1431
- buttonTextColor = "#000004",
1432
- buttonFontWeight = 500,
1433
- buttonRadius = "6px",
1434
- logoUrl,
1435
- footerColor = "#1e1b4b",
1436
- headingColor = "#1e1b4b"
1437
- } = options;
1438
- const safeAppName = escapeHtml(appName);
1439
- const safeButtonUrl = buttonUrl ? sanitizeEmailUrl(buttonUrl, appUrl) : "";
1440
- const safeAppUrl = appUrl ? sanitizeEmailUrl(appUrl, appUrl) : "";
1441
- const safeMarketingUrl = marketingUrl ? sanitizeEmailUrl(marketingUrl, appUrl) : "";
1442
- 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>` : "";
1443
1550
  return `
1444
1551
  <!DOCTYPE html>
1445
1552
  <html xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" lang="en">
1446
1553
  <head>
1447
- <title>${heading}</title>
1554
+ <title>${parts.title}</title>
1448
1555
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
1449
1556
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1450
1557
  <!--[if mso]>
@@ -1459,7 +1566,7 @@ function generateEmailTemplate(options) {
1459
1566
  </xml>
1460
1567
  <![endif]-->
1461
1568
  <!--[if !mso]><!-->
1462
- <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">
1463
1570
  <!--<![endif]-->
1464
1571
  <style>
1465
1572
  * {
@@ -1480,13 +1587,6 @@ function generateEmailTemplate(options) {
1480
1587
  p {
1481
1588
  line-height: inherit;
1482
1589
  }
1483
- .desktop_hide,
1484
- .desktop_hide table {
1485
- mso-hide: all;
1486
- display: none;
1487
- max-height: 0px;
1488
- overflow: hidden;
1489
- }
1490
1590
  .image_block img+div {
1491
1591
  display: none;
1492
1592
  }
@@ -1495,41 +1595,16 @@ function generateEmailTemplate(options) {
1495
1595
  line-height: 0;
1496
1596
  }
1497
1597
  @media (max-width:660px) {
1498
- .desktop_hide table.icons-inner {
1499
- display: inline-block !important;
1500
- }
1501
- .icons-inner {
1502
- text-align: center;
1503
- }
1504
- .icons-inner td {
1505
- margin: 0 auto;
1506
- }
1507
- .mobile_hide {
1508
- display: none;
1598
+ .page {
1599
+ padding: 16px 8px !important;
1509
1600
  }
1510
1601
  .row-content {
1511
1602
  width: 100% !important;
1512
1603
  max-width: 100% !important;
1513
1604
  }
1514
- .stack .column {
1515
- width: 100%;
1516
- display: block;
1517
- }
1518
- .mobile_hide {
1519
- min-height: 0;
1520
- max-height: 0;
1521
- max-width: 0;
1522
- overflow: hidden;
1523
- font-size: 0px;
1524
- }
1525
- .desktop_hide,
1526
- .desktop_hide table {
1527
- display: table !important;
1528
- max-height: none !important;
1529
- }
1530
1605
  .pad {
1531
- padding-left: 20px !important;
1532
- padding-right: 20px !important;
1606
+ padding-left: 24px !important;
1607
+ padding-right: 24px !important;
1533
1608
  }
1534
1609
  }
1535
1610
  </style>
@@ -1541,233 +1616,46 @@ function generateEmailTemplate(options) {
1541
1616
  </style>
1542
1617
  <![endif]-->
1543
1618
  </head>
1544
- <body class="body" style="background-color: #f8f9fa; margin: 0; padding: 0; -webkit-text-size-adjust: none; text-size-adjust: none;">
1545
- <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}; `)}>
1546
1621
  <tbody>
1547
1622
  <tr>
1548
- <td>
1549
- <!-- Main Content Row -->
1550
- <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;">
1551
1626
  <tbody>
1552
1627
  <tr>
1553
- <td>
1554
- <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;">
1555
- <tbody>
1556
- <tr>
1557
- <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;">
1558
-
1559
- <!-- Top Spacer -->
1560
- <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;">
1561
- <tr>
1562
- <td class="pad" style="padding-bottom:12px;padding-top:30px;">
1563
- <div class="alignment" align="center">
1564
- <table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;">
1565
- <tr>
1566
- <td class="divider_inner" style="font-size: 1px; line-height: 1px; border-top: 0px solid #BBBBBB;">
1567
- <span style="word-break: break-word;">&#8202;</span>
1568
- </td>
1569
- </tr>
1570
- </table>
1571
- </div>
1572
- </td>
1573
- </tr>
1574
- </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}
1575
1642
 
1576
- <!-- Header Image -->
1577
- <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;">
1578
- <tr>
1579
- <td class="pad" style="padding-left:40px;padding-right:40px;width:100%;">
1580
- <div class="alignment" align="center">
1581
- <div class="fullWidth" style="max-width: 50%;">
1582
- <img src="${safeLogoUrl}" style="display: block; height: auto; border: 0; width: 100%;" alt="${safeAppName}" title="${safeAppName}" height="auto">
1583
- </div>
1584
- </div>
1585
- </td>
1586
- </tr>
1587
- </table>
1588
-
1589
- ${welcomeMessage ? `
1590
- <!-- Welcome Message -->
1591
- <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;">
1592
- <tr>
1593
- <td class="pad" style="padding-top:30px;padding-left:40px;padding-right:40px;">
1594
- <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;">
1595
- <p style="margin: 0; word-break: break-word;">${welcomeMessage}</p>
1596
- </div>
1597
- </td>
1598
- </tr>
1599
- </table>
1600
- ` : ""}
1601
-
1602
- ${heading ? `
1603
- <!-- Heading -->
1604
- <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;">
1605
- <tr>
1606
- <td class="pad" style="padding-bottom:10px;padding-left:40px;padding-right:40px;padding-top:10px;">
1607
- <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;">
1608
- <p style="margin: 0; word-break: break-word;">${heading}</p>
1609
- </div>
1610
- </td>
1611
- </tr>
1612
- </table>
1613
- ` : ""}
1614
-
1615
- <!-- Body Text -->
1616
- <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;">
1617
- <tr>
1618
- <td class="pad" style="padding-bottom:30px;padding-left:40px;padding-right:40px;padding-top:30px;">
1619
- <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;">
1620
- <p style="margin: 0; word-break: break-word;">${body}</p>
1621
- </div>
1622
- </td>
1623
- </tr>
1624
- </table>
1625
-
1626
- ${safeButtonUrl ? `
1627
- <!-- Button -->
1628
- <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;">
1629
- <tr>
1630
- <td class="pad" style="padding-left:10px;padding-right:10px;padding-bottom:10px;text-align:center;">
1631
- <div class="alignment" align="center">
1632
- <!--[if mso]>
1633
- <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}">
1634
- <v:stroke dashstyle="Solid" weight="0px" color="${buttonBgColor}"/>
1635
- <w:anchorlock/>
1636
- <v:textbox inset="0px,0px,0px,0px">
1637
- <center dir="false" style="color:${buttonTextColor};font-family:'Montserrat',Arial,sans-serif;font-size:16px;font-weight:${buttonFontWeight};">
1638
- <![endif]-->
1639
- <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;">
1640
- ${buttonText}
1641
- </a>
1642
- <!--[if mso]>
1643
- </center>
1644
- </v:textbox>
1645
- </v:roundrect>
1646
- <![endif]-->
1647
- </div>
1648
- </td>
1649
- </tr>
1650
- </table>
1651
-
1652
- <!-- Copy Link -->
1653
- <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;">
1654
- <tr>
1655
- <td class="pad" style="padding-bottom:30px;padding-left:40px;padding-right:40px;">
1656
- <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;">
1657
- <p style="margin: 0 0 5px 0; word-break: break-word;">or copy and paste this link into your browser:</p>
1658
- <a href="${safeButtonUrl}" style="color:#4a90d9;text-decoration:underline;word-break:break-all;">${safeButtonUrl}</a>
1659
- </div>
1660
- </td>
1661
- </tr>
1662
- </table>
1663
- ` : ""}
1664
-
1665
- ${securityWarning ? `
1666
- <!-- Security Warning -->
1667
- <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;">
1668
- <tr>
1669
- <td class="pad" style="padding-left:40px;padding-right:40px;padding-bottom:20px;">
1670
- <div style="background-color: #a1f1ff; border-radius: 8px; padding: 16px; display: flex; align-items: center; justify-content: center;">
1671
- <p style="margin: 0; color: #01001b; font-family: 'Montserrat', Arial, sans-serif; font-size: 13px; line-height: 1.5; text-align: center;">
1672
- ${securityWarning}
1673
- </p>
1674
- </div>
1675
- </td>
1676
- </tr>
1677
- </table>
1678
- ` : ""}
1679
-
1680
- <!-- Footer Note -->
1681
- <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;">
1682
- <tr>
1683
- <td class="pad" style="padding-bottom:30px;padding-left:40px;padding-right:40px;">
1684
- <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;">
1685
- <p style="margin: 0; word-break: break-word;">${footerNote}</p>
1686
- </div>
1687
- </td>
1688
- </tr>
1689
- </table>
1690
-
1691
- </td>
1692
- </tr>
1693
- </tbody>
1694
- </table>
1695
1643
  </td>
1696
1644
  </tr>
1697
1645
  </tbody>
1698
1646
  </table>
1699
1647
 
1700
- <!-- Spacer Row -->
1701
- <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;">
1702
1650
  <tbody>
1703
1651
  <tr>
1704
- <td>
1705
- <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;">
1706
- <tbody>
1707
- <tr>
1708
- <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;">
1709
- <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;">
1710
- <tr>
1711
- <td class="pad">
1712
- <div></div>
1713
- </td>
1714
- </tr>
1715
- </table>
1716
- </td>
1717
- </tr>
1718
- </tbody>
1719
- </table>
1720
- </td>
1721
- </tr>
1722
- </tbody>
1723
- </table>
1724
-
1725
- <!-- Footer Row -->
1726
- <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;">
1727
- <tbody>
1728
- <tr>
1729
- <td>
1730
- <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;">
1731
- <tbody>
1732
- <tr>
1733
- <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;">
1734
-
1735
- <!-- Policy Links -->
1736
- <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;">
1737
- <tr>
1738
- <td class="pad" style="padding-bottom:20px;padding-left:40px;padding-right:40px;padding-top:20px;">
1739
- <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;">
1740
- <p style="margin: 0; word-break: break-word;">
1741
- <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>
1742
- </p>
1743
- </div>
1744
- </td>
1745
- </tr>
1746
- </table>
1747
-
1748
- <!-- Copyright & Disclaimer -->
1749
- <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;">
1750
- <tr>
1751
- <td class="pad" style="padding-bottom:15px;padding-left:40px;padding-right:40px;">
1752
- <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;">
1753
- <p style="margin: 0 0 12px 0; word-break: break-word;">\xA9 ${(/* @__PURE__ */ new Date()).getFullYear()} ${safeAppName}. All rights reserved.</p>
1754
- <p style="margin: 0; font-size: 11px; line-height: 1.5; word-break: break-word;">
1755
- 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.
1756
- </p>
1757
- </div>
1758
- </td>
1759
- </tr>
1760
- </table>
1761
-
1762
- </td>
1763
- </tr>
1764
- </tbody>
1765
- </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>
1766
1655
  </td>
1767
1656
  </tr>
1768
1657
  </tbody>
1769
1658
  </table>
1770
-
1771
1659
  </td>
1772
1660
  </tr>
1773
1661
  </tbody>
@@ -1776,6 +1664,55 @@ function generateEmailTemplate(options) {
1776
1664
  </html>
1777
1665
  `;
1778
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
+ }
1779
1716
 
1780
1717
  // src/core/passwordless.ts
1781
1718
  var PASSWORDLESS_TTL_MS = 10 * 60 * 1e3;
@@ -1870,13 +1807,13 @@ async function cleanupExpiredPasswordlessChallenges(db, tables2) {
1870
1807
  function passwordlessEmail(input) {
1871
1808
  const { appName, appUrl, linkUrl, code } = input;
1872
1809
  const minutes = Math.max(1, Math.round((input.ttlMs ?? PASSWORDLESS_TTL_MS) / 6e4));
1873
- const lifetime = `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
1810
+ const lifetime2 = `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
1874
1811
  const safeLink = sanitizeEmailUrl(linkUrl, appUrl);
1875
1812
  const html = generateEmailTemplate({
1876
1813
  heading: `Sign in to ${escapeHtml(appName)}`,
1877
1814
  body: `<p>Use this code, or the button below, to sign in:</p>
1878
1815
  <p style="font-size: 32px; font-weight: bold; letter-spacing: 4px; text-align: center; margin: 24px 0; font-family: monospace;">${escapeHtml(code)}</p>
1879
- <p>The code and the link expire in ${lifetime}.</p>`,
1816
+ <p>The code and the link expire in ${lifetime2}.</p>`,
1880
1817
  buttonText: "Sign in",
1881
1818
  buttonUrl: safeLink,
1882
1819
  footerNote: "If you didn't ask to sign in, you can ignore this email.",
@@ -1891,7 +1828,7 @@ function passwordlessEmail(input) {
1891
1828
  Or sign in with this link:
1892
1829
  ${safeLink}
1893
1830
 
1894
- The code and the link expire in ${lifetime}.
1831
+ The code and the link expire in ${lifetime2}.
1895
1832
 
1896
1833
  If you didn't ask to sign in, you can ignore this email.`
1897
1834
  };
@@ -2441,6 +2378,15 @@ var import_drizzle_orm10 = require("drizzle-orm");
2441
2378
  var import_drizzle_orm7 = require("drizzle-orm");
2442
2379
 
2443
2380
  // src/lib/email/templates.ts
2381
+ function lifetime(minutes) {
2382
+ if (minutes % 60 !== 0) return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
2383
+ const hours = minutes / 60;
2384
+ return `${hours} ${hours === 1 ? "hour" : "hours"}`;
2385
+ }
2386
+ var VERIFICATION_LIFETIME = lifetime(AUTH_DEFAULTS.EMAIL_VERIFICATION_TTL_HOURS * 60);
2387
+ var PASSWORD_RESET_LIFETIME = lifetime(AUTH_DEFAULTS.PASSWORD_RESET_TTL_MINUTES);
2388
+ var EMAIL_CHANGE_LIFETIME = lifetime(AUTH_DEFAULTS.EMAIL_CHANGE_TTL_HOURS * 60);
2389
+ var TWO_FACTOR_CODE_LIFETIME = lifetime(AUTH_DEFAULTS.TWO_FACTOR_EMAIL_CODE_TTL_MINUTES);
2444
2390
  function extractAppUrl(url) {
2445
2391
  try {
2446
2392
  const parsed = new URL(url);
@@ -2456,7 +2402,7 @@ function generateVerificationEmail(data, appName = "Your App", configuredAppUrl
2456
2402
  const safeFirstName = firstName ? escapeHtml(firstName) : null;
2457
2403
  const html = generateEmailTemplate({
2458
2404
  heading: "",
2459
- body: `Please verify that your email address is <strong>${escapeHtml(email)}</strong>, and that you entered it when signing up for ${escapeHtml(appName)}.`,
2405
+ body: `Please verify that your email address is <strong>${escapeHtml(email)}</strong>, and that you entered it when signing up for ${escapeHtml(appName)}. This link expires in ${VERIFICATION_LIFETIME}.`,
2460
2406
  buttonText: "Verify Email",
2461
2407
  buttonUrl: safeUrl,
2462
2408
  welcomeMessage: safeFirstName ? `Hi ${safeFirstName}, Welcome to ${escapeHtml(appName)}!` : void 0,
@@ -2475,7 +2421,7 @@ Thank you for signing up for ${appName}! Please verify your email address by cli
2475
2421
 
2476
2422
  ${safeUrl}
2477
2423
 
2478
- This link expires in 48 hours.
2424
+ This link expires in ${VERIFICATION_LIFETIME}.
2479
2425
 
2480
2426
  If you didn't create an account with ${appName}, you can safely ignore this email.`,
2481
2427
  tags: {
@@ -2493,7 +2439,7 @@ function generatePasswordResetEmail(data, appName = "Your App", configuredAppUrl
2493
2439
  buttonText: "Reset Password",
2494
2440
  buttonUrl: safeUrl,
2495
2441
  securityWarning: `
2496
- <strong>Link expires in 1 hour.</strong><br>
2442
+ <strong>Link expires in ${PASSWORD_RESET_LIFETIME}.</strong><br>
2497
2443
  For security, this link can only be used once.
2498
2444
  `,
2499
2445
  footerNote: "If you didn't request this change, no action is needed. Your password will remain unchanged.",
@@ -2511,7 +2457,7 @@ We received a request to reset your password. Click the link below to create a n
2511
2457
 
2512
2458
  ${safeUrl}
2513
2459
 
2514
- This link will expire in 1 hour.
2460
+ This link expires in ${PASSWORD_RESET_LIFETIME}.
2515
2461
 
2516
2462
  If you didn't request a password reset, you can safely ignore this email.`,
2517
2463
  tags: {
@@ -2529,7 +2475,7 @@ function generateEmailChangeConfirmation(data, appName = "Your App", configuredA
2529
2475
  buttonText: "Confirm Email Change",
2530
2476
  buttonUrl: safeUrl,
2531
2477
  securityWarning: `
2532
- <strong>This link expires in 24 hours.</strong><br>
2478
+ <strong>This link expires in ${EMAIL_CHANGE_LIFETIME}.</strong><br>
2533
2479
  For security, this link can only be used once.
2534
2480
  `,
2535
2481
  footerNote: "If you didn't request this change, you can safely ignore this email.",
@@ -2549,7 +2495,7 @@ Click the link below to confirm this change:
2549
2495
 
2550
2496
  ${safeUrl}
2551
2497
 
2552
- This link expires in 24 hours.
2498
+ This link expires in ${EMAIL_CHANGE_LIFETIME}.
2553
2499
 
2554
2500
  If you didn't request this change, you can safely ignore this email.`,
2555
2501
  tags: {
@@ -2603,7 +2549,7 @@ function generate2faCodeEmail(data, appName = "Your App", appUrl = "", branding)
2603
2549
  <p>Hi ${safeFirstName},</p>
2604
2550
  <p>Your verification code is:</p>
2605
2551
  <p style="font-size: 32px; font-weight: bold; letter-spacing: 4px; text-align: center; margin: 24px 0; font-family: monospace;">${escapeHtml(code)}</p>
2606
- <p>This code expires in 5 minutes.</p>
2552
+ <p>This code expires in ${TWO_FACTOR_CODE_LIFETIME}.</p>
2607
2553
  <p>If you didn't request this code, you can safely ignore this email.</p>
2608
2554
  `,
2609
2555
  buttonText: "",
@@ -2623,7 +2569,7 @@ Your verification code is:
2623
2569
 
2624
2570
  ${code}
2625
2571
 
2626
- This code expires in 5 minutes.
2572
+ This code expires in ${TWO_FACTOR_CODE_LIFETIME}.
2627
2573
 
2628
2574
  If you didn't request this code, you can safely ignore this email.
2629
2575
 
@@ -3702,7 +3648,7 @@ var signupHandler = async (c) => {
3702
3648
  if (!invitedVerified) {
3703
3649
  const emailToken = generateSecureToken(32);
3704
3650
  const tokenHash = await hashToken(emailToken);
3705
- const expiresAt = Date.now() + 24 * 60 * 60 * 1e3;
3651
+ const expiresAt = Date.now() + AUTH_DEFAULTS.EMAIL_VERIFICATION_TTL_HOURS * 60 * 60 * 1e3;
3706
3652
  await database.insert(schema.emailVerificationTokens).values({
3707
3653
  tokenHash,
3708
3654
  userId: newUser.id,
@@ -3781,7 +3727,7 @@ function generateEmailOtp() {
3781
3727
  const num = (bytes[0] << 24 | bytes[1] << 16 | bytes[2] << 8 | bytes[3]) >>> 0;
3782
3728
  return String(num % 1e6).padStart(6, "0");
3783
3729
  }
3784
- var EMAIL_CODE_TTL_SECONDS = 300;
3730
+ var EMAIL_CODE_TTL_SECONDS = AUTH_DEFAULTS.TWO_FACTOR_EMAIL_CODE_TTL_MINUTES * 60;
3785
3731
  async function sendEmailChallengeCode(env, database, usersTable, user, emailBranding) {
3786
3732
  const code = generateEmailOtp();
3787
3733
  await env.OAUTH_STATES.put(`email_2fa_challenge:${user.id}`, await hashToken(code), {
@@ -4839,7 +4785,7 @@ var resendVerificationHandler = async (c) => {
4839
4785
  await db.delete(schema.emailVerificationTokens).where((0, import_drizzle_orm20.eq)(schema.emailVerificationTokens.userId, userId));
4840
4786
  const emailToken = generateSecureToken(32);
4841
4787
  const tokenHash = await hashToken(emailToken);
4842
- const expiresAt = Date.now() + 24 * 60 * 60 * 1e3;
4788
+ const expiresAt = Date.now() + AUTH_DEFAULTS.EMAIL_VERIFICATION_TTL_HOURS * 60 * 60 * 1e3;
4843
4789
  await db.insert(schema.emailVerificationTokens).values({
4844
4790
  tokenHash,
4845
4791
  userId: user.id,
@@ -6448,6 +6394,7 @@ function verifyWebhookSignature(payload, headers, secret) {
6448
6394
  rateLimit,
6449
6395
  recentPasswordlessFailures,
6450
6396
  refreshSession,
6397
+ renderEmail,
6451
6398
  requestEmailChange,
6452
6399
  requireAuth,
6453
6400
  requireVerifiedEmail,