@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.js CHANGED
@@ -260,6 +260,9 @@ var AUTH_DEFAULTS = {
260
260
  LOCKOUT_DURATION_MINUTES: 30,
261
261
  LOCKOUT_MAX_ATTEMPTS: 5,
262
262
  PASSWORD_RESET_TTL_MINUTES: 15,
263
+ EMAIL_VERIFICATION_TTL_HOURS: 24,
264
+ EMAIL_CHANGE_TTL_HOURS: 24,
265
+ TWO_FACTOR_EMAIL_CODE_TTL_MINUTES: 5,
263
266
  TWO_FACTOR_CHALLENGE_TTL_SECONDS: 300,
264
267
  // 5 minutes
265
268
  APP_NAME: "App"
@@ -749,7 +752,7 @@ async function verifyTurnstileToken(token, secretKey, remoteIp, environment) {
749
752
 
750
753
  // src/core/email-change.ts
751
754
  import { eq as eq3 } from "drizzle-orm";
752
- var TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1e3;
755
+ var TOKEN_EXPIRY_MS = AUTH_DEFAULTS.EMAIL_CHANGE_TTL_HOURS * 60 * 60 * 1e3;
753
756
  async function requestEmailChange(params) {
754
757
  const { userId, password, newEmail, pepper, db, tables: tables2 } = params;
755
758
  const { users, emailChangeTokens } = tables2;
@@ -994,37 +997,140 @@ function escapeHtml(text) {
994
997
  }
995
998
 
996
999
  // src/core/email-template.ts
997
- function generateEmailTemplate(options) {
998
- const {
999
- heading,
1000
- body,
1001
- buttonText,
1002
- buttonUrl,
1003
- welcomeMessage,
1004
- securityWarning,
1005
- footerNote,
1006
- appUrl,
1007
- marketingUrl = appUrl,
1008
- // Default to appUrl if no marketing site
1009
- appName = "App",
1010
- buttonBgColor = "#00fe9a",
1011
- buttonTextColor = "#000004",
1012
- buttonFontWeight = 500,
1013
- buttonRadius = "6px",
1014
- logoUrl,
1015
- footerColor = "#1e1b4b",
1016
- headingColor = "#1e1b4b"
1017
- } = options;
1018
- const safeAppName = escapeHtml(appName);
1019
- const safeButtonUrl = buttonUrl ? sanitizeEmailUrl(buttonUrl, appUrl) : "";
1020
- const safeAppUrl = appUrl ? sanitizeEmailUrl(appUrl, appUrl) : "";
1021
- const safeMarketingUrl = marketingUrl ? sanitizeEmailUrl(marketingUrl, appUrl) : "";
1022
- const safeLogoUrl = logoUrl ? sanitizeEmailUrl(logoUrl, appUrl) : `${safeAppUrl.replace(/\/$/, "")}/email-header-image.png`;
1000
+ var FONT = "'Montserrat',Arial,sans-serif";
1001
+ var PAGE = "#f4f5f7";
1002
+ var BODY_TEXT = "#404040";
1003
+ var MUTED = "#737373";
1004
+ var RULE = "#e5e7eb";
1005
+ var TABLE = 'width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;"';
1006
+ function logoDimensions(width, height) {
1007
+ if (width === void 0 && height === void 0) return null;
1008
+ if (width === void 0 || height === void 0) {
1009
+ throw new Error("logoWidth and logoHeight must be given together");
1010
+ }
1011
+ return { width: pixels("logoWidth", width), height: pixels("logoHeight", height) };
1012
+ }
1013
+ function pixels(name, value) {
1014
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
1015
+ throw new Error(`${name} must be a positive integer`);
1016
+ }
1017
+ return escapeHtml(String(value));
1018
+ }
1019
+ function brandColours(branding) {
1020
+ return {
1021
+ buttonBgColor: escapeHtml(branding.buttonBgColor ?? "#00fe9a"),
1022
+ buttonTextColor: escapeHtml(branding.buttonTextColor ?? "#000004"),
1023
+ buttonFontWeight: escapeHtml(String(branding.buttonFontWeight ?? 500)),
1024
+ buttonRadius: escapeHtml(branding.buttonRadius ?? "6px"),
1025
+ footerColor: escapeHtml(branding.footerColor ?? "#1e1b4b"),
1026
+ headingColor: escapeHtml(branding.headingColor ?? "#1e1b4b")
1027
+ };
1028
+ }
1029
+ function logoUrlFor(logoUrl, appUrl) {
1030
+ if (logoUrl) return escapeHtml(sanitizeEmailUrl(logoUrl, appUrl));
1031
+ const base = appUrl ? sanitizeEmailUrl(appUrl, appUrl) : "";
1032
+ return escapeHtml(`${base.replace(/\/$/, "")}/email-header-image.png`);
1033
+ }
1034
+ function marketingUrlFor(marketingUrl) {
1035
+ return marketingUrl ? escapeHtml(sanitizeEmailUrl(marketingUrl, marketingUrl).replace(/\/$/, "")) : "";
1036
+ }
1037
+ var cardRow = (padding, content) => `
1038
+ <table ${TABLE}>
1039
+ <tr>
1040
+ <td class="pad" style="${padding}">
1041
+ ${content}
1042
+ </td>
1043
+ </tr>
1044
+ </table>`;
1045
+ function logoBlock({ logoUrl, logoSize, appName }) {
1046
+ 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%;">
1047
+ <img src="${logoUrl}" style="display: block; height: auto; border: 0; width: 100%;" alt="${appName}" title="${appName}" height="auto">
1048
+ </div>`;
1049
+ return `
1050
+ <!-- Header Image -->
1051
+ <table class="image_block" ${TABLE}>
1052
+ <tr>
1053
+ <td class="pad" style="padding-top:40px;padding-left:40px;padding-right:40px;padding-bottom:8px;">
1054
+ <div class="alignment" align="center">
1055
+ ${img}
1056
+ </div>
1057
+ </td>
1058
+ </tr>
1059
+ </table>`;
1060
+ }
1061
+ function buttonBlock({ label, url }, brand) {
1062
+ const font = `font-family: ${FONT}; font-size: 16px; font-weight: ${brand.buttonFontWeight};`;
1063
+ return `
1064
+ <!-- Button -->${cardRow(
1065
+ "padding-top:8px;padding-bottom:32px;padding-left:40px;padding-right:40px;text-align:center;",
1066
+ `<div class="alignment" align="center">
1067
+ <!--[if mso]>
1068
+ <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}">
1069
+ <v:stroke dashstyle="Solid" weight="0px" color="${brand.buttonBgColor}"/>
1070
+ <w:anchorlock/>
1071
+ <v:textbox inset="0px,0px,0px,0px">
1072
+ <center dir="false" style="color:${brand.buttonTextColor};${font}">
1073
+ <![endif]-->
1074
+ <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>
1075
+ <!--[if mso]>
1076
+ </center>
1077
+ </v:textbox>
1078
+ </v:roundrect>
1079
+ <![endif]-->
1080
+ </div>`
1081
+ )}
1082
+
1083
+ <!-- Copy Link -->${cardRow(
1084
+ "padding-bottom:24px;padding-left:40px;padding-right:40px;",
1085
+ `<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;">
1086
+ <p style="margin: 0 0 4px 0; word-break: break-word;">Or copy and paste this link into your browser:</p>
1087
+ <a href="${url}" style="color:${MUTED};text-decoration:underline;word-break:break-all;">${url}</a>
1088
+ </div>`
1089
+ )}`;
1090
+ }
1091
+ function renderLayout(parts) {
1092
+ const { brand } = parts;
1093
+ const welcome = parts.welcome ? `
1094
+ <!-- Welcome Message -->${cardRow(
1095
+ "padding-top:24px;padding-left:40px;padding-right:40px;",
1096
+ `<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;">
1097
+ <p style="margin: 0; word-break: break-word;">${parts.welcome}</p>
1098
+ </div>`
1099
+ )}` : "";
1100
+ const heading = parts.heading ? `
1101
+ <!-- Heading -->${cardRow(
1102
+ "padding-top:24px;padding-left:40px;padding-right:40px;",
1103
+ `<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;">
1104
+ <p style="margin: 0; word-break: break-word;">${parts.heading}</p>
1105
+ </div>`
1106
+ )}` : "";
1107
+ const notice = parts.notice ? `
1108
+ <!-- Notice -->${cardRow(
1109
+ "padding-bottom:24px;padding-left:40px;padding-right:40px;",
1110
+ `<table ${TABLE.replace('style="', `style="background-color: ${PAGE}; border-left: 3px solid ${brand.headingColor}; border-radius: 4px; `)}>
1111
+ <tr>
1112
+ <td style="padding: 14px 16px; color: ${BODY_TEXT}; font-family: ${FONT}; font-size: 13px; line-height: 1.5; text-align: left;">
1113
+ ${parts.notice}
1114
+ </td>
1115
+ </tr>
1116
+ </table>`
1117
+ )}` : "";
1118
+ const footerNote = parts.footerNote ? `
1119
+ <!-- Footer Note -->${cardRow(
1120
+ "padding-bottom:32px;padding-left:40px;padding-right:40px;",
1121
+ `<div style="color:${MUTED};font-family:${FONT};font-size:13px;line-height:1.5;text-align:left;mso-line-height-alt:20px;">
1122
+ <p style="margin: 0; word-break: break-word;">${parts.footerNote}</p>
1123
+ </div>`
1124
+ )}` : "";
1125
+ const links = parts.marketingUrl ? `
1126
+ <p style="margin: 0 0 12px 0; word-break: break-word;">
1127
+ <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>
1128
+ </p>` : "";
1023
1129
  return `
1024
1130
  <!DOCTYPE html>
1025
1131
  <html xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" lang="en">
1026
1132
  <head>
1027
- <title>${heading}</title>
1133
+ <title>${parts.title}</title>
1028
1134
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
1029
1135
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
1030
1136
  <!--[if mso]>
@@ -1039,7 +1145,7 @@ function generateEmailTemplate(options) {
1039
1145
  </xml>
1040
1146
  <![endif]-->
1041
1147
  <!--[if !mso]><!-->
1042
- <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&display=swap" rel="stylesheet" type="text/css">
1148
+ <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap" rel="stylesheet" type="text/css">
1043
1149
  <!--<![endif]-->
1044
1150
  <style>
1045
1151
  * {
@@ -1060,13 +1166,6 @@ function generateEmailTemplate(options) {
1060
1166
  p {
1061
1167
  line-height: inherit;
1062
1168
  }
1063
- .desktop_hide,
1064
- .desktop_hide table {
1065
- mso-hide: all;
1066
- display: none;
1067
- max-height: 0px;
1068
- overflow: hidden;
1069
- }
1070
1169
  .image_block img+div {
1071
1170
  display: none;
1072
1171
  }
@@ -1075,41 +1174,16 @@ function generateEmailTemplate(options) {
1075
1174
  line-height: 0;
1076
1175
  }
1077
1176
  @media (max-width:660px) {
1078
- .desktop_hide table.icons-inner {
1079
- display: inline-block !important;
1080
- }
1081
- .icons-inner {
1082
- text-align: center;
1083
- }
1084
- .icons-inner td {
1085
- margin: 0 auto;
1086
- }
1087
- .mobile_hide {
1088
- display: none;
1177
+ .page {
1178
+ padding: 16px 8px !important;
1089
1179
  }
1090
1180
  .row-content {
1091
1181
  width: 100% !important;
1092
1182
  max-width: 100% !important;
1093
1183
  }
1094
- .stack .column {
1095
- width: 100%;
1096
- display: block;
1097
- }
1098
- .mobile_hide {
1099
- min-height: 0;
1100
- max-height: 0;
1101
- max-width: 0;
1102
- overflow: hidden;
1103
- font-size: 0px;
1104
- }
1105
- .desktop_hide,
1106
- .desktop_hide table {
1107
- display: table !important;
1108
- max-height: none !important;
1109
- }
1110
1184
  .pad {
1111
- padding-left: 20px !important;
1112
- padding-right: 20px !important;
1185
+ padding-left: 24px !important;
1186
+ padding-right: 24px !important;
1113
1187
  }
1114
1188
  }
1115
1189
  </style>
@@ -1121,233 +1195,46 @@ function generateEmailTemplate(options) {
1121
1195
  </style>
1122
1196
  <![endif]-->
1123
1197
  </head>
1124
- <body class="body" style="background-color: #f8f9fa; margin: 0; padding: 0; -webkit-text-size-adjust: none; text-size-adjust: none;">
1125
- <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;">
1198
+ <body class="body" style="background-color: ${PAGE}; margin: 0; padding: 0; -webkit-text-size-adjust: none; text-size-adjust: none;">
1199
+ <table class="nl-container" ${TABLE.replace('style="', `style="background-color: ${PAGE}; `)}>
1126
1200
  <tbody>
1127
1201
  <tr>
1128
- <td>
1129
- <!-- Main Content Row -->
1130
- <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;">
1202
+ <td class="page" style="padding: 32px 16px;">
1203
+ <!-- Card -->
1204
+ <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;">
1131
1205
  <tbody>
1132
1206
  <tr>
1133
- <td>
1134
- <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;">
1135
- <tbody>
1136
- <tr>
1137
- <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;">
1138
-
1139
- <!-- Top Spacer -->
1140
- <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;">
1141
- <tr>
1142
- <td class="pad" style="padding-bottom:12px;padding-top:30px;">
1143
- <div class="alignment" align="center">
1144
- <table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt;">
1145
- <tr>
1146
- <td class="divider_inner" style="font-size: 1px; line-height: 1px; border-top: 0px solid #BBBBBB;">
1147
- <span style="word-break: break-word;">&#8202;</span>
1148
- </td>
1149
- </tr>
1150
- </table>
1151
- </div>
1152
- </td>
1153
- </tr>
1154
- </table>
1207
+ <td width="100%" style="mso-table-lspace: 0pt; mso-table-rspace: 0pt; text-align: left; vertical-align: top;">
1208
+ ${logoBlock(parts)}
1209
+ ${welcome}
1210
+ ${heading}
1211
+
1212
+ <!-- Body Text -->${cardRow(
1213
+ "padding-top:16px;padding-bottom:24px;padding-left:40px;padding-right:40px;",
1214
+ `<div style="color:${BODY_TEXT};font-family:${FONT};font-size:15px;line-height:1.6;text-align:left;mso-line-height-alt:24px;">
1215
+ ${parts.body}
1216
+ </div>`
1217
+ )}
1218
+ ${parts.button ? buttonBlock(parts.button, brand) : ""}
1219
+ ${notice}
1220
+ ${footerNote}
1155
1221
 
1156
- <!-- Header Image -->
1157
- <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;">
1158
- <tr>
1159
- <td class="pad" style="padding-left:40px;padding-right:40px;width:100%;">
1160
- <div class="alignment" align="center">
1161
- <div class="fullWidth" style="max-width: 50%;">
1162
- <img src="${safeLogoUrl}" style="display: block; height: auto; border: 0; width: 100%;" alt="${safeAppName}" title="${safeAppName}" height="auto">
1163
- </div>
1164
- </div>
1165
- </td>
1166
- </tr>
1167
- </table>
1168
-
1169
- ${welcomeMessage ? `
1170
- <!-- Welcome Message -->
1171
- <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;">
1172
- <tr>
1173
- <td class="pad" style="padding-top:30px;padding-left:40px;padding-right:40px;">
1174
- <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;">
1175
- <p style="margin: 0; word-break: break-word;">${welcomeMessage}</p>
1176
- </div>
1177
- </td>
1178
- </tr>
1179
- </table>
1180
- ` : ""}
1181
-
1182
- ${heading ? `
1183
- <!-- Heading -->
1184
- <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;">
1185
- <tr>
1186
- <td class="pad" style="padding-bottom:10px;padding-left:40px;padding-right:40px;padding-top:10px;">
1187
- <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;">
1188
- <p style="margin: 0; word-break: break-word;">${heading}</p>
1189
- </div>
1190
- </td>
1191
- </tr>
1192
- </table>
1193
- ` : ""}
1194
-
1195
- <!-- Body Text -->
1196
- <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;">
1197
- <tr>
1198
- <td class="pad" style="padding-bottom:30px;padding-left:40px;padding-right:40px;padding-top:30px;">
1199
- <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;">
1200
- <p style="margin: 0; word-break: break-word;">${body}</p>
1201
- </div>
1202
- </td>
1203
- </tr>
1204
- </table>
1205
-
1206
- ${safeButtonUrl ? `
1207
- <!-- Button -->
1208
- <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;">
1209
- <tr>
1210
- <td class="pad" style="padding-left:10px;padding-right:10px;padding-bottom:10px;text-align:center;">
1211
- <div class="alignment" align="center">
1212
- <!--[if mso]>
1213
- <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}">
1214
- <v:stroke dashstyle="Solid" weight="0px" color="${buttonBgColor}"/>
1215
- <w:anchorlock/>
1216
- <v:textbox inset="0px,0px,0px,0px">
1217
- <center dir="false" style="color:${buttonTextColor};font-family:'Montserrat',Arial,sans-serif;font-size:16px;font-weight:${buttonFontWeight};">
1218
- <![endif]-->
1219
- <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;">
1220
- ${buttonText}
1221
- </a>
1222
- <!--[if mso]>
1223
- </center>
1224
- </v:textbox>
1225
- </v:roundrect>
1226
- <![endif]-->
1227
- </div>
1228
- </td>
1229
- </tr>
1230
- </table>
1231
-
1232
- <!-- Copy Link -->
1233
- <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;">
1234
- <tr>
1235
- <td class="pad" style="padding-bottom:30px;padding-left:40px;padding-right:40px;">
1236
- <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;">
1237
- <p style="margin: 0 0 5px 0; word-break: break-word;">or copy and paste this link into your browser:</p>
1238
- <a href="${safeButtonUrl}" style="color:#4a90d9;text-decoration:underline;word-break:break-all;">${safeButtonUrl}</a>
1239
- </div>
1240
- </td>
1241
- </tr>
1242
- </table>
1243
- ` : ""}
1244
-
1245
- ${securityWarning ? `
1246
- <!-- Security Warning -->
1247
- <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;">
1248
- <tr>
1249
- <td class="pad" style="padding-left:40px;padding-right:40px;padding-bottom:20px;">
1250
- <div style="background-color: #a1f1ff; border-radius: 8px; padding: 16px; display: flex; align-items: center; justify-content: center;">
1251
- <p style="margin: 0; color: #01001b; font-family: 'Montserrat', Arial, sans-serif; font-size: 13px; line-height: 1.5; text-align: center;">
1252
- ${securityWarning}
1253
- </p>
1254
- </div>
1255
- </td>
1256
- </tr>
1257
- </table>
1258
- ` : ""}
1259
-
1260
- <!-- Footer Note -->
1261
- <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;">
1262
- <tr>
1263
- <td class="pad" style="padding-bottom:30px;padding-left:40px;padding-right:40px;">
1264
- <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;">
1265
- <p style="margin: 0; word-break: break-word;">${footerNote}</p>
1266
- </div>
1267
- </td>
1268
- </tr>
1269
- </table>
1270
-
1271
- </td>
1272
- </tr>
1273
- </tbody>
1274
- </table>
1275
1222
  </td>
1276
1223
  </tr>
1277
1224
  </tbody>
1278
1225
  </table>
1279
1226
 
1280
- <!-- Spacer Row -->
1281
- <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;">
1227
+ <!-- Footer -->
1228
+ <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;">
1282
1229
  <tbody>
1283
1230
  <tr>
1284
- <td>
1285
- <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;">
1286
- <tbody>
1287
- <tr>
1288
- <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;">
1289
- <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;">
1290
- <tr>
1291
- <td class="pad">
1292
- <div></div>
1293
- </td>
1294
- </tr>
1295
- </table>
1296
- </td>
1297
- </tr>
1298
- </tbody>
1299
- </table>
1300
- </td>
1301
- </tr>
1302
- </tbody>
1303
- </table>
1304
-
1305
- <!-- Footer Row -->
1306
- <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;">
1307
- <tbody>
1308
- <tr>
1309
- <td>
1310
- <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;">
1311
- <tbody>
1312
- <tr>
1313
- <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;">
1314
-
1315
- <!-- Policy Links -->
1316
- <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;">
1317
- <tr>
1318
- <td class="pad" style="padding-bottom:20px;padding-left:40px;padding-right:40px;padding-top:20px;">
1319
- <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;">
1320
- <p style="margin: 0; word-break: break-word;">
1321
- <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>
1322
- </p>
1323
- </div>
1324
- </td>
1325
- </tr>
1326
- </table>
1327
-
1328
- <!-- Copyright & Disclaimer -->
1329
- <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;">
1330
- <tr>
1331
- <td class="pad" style="padding-bottom:15px;padding-left:40px;padding-right:40px;">
1332
- <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;">
1333
- <p style="margin: 0 0 12px 0; word-break: break-word;">\xA9 ${(/* @__PURE__ */ new Date()).getFullYear()} ${safeAppName}. All rights reserved.</p>
1334
- <p style="margin: 0; font-size: 11px; line-height: 1.5; word-break: break-word;">
1335
- 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.
1336
- </p>
1337
- </div>
1338
- </td>
1339
- </tr>
1340
- </table>
1341
-
1342
- </td>
1343
- </tr>
1344
- </tbody>
1345
- </table>
1231
+ <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}
1232
+ <p style="margin: 0 0 8px 0; word-break: break-word;">\xA9 ${(/* @__PURE__ */ new Date()).getFullYear()} ${parts.appName}. All rights reserved.</p>
1233
+ <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>
1346
1234
  </td>
1347
1235
  </tr>
1348
1236
  </tbody>
1349
1237
  </table>
1350
-
1351
1238
  </td>
1352
1239
  </tr>
1353
1240
  </tbody>
@@ -1356,6 +1243,55 @@ function generateEmailTemplate(options) {
1356
1243
  </html>
1357
1244
  `;
1358
1245
  }
1246
+ function generateEmailTemplate(options) {
1247
+ const { appUrl, buttonUrl } = options;
1248
+ const safeButtonUrl = buttonUrl ? escapeHtml(sanitizeEmailUrl(buttonUrl, appUrl)) : "";
1249
+ return renderLayout({
1250
+ title: options.heading,
1251
+ logoUrl: logoUrlFor(options.logoUrl, appUrl),
1252
+ logoSize: logoDimensions(options.logoWidth, options.logoHeight),
1253
+ appName: escapeHtml(options.appName ?? "App"),
1254
+ welcome: options.welcomeMessage ?? "",
1255
+ heading: options.heading,
1256
+ body: options.body,
1257
+ button: safeButtonUrl ? { label: options.buttonText, url: safeButtonUrl } : null,
1258
+ notice: options.securityWarning?.trim() ?? "",
1259
+ footerNote: options.footerNote,
1260
+ marketingUrl: marketingUrlFor(options.marketingUrl),
1261
+ brand: brandColours(options)
1262
+ });
1263
+ }
1264
+ function renderEmail(content) {
1265
+ const { appUrl, branding = {} } = content;
1266
+ const link = (url) => escapeHtml(sanitizeEmailUrl(url, appUrl));
1267
+ const text = (value) => escapeHtml(value).replace(/\r?\n/g, "<br>");
1268
+ const brand = brandColours(branding);
1269
+ const paragraphs = content.paragraphs.map(
1270
+ (p) => `<p style="margin: 0 0 16px 0; word-break: break-word;">${text(p)}</p>`
1271
+ );
1272
+ const sections = (content.sections ?? []).map((section) => {
1273
+ 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>`;
1274
+ const rows = section.rows.map(
1275
+ (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>`
1276
+ );
1277
+ return `<p style="margin: 8px 0 4px 0; word-break: break-word;">${title}</p>
1278
+ <ul style="margin: 0 0 16px 0; padding-left: 20px;">${rows.join("")}</ul>`;
1279
+ });
1280
+ return renderLayout({
1281
+ title: escapeHtml(content.heading),
1282
+ logoUrl: logoUrlFor(branding.logoUrl, appUrl),
1283
+ logoSize: logoDimensions(branding.logoWidth, branding.logoHeight),
1284
+ appName: escapeHtml(content.appName ?? "App"),
1285
+ welcome: "",
1286
+ heading: escapeHtml(content.heading),
1287
+ body: [...paragraphs, ...sections].join("\n "),
1288
+ button: content.button ? { label: escapeHtml(content.button.label), url: link(content.button.url) } : null,
1289
+ notice: content.notice ? text(content.notice) : "",
1290
+ footerNote: content.footerNote ? text(content.footerNote) : "",
1291
+ marketingUrl: marketingUrlFor(branding.marketingUrl),
1292
+ brand
1293
+ });
1294
+ }
1359
1295
 
1360
1296
  // src/core/passwordless.ts
1361
1297
  var PASSWORDLESS_TTL_MS = 10 * 60 * 1e3;
@@ -1450,13 +1386,13 @@ async function cleanupExpiredPasswordlessChallenges(db, tables2) {
1450
1386
  function passwordlessEmail(input) {
1451
1387
  const { appName, appUrl, linkUrl, code } = input;
1452
1388
  const minutes = Math.max(1, Math.round((input.ttlMs ?? PASSWORDLESS_TTL_MS) / 6e4));
1453
- const lifetime = `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
1389
+ const lifetime2 = `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
1454
1390
  const safeLink = sanitizeEmailUrl(linkUrl, appUrl);
1455
1391
  const html = generateEmailTemplate({
1456
1392
  heading: `Sign in to ${escapeHtml(appName)}`,
1457
1393
  body: `<p>Use this code, or the button below, to sign in:</p>
1458
1394
  <p style="font-size: 32px; font-weight: bold; letter-spacing: 4px; text-align: center; margin: 24px 0; font-family: monospace;">${escapeHtml(code)}</p>
1459
- <p>The code and the link expire in ${lifetime}.</p>`,
1395
+ <p>The code and the link expire in ${lifetime2}.</p>`,
1460
1396
  buttonText: "Sign in",
1461
1397
  buttonUrl: safeLink,
1462
1398
  footerNote: "If you didn't ask to sign in, you can ignore this email.",
@@ -1471,7 +1407,7 @@ function passwordlessEmail(input) {
1471
1407
  Or sign in with this link:
1472
1408
  ${safeLink}
1473
1409
 
1474
- The code and the link expire in ${lifetime}.
1410
+ The code and the link expire in ${lifetime2}.
1475
1411
 
1476
1412
  If you didn't ask to sign in, you can ignore this email.`
1477
1413
  };
@@ -2021,6 +1957,15 @@ import { eq as eq9 } from "drizzle-orm";
2021
1957
  import { eq as eq7 } from "drizzle-orm";
2022
1958
 
2023
1959
  // src/lib/email/templates.ts
1960
+ function lifetime(minutes) {
1961
+ if (minutes % 60 !== 0) return `${minutes} ${minutes === 1 ? "minute" : "minutes"}`;
1962
+ const hours = minutes / 60;
1963
+ return `${hours} ${hours === 1 ? "hour" : "hours"}`;
1964
+ }
1965
+ var VERIFICATION_LIFETIME = lifetime(AUTH_DEFAULTS.EMAIL_VERIFICATION_TTL_HOURS * 60);
1966
+ var PASSWORD_RESET_LIFETIME = lifetime(AUTH_DEFAULTS.PASSWORD_RESET_TTL_MINUTES);
1967
+ var EMAIL_CHANGE_LIFETIME = lifetime(AUTH_DEFAULTS.EMAIL_CHANGE_TTL_HOURS * 60);
1968
+ var TWO_FACTOR_CODE_LIFETIME = lifetime(AUTH_DEFAULTS.TWO_FACTOR_EMAIL_CODE_TTL_MINUTES);
2024
1969
  function extractAppUrl(url) {
2025
1970
  try {
2026
1971
  const parsed = new URL(url);
@@ -2036,7 +1981,7 @@ function generateVerificationEmail(data, appName = "Your App", configuredAppUrl
2036
1981
  const safeFirstName = firstName ? escapeHtml(firstName) : null;
2037
1982
  const html = generateEmailTemplate({
2038
1983
  heading: "",
2039
- body: `Please verify that your email address is <strong>${escapeHtml(email)}</strong>, and that you entered it when signing up for ${escapeHtml(appName)}.`,
1984
+ 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}.`,
2040
1985
  buttonText: "Verify Email",
2041
1986
  buttonUrl: safeUrl,
2042
1987
  welcomeMessage: safeFirstName ? `Hi ${safeFirstName}, Welcome to ${escapeHtml(appName)}!` : void 0,
@@ -2055,7 +2000,7 @@ Thank you for signing up for ${appName}! Please verify your email address by cli
2055
2000
 
2056
2001
  ${safeUrl}
2057
2002
 
2058
- This link expires in 48 hours.
2003
+ This link expires in ${VERIFICATION_LIFETIME}.
2059
2004
 
2060
2005
  If you didn't create an account with ${appName}, you can safely ignore this email.`,
2061
2006
  tags: {
@@ -2073,7 +2018,7 @@ function generatePasswordResetEmail(data, appName = "Your App", configuredAppUrl
2073
2018
  buttonText: "Reset Password",
2074
2019
  buttonUrl: safeUrl,
2075
2020
  securityWarning: `
2076
- <strong>Link expires in 1 hour.</strong><br>
2021
+ <strong>Link expires in ${PASSWORD_RESET_LIFETIME}.</strong><br>
2077
2022
  For security, this link can only be used once.
2078
2023
  `,
2079
2024
  footerNote: "If you didn't request this change, no action is needed. Your password will remain unchanged.",
@@ -2091,7 +2036,7 @@ We received a request to reset your password. Click the link below to create a n
2091
2036
 
2092
2037
  ${safeUrl}
2093
2038
 
2094
- This link will expire in 1 hour.
2039
+ This link expires in ${PASSWORD_RESET_LIFETIME}.
2095
2040
 
2096
2041
  If you didn't request a password reset, you can safely ignore this email.`,
2097
2042
  tags: {
@@ -2109,7 +2054,7 @@ function generateEmailChangeConfirmation(data, appName = "Your App", configuredA
2109
2054
  buttonText: "Confirm Email Change",
2110
2055
  buttonUrl: safeUrl,
2111
2056
  securityWarning: `
2112
- <strong>This link expires in 24 hours.</strong><br>
2057
+ <strong>This link expires in ${EMAIL_CHANGE_LIFETIME}.</strong><br>
2113
2058
  For security, this link can only be used once.
2114
2059
  `,
2115
2060
  footerNote: "If you didn't request this change, you can safely ignore this email.",
@@ -2129,7 +2074,7 @@ Click the link below to confirm this change:
2129
2074
 
2130
2075
  ${safeUrl}
2131
2076
 
2132
- This link expires in 24 hours.
2077
+ This link expires in ${EMAIL_CHANGE_LIFETIME}.
2133
2078
 
2134
2079
  If you didn't request this change, you can safely ignore this email.`,
2135
2080
  tags: {
@@ -2183,7 +2128,7 @@ function generate2faCodeEmail(data, appName = "Your App", appUrl = "", branding)
2183
2128
  <p>Hi ${safeFirstName},</p>
2184
2129
  <p>Your verification code is:</p>
2185
2130
  <p style="font-size: 32px; font-weight: bold; letter-spacing: 4px; text-align: center; margin: 24px 0; font-family: monospace;">${escapeHtml(code)}</p>
2186
- <p>This code expires in 5 minutes.</p>
2131
+ <p>This code expires in ${TWO_FACTOR_CODE_LIFETIME}.</p>
2187
2132
  <p>If you didn't request this code, you can safely ignore this email.</p>
2188
2133
  `,
2189
2134
  buttonText: "",
@@ -2203,7 +2148,7 @@ Your verification code is:
2203
2148
 
2204
2149
  ${code}
2205
2150
 
2206
- This code expires in 5 minutes.
2151
+ This code expires in ${TWO_FACTOR_CODE_LIFETIME}.
2207
2152
 
2208
2153
  If you didn't request this code, you can safely ignore this email.
2209
2154
 
@@ -3282,7 +3227,7 @@ var signupHandler = async (c) => {
3282
3227
  if (!invitedVerified) {
3283
3228
  const emailToken = generateSecureToken(32);
3284
3229
  const tokenHash = await hashToken(emailToken);
3285
- const expiresAt = Date.now() + 24 * 60 * 60 * 1e3;
3230
+ const expiresAt = Date.now() + AUTH_DEFAULTS.EMAIL_VERIFICATION_TTL_HOURS * 60 * 60 * 1e3;
3286
3231
  await database.insert(schema.emailVerificationTokens).values({
3287
3232
  tokenHash,
3288
3233
  userId: newUser.id,
@@ -3361,7 +3306,7 @@ function generateEmailOtp() {
3361
3306
  const num = (bytes[0] << 24 | bytes[1] << 16 | bytes[2] << 8 | bytes[3]) >>> 0;
3362
3307
  return String(num % 1e6).padStart(6, "0");
3363
3308
  }
3364
- var EMAIL_CODE_TTL_SECONDS = 300;
3309
+ var EMAIL_CODE_TTL_SECONDS = AUTH_DEFAULTS.TWO_FACTOR_EMAIL_CODE_TTL_MINUTES * 60;
3365
3310
  async function sendEmailChallengeCode(env, database, usersTable, user, emailBranding) {
3366
3311
  const code = generateEmailOtp();
3367
3312
  await env.OAUTH_STATES.put(`email_2fa_challenge:${user.id}`, await hashToken(code), {
@@ -4419,7 +4364,7 @@ var resendVerificationHandler = async (c) => {
4419
4364
  await db.delete(schema.emailVerificationTokens).where(eq19(schema.emailVerificationTokens.userId, userId));
4420
4365
  const emailToken = generateSecureToken(32);
4421
4366
  const tokenHash = await hashToken(emailToken);
4422
- const expiresAt = Date.now() + 24 * 60 * 60 * 1e3;
4367
+ const expiresAt = Date.now() + AUTH_DEFAULTS.EMAIL_VERIFICATION_TTL_HOURS * 60 * 60 * 1e3;
4423
4368
  await db.insert(schema.emailVerificationTokens).values({
4424
4369
  tokenHash,
4425
4370
  userId: user.id,
@@ -6027,6 +5972,7 @@ export {
6027
5972
  rateLimit,
6028
5973
  recentPasswordlessFailures,
6029
5974
  refreshSession,
5975
+ renderEmail,
6030
5976
  requestEmailChange,
6031
5977
  requireAuth,
6032
5978
  requireVerifiedEmail,