hazo_auth 0.1.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.
Files changed (162) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +48 -0
  3. package/components.json +22 -0
  4. package/hazo_auth_config.example.ini +414 -0
  5. package/hazo_notify_config.example.ini +159 -0
  6. package/instrumentation.ts +32 -0
  7. package/migrations/001_add_token_type_to_refresh_tokens.sql +14 -0
  8. package/migrations/002_add_name_to_hazo_users.sql +7 -0
  9. package/next.config.mjs +55 -0
  10. package/package.json +114 -0
  11. package/postcss.config.mjs +8 -0
  12. package/public/file.svg +1 -0
  13. package/public/globe.svg +1 -0
  14. package/public/next.svg +1 -0
  15. package/public/vercel.svg +1 -0
  16. package/public/window.svg +1 -0
  17. package/scripts/apply_migration.ts +118 -0
  18. package/src/app/api/auth/change_password/route.ts +109 -0
  19. package/src/app/api/auth/forgot_password/route.ts +107 -0
  20. package/src/app/api/auth/library_photos/route.ts +70 -0
  21. package/src/app/api/auth/login/route.ts +155 -0
  22. package/src/app/api/auth/logout/route.ts +62 -0
  23. package/src/app/api/auth/me/route.ts +47 -0
  24. package/src/app/api/auth/profile_picture/[filename]/route.ts +67 -0
  25. package/src/app/api/auth/register/route.ts +106 -0
  26. package/src/app/api/auth/remove_profile_picture/route.ts +86 -0
  27. package/src/app/api/auth/resend_verification/route.ts +107 -0
  28. package/src/app/api/auth/reset_password/route.ts +107 -0
  29. package/src/app/api/auth/update_user/route.ts +126 -0
  30. package/src/app/api/auth/upload_profile_picture/route.ts +268 -0
  31. package/src/app/api/auth/validate_reset_token/route.ts +80 -0
  32. package/src/app/api/auth/verify_email/route.ts +85 -0
  33. package/src/app/api/migrations/apply/route.ts +91 -0
  34. package/src/app/favicon.ico +0 -0
  35. package/src/app/fonts/GeistMonoVF.woff +0 -0
  36. package/src/app/fonts/GeistVF.woff +0 -0
  37. package/src/app/forgot_password/forgot_password_page_client.tsx +60 -0
  38. package/src/app/forgot_password/page.tsx +24 -0
  39. package/src/app/globals.css +89 -0
  40. package/src/app/hazo_connect/api/sqlite/data/route.ts +197 -0
  41. package/src/app/hazo_connect/api/sqlite/schema/route.ts +35 -0
  42. package/src/app/hazo_connect/api/sqlite/tables/route.ts +26 -0
  43. package/src/app/hazo_connect/sqlite_admin/page.tsx +51 -0
  44. package/src/app/hazo_connect/sqlite_admin/sqlite-admin-client.tsx +947 -0
  45. package/src/app/layout.tsx +43 -0
  46. package/src/app/login/login_page_client.tsx +71 -0
  47. package/src/app/login/page.tsx +26 -0
  48. package/src/app/my_settings/my_settings_page_client.tsx +120 -0
  49. package/src/app/my_settings/page.tsx +40 -0
  50. package/src/app/page.tsx +170 -0
  51. package/src/app/register/page.tsx +26 -0
  52. package/src/app/register/register_page_client.tsx +72 -0
  53. package/src/app/reset_password/page.tsx +29 -0
  54. package/src/app/reset_password/reset_password_page_client.tsx +81 -0
  55. package/src/app/verify_email/page.tsx +24 -0
  56. package/src/app/verify_email/verify_email_page_client.tsx +60 -0
  57. package/src/components/layouts/email_verification/config/email_verification_field_config.ts +86 -0
  58. package/src/components/layouts/email_verification/hooks/use_email_verification.ts +291 -0
  59. package/src/components/layouts/email_verification/index.tsx +297 -0
  60. package/src/components/layouts/forgot_password/config/forgot_password_field_config.ts +58 -0
  61. package/src/components/layouts/forgot_password/hooks/use_forgot_password_form.ts +179 -0
  62. package/src/components/layouts/forgot_password/index.tsx +168 -0
  63. package/src/components/layouts/login/config/login_field_config.ts +67 -0
  64. package/src/components/layouts/login/hooks/use_login_form.ts +281 -0
  65. package/src/components/layouts/login/index.tsx +224 -0
  66. package/src/components/layouts/my_settings/components/editable_field.tsx +177 -0
  67. package/src/components/layouts/my_settings/components/password_change_dialog.tsx +301 -0
  68. package/src/components/layouts/my_settings/components/profile_picture_dialog.tsx +385 -0
  69. package/src/components/layouts/my_settings/components/profile_picture_display.tsx +66 -0
  70. package/src/components/layouts/my_settings/components/profile_picture_gravatar_tab.tsx +143 -0
  71. package/src/components/layouts/my_settings/components/profile_picture_library_tab.tsx +282 -0
  72. package/src/components/layouts/my_settings/components/profile_picture_upload_tab.tsx +341 -0
  73. package/src/components/layouts/my_settings/config/my_settings_field_config.ts +61 -0
  74. package/src/components/layouts/my_settings/hooks/use_my_settings.ts +458 -0
  75. package/src/components/layouts/my_settings/index.tsx +351 -0
  76. package/src/components/layouts/register/config/register_field_config.ts +101 -0
  77. package/src/components/layouts/register/hooks/use_register_form.ts +272 -0
  78. package/src/components/layouts/register/index.tsx +208 -0
  79. package/src/components/layouts/reset_password/config/reset_password_field_config.ts +86 -0
  80. package/src/components/layouts/reset_password/hooks/use_reset_password_form.ts +276 -0
  81. package/src/components/layouts/reset_password/index.tsx +294 -0
  82. package/src/components/layouts/shared/components/already_logged_in_guard.tsx +95 -0
  83. package/src/components/layouts/shared/components/field_error_message.tsx +29 -0
  84. package/src/components/layouts/shared/components/form_action_buttons.tsx +64 -0
  85. package/src/components/layouts/shared/components/form_field_wrapper.tsx +44 -0
  86. package/src/components/layouts/shared/components/form_header.tsx +36 -0
  87. package/src/components/layouts/shared/components/logout_button.tsx +76 -0
  88. package/src/components/layouts/shared/components/password_field.tsx +72 -0
  89. package/src/components/layouts/shared/components/sidebar_layout_wrapper.tsx +264 -0
  90. package/src/components/layouts/shared/components/two_column_auth_layout.tsx +44 -0
  91. package/src/components/layouts/shared/components/unauthorized_guard.tsx +78 -0
  92. package/src/components/layouts/shared/components/visual_panel.tsx +41 -0
  93. package/src/components/layouts/shared/config/layout_customization.ts +95 -0
  94. package/src/components/layouts/shared/data/layout_data_client.ts +19 -0
  95. package/src/components/layouts/shared/hooks/use_auth_status.ts +103 -0
  96. package/src/components/layouts/shared/utils/ip_address.ts +37 -0
  97. package/src/components/layouts/shared/utils/validation.ts +66 -0
  98. package/src/components/ui/avatar.tsx +50 -0
  99. package/src/components/ui/button.tsx +57 -0
  100. package/src/components/ui/dialog.tsx +122 -0
  101. package/src/components/ui/hazo_ui_tooltip.tsx +67 -0
  102. package/src/components/ui/input.tsx +22 -0
  103. package/src/components/ui/label.tsx +26 -0
  104. package/src/components/ui/separator.tsx +31 -0
  105. package/src/components/ui/sheet.tsx +139 -0
  106. package/src/components/ui/sidebar.tsx +773 -0
  107. package/src/components/ui/skeleton.tsx +15 -0
  108. package/src/components/ui/sonner.tsx +31 -0
  109. package/src/components/ui/switch.tsx +29 -0
  110. package/src/components/ui/tabs.tsx +55 -0
  111. package/src/components/ui/tooltip.tsx +32 -0
  112. package/src/components/ui/vertical-tabs.tsx +59 -0
  113. package/src/hooks/use-mobile.tsx +19 -0
  114. package/src/lib/already_logged_in_config.server.ts +46 -0
  115. package/src/lib/app_logger.ts +24 -0
  116. package/src/lib/auth/auth_utils.server.ts +196 -0
  117. package/src/lib/auth/server_auth.ts +88 -0
  118. package/src/lib/config/config_loader.server.ts +149 -0
  119. package/src/lib/email_verification_config.server.ts +32 -0
  120. package/src/lib/file_types_config.server.ts +25 -0
  121. package/src/lib/forgot_password_config.server.ts +32 -0
  122. package/src/lib/hazo_connect_instance.server.ts +77 -0
  123. package/src/lib/hazo_connect_setup.server.ts +181 -0
  124. package/src/lib/hazo_connect_setup.ts +54 -0
  125. package/src/lib/login_config.server.ts +46 -0
  126. package/src/lib/messages_config.server.ts +45 -0
  127. package/src/lib/migrations/apply_migration.ts +105 -0
  128. package/src/lib/my_settings_config.server.ts +135 -0
  129. package/src/lib/password_requirements_config.server.ts +39 -0
  130. package/src/lib/profile_picture_config.server.ts +56 -0
  131. package/src/lib/register_config.server.ts +57 -0
  132. package/src/lib/reset_password_config.server.ts +75 -0
  133. package/src/lib/services/email_service.ts +581 -0
  134. package/src/lib/services/email_verification_service.ts +264 -0
  135. package/src/lib/services/login_service.ts +118 -0
  136. package/src/lib/services/password_change_service.ts +154 -0
  137. package/src/lib/services/password_reset_service.ts +405 -0
  138. package/src/lib/services/profile_picture_remove_service.ts +120 -0
  139. package/src/lib/services/profile_picture_service.ts +215 -0
  140. package/src/lib/services/profile_picture_source_mapper.ts +62 -0
  141. package/src/lib/services/registration_service.ts +163 -0
  142. package/src/lib/services/token_service.ts +240 -0
  143. package/src/lib/services/user_update_service.ts +128 -0
  144. package/src/lib/ui_sizes_config.server.ts +37 -0
  145. package/src/lib/user_fields_config.server.ts +31 -0
  146. package/src/lib/utils/api_route_helpers.ts +60 -0
  147. package/src/lib/utils.ts +11 -0
  148. package/src/middleware.ts +91 -0
  149. package/src/server/config/config_loader.ts +496 -0
  150. package/src/server/index.ts +38 -0
  151. package/src/server/logging/logger_service.ts +56 -0
  152. package/src/server/routes/root_router.ts +16 -0
  153. package/src/server/server.ts +28 -0
  154. package/src/server/types/app_types.ts +74 -0
  155. package/src/server/types/express.d.ts +15 -0
  156. package/src/stories/email_verification_layout.stories.tsx +137 -0
  157. package/src/stories/forgot_password_layout.stories.tsx +85 -0
  158. package/src/stories/login_layout.stories.tsx +85 -0
  159. package/src/stories/project_overview.stories.tsx +33 -0
  160. package/src/stories/register_layout.stories.tsx +107 -0
  161. package/tailwind.config.ts +77 -0
  162. package/tsconfig.json +27 -0
@@ -0,0 +1,581 @@
1
+ // file_description: service for sending emails with template support
2
+ // section: imports
3
+ import fs from "fs";
4
+ import path from "path";
5
+ import { create_app_logger } from "../app_logger";
6
+ import { read_config_section } from "../config/config_loader.server";
7
+ import type { EmailerConfig, SendEmailOptions } from "hazo_notify";
8
+
9
+ // section: types
10
+ export type EmailOptions = {
11
+ to: string;
12
+ from: string;
13
+ subject: string;
14
+ html_body?: string;
15
+ text_body?: string;
16
+ };
17
+
18
+ export type EmailTemplateType = "forgot_password" | "email_verification" | "password_changed";
19
+
20
+ export type EmailTemplateData = {
21
+ token?: string;
22
+ verification_url?: string;
23
+ reset_url?: string;
24
+ user_email?: string;
25
+ user_name?: string;
26
+ [key: string]: string | undefined;
27
+ };
28
+
29
+ // section: constants
30
+ const DEFAULT_EMAIL_FROM = "noreply@hazo_auth.local";
31
+ const DEFAULT_EMAIL_TEMPLATE_DIR = path.resolve(process.cwd(), "email_templates");
32
+
33
+ // section: singleton
34
+ /**
35
+ * Singleton instance for hazo_notify emailer configuration
36
+ * This is initialized once in instrumentation.ts and reused across all email sends
37
+ */
38
+ let hazo_notify_config: EmailerConfig | null = null;
39
+
40
+ /**
41
+ * Sets the hazo_notify emailer configuration instance
42
+ * This is called from instrumentation.ts during initialization
43
+ * @param config - The hazo_notify emailer configuration instance
44
+ */
45
+ export function set_hazo_notify_instance(config: EmailerConfig): void {
46
+ hazo_notify_config = config;
47
+ }
48
+
49
+ /**
50
+ * Gets the hazo_notify emailer configuration instance
51
+ * If not set, loads it from config file as fallback
52
+ * @returns The hazo_notify emailer configuration instance
53
+ */
54
+ async function get_hazo_notify_instance(): Promise<EmailerConfig> {
55
+ if (!hazo_notify_config) {
56
+ // Fallback: load from config file if not initialized
57
+ const logger = create_app_logger();
58
+ logger.warn("hazo_notify_instance_not_initialized", {
59
+ filename: "email_service.ts",
60
+ line_number: 0,
61
+ note: "hazo_notify instance not initialized in instrumentation.ts, loading from config file as fallback",
62
+ });
63
+ try {
64
+ // Dynamic import to avoid build-time issues with hazo_notify
65
+ const hazo_notify_module = await import("hazo_notify");
66
+ const { load_emailer_config } = hazo_notify_module;
67
+ hazo_notify_config = load_emailer_config();
68
+ } catch (error) {
69
+ const error_message = error instanceof Error ? error.message : "Unknown error";
70
+ logger.error("hazo_notify_config_load_failed", {
71
+ filename: "email_service.ts",
72
+ line_number: 0,
73
+ error: error_message,
74
+ });
75
+ throw new Error(`Failed to load hazo_notify config: ${error_message}`);
76
+ }
77
+ }
78
+ return hazo_notify_config;
79
+ }
80
+
81
+ // section: helpers
82
+ /**
83
+ * Gets email template directory from config
84
+ * @returns Email template directory path
85
+ */
86
+ function get_email_template_directory(): string {
87
+ const email_section = read_config_section("hazo_auth__email");
88
+ const template_dir = email_section?.email_template_main_directory;
89
+
90
+ if (template_dir) {
91
+ return path.isAbsolute(template_dir)
92
+ ? template_dir
93
+ : path.resolve(process.cwd(), template_dir);
94
+ }
95
+
96
+ return DEFAULT_EMAIL_TEMPLATE_DIR;
97
+ }
98
+
99
+ /**
100
+ * Gets email from address from config
101
+ * Priority: 1. hazo_auth__email.from_email, 2. hazo_notify_config.from_email
102
+ * @param notify_config - The hazo_notify configuration instance (for fallback)
103
+ * @returns Email from address
104
+ */
105
+ async function get_email_from(notify_config: EmailerConfig): Promise<string> {
106
+ const email_section = read_config_section("hazo_auth__email");
107
+ const hazo_auth_from_email = email_section?.from_email;
108
+
109
+ // If set in hazo_auth_config.ini, use it (overrides hazo_notify config)
110
+ if (hazo_auth_from_email) {
111
+ return hazo_auth_from_email;
112
+ }
113
+
114
+ // Fall back to hazo_notify config
115
+ return notify_config.from_email;
116
+ }
117
+
118
+ /**
119
+ * Gets email from name from config
120
+ * Priority: 1. hazo_auth__email.from_name, 2. hazo_notify_config.from_name
121
+ * @param notify_config - The hazo_notify configuration instance (for fallback)
122
+ * @returns Email from name
123
+ */
124
+ async function get_email_from_name(notify_config: EmailerConfig): Promise<string> {
125
+ const email_section = read_config_section("hazo_auth__email");
126
+ const hazo_auth_from_name = email_section?.from_name;
127
+
128
+ // If set in hazo_auth_config.ini, use it (overrides hazo_notify config)
129
+ if (hazo_auth_from_name) {
130
+ return hazo_auth_from_name;
131
+ }
132
+
133
+ // Fall back to hazo_notify config
134
+ return notify_config.from_name;
135
+ }
136
+
137
+ /**
138
+ * Gets base URL for email links from config or environment variable
139
+ * Priority: 1. hazo_auth__email.base_url, 2. APP_DOMAIN_NAME, 3. NEXT_PUBLIC_APP_URL/APP_URL
140
+ * @returns Base URL for email links
141
+ */
142
+ function get_base_url(): string {
143
+ const email_section = read_config_section("hazo_auth__email");
144
+ const base_url = email_section?.base_url;
145
+
146
+ if (base_url) {
147
+ return base_url.endsWith("/") ? base_url.slice(0, -1) : base_url;
148
+ }
149
+
150
+ // Try to get from APP_DOMAIN_NAME environment variable (adds protocol if needed)
151
+ const app_domain_name = process.env.APP_DOMAIN_NAME;
152
+ if (app_domain_name) {
153
+ // Ensure protocol is included (default to https if not specified)
154
+ const domain = app_domain_name.trim();
155
+ if (domain.startsWith("http://") || domain.startsWith("https://")) {
156
+ return domain.endsWith("/") ? domain.slice(0, -1) : domain;
157
+ }
158
+ // If no protocol, default to https
159
+ return `https://${domain}`;
160
+ }
161
+
162
+ // Try to get from other environment variables (fallback)
163
+ const env_base_url = process.env.NEXT_PUBLIC_APP_URL || process.env.APP_URL;
164
+ if (env_base_url) {
165
+ return env_base_url.endsWith("/") ? env_base_url.slice(0, -1) : env_base_url;
166
+ }
167
+
168
+ // Default to empty string (will use relative URLs)
169
+ return "";
170
+ }
171
+
172
+ /**
173
+ * Constructs verification URL from token
174
+ * @param token - Verification token
175
+ * @returns Verification URL
176
+ */
177
+ function get_verification_url(token: string): string {
178
+ const base_url = get_base_url();
179
+ const path = "/verify_email";
180
+ const url = base_url ? `${base_url}${path}?token=${encodeURIComponent(token)}` : `${path}?token=${encodeURIComponent(token)}`;
181
+ return url;
182
+ }
183
+
184
+ /**
185
+ * Constructs reset password URL from token
186
+ * @param token - Password reset token
187
+ * @returns Reset password URL
188
+ */
189
+ function get_reset_password_url(token: string): string {
190
+ const base_url = get_base_url();
191
+ const path = "/reset_password";
192
+ const url = base_url ? `${base_url}${path}?token=${encodeURIComponent(token)}` : `${path}?token=${encodeURIComponent(token)}`;
193
+ return url;
194
+ }
195
+
196
+ /**
197
+ * Gets default HTML template for a given template type
198
+ * @param template_type - Type of email template
199
+ * @param data - Template data for variable substitution
200
+ * @returns Default HTML template content
201
+ */
202
+ function get_default_html_template(
203
+ template_type: EmailTemplateType,
204
+ data: EmailTemplateData
205
+ ): string {
206
+ switch (template_type) {
207
+ case "email_verification":
208
+ return `
209
+ <!DOCTYPE html>
210
+ <html>
211
+ <head>
212
+ <meta charset="UTF-8">
213
+ <title>Verify Your Email</title>
214
+ </head>
215
+ <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
216
+ <h1 style="color: #0f172a;">Verify Your Email Address</h1>
217
+ <p>Thank you for registering! Please click the link below to verify your email address:</p>
218
+ <p style="margin: 20px 0;">
219
+ <a href="${data.verification_url || "#"}" style="display: inline-block; padding: 12px 24px; background-color: #0f172a; color: #ffffff; text-decoration: none; border-radius: 4px;">Verify Email Address</a>
220
+ </p>
221
+ <p>Or copy and paste this link into your browser:</p>
222
+ <p style="word-break: break-all; color: #666;">${data.verification_url || data.token || ""}</p>
223
+ <p>This link will expire in 48 hours.</p>
224
+ <p style="margin-top: 30px; color: #666; font-size: 12px;">If you didn't create an account, you can safely ignore this email.</p>
225
+ </body>
226
+ </html>
227
+ `.trim();
228
+
229
+ case "forgot_password":
230
+ return `
231
+ <!DOCTYPE html>
232
+ <html>
233
+ <head>
234
+ <meta charset="UTF-8">
235
+ <title>Reset Your Password</title>
236
+ </head>
237
+ <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
238
+ <h1 style="color: #0f172a;">Reset Your Password</h1>
239
+ <p>We received a request to reset your password. Click the link below to reset it:</p>
240
+ <p style="margin: 20px 0;">
241
+ <a href="${data.reset_url || "#"}" style="display: inline-block; padding: 12px 24px; background-color: #0f172a; color: #ffffff; text-decoration: none; border-radius: 4px;">Reset Password</a>
242
+ </p>
243
+ <p>Or copy and paste this link into your browser:</p>
244
+ <p style="word-break: break-all; color: #666;">${data.reset_url || data.token || ""}</p>
245
+ <p>This link will expire in 10 minutes.</p>
246
+ <p style="margin-top: 30px; color: #666; font-size: 12px;">If you didn't request a password reset, you can safely ignore this email.</p>
247
+ </body>
248
+ </html>
249
+ `.trim();
250
+
251
+ case "password_changed":
252
+ return `
253
+ <!DOCTYPE html>
254
+ <html>
255
+ <head>
256
+ <meta charset="UTF-8">
257
+ <title>Password Changed</title>
258
+ </head>
259
+ <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
260
+ <h1 style="color: #0f172a;">Password Changed Successfully</h1>
261
+ <p>Hello${data.user_name ? ` ${data.user_name}` : ""},</p>
262
+ <p>This email confirms that your password has been changed successfully.</p>
263
+ <p>If you did not make this change, please contact support immediately to secure your account.</p>
264
+ <p style="margin-top: 30px; color: #666; font-size: 12px;">This is an automated notification. Please do not reply to this email.</p>
265
+ </body>
266
+ </html>
267
+ `.trim();
268
+
269
+ default:
270
+ return "<p>Email content</p>";
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Gets default text template for a given template type
276
+ * @param template_type - Type of email template
277
+ * @param data - Template data for variable substitution
278
+ * @returns Default text template content
279
+ */
280
+ function get_default_text_template(
281
+ template_type: EmailTemplateType,
282
+ data: EmailTemplateData
283
+ ): string {
284
+ switch (template_type) {
285
+ case "email_verification":
286
+ return `
287
+ Verify Your Email Address
288
+
289
+ Thank you for registering! Please click the link below to verify your email address:
290
+
291
+ ${data.verification_url || data.token || ""}
292
+
293
+ This link will expire in 48 hours.
294
+
295
+ If you didn't create an account, you can safely ignore this email.
296
+ `.trim();
297
+
298
+ case "forgot_password":
299
+ return `
300
+ Reset Your Password
301
+
302
+ We received a request to reset your password. Click the link below to reset it:
303
+
304
+ ${data.reset_url || data.token || ""}
305
+
306
+ This link will expire in 10 minutes.
307
+
308
+ If you didn't request a password reset, you can safely ignore this email.
309
+ `.trim();
310
+
311
+ case "password_changed":
312
+ return `
313
+ Password Changed Successfully
314
+
315
+ Hello${data.user_name ? ` ${data.user_name}` : ""},
316
+
317
+ This email confirms that your password has been changed successfully.
318
+
319
+ If you did not make this change, please contact support immediately to secure your account.
320
+
321
+ This is an automated notification. Please do not reply to this email.
322
+ `.trim();
323
+
324
+ default:
325
+ return "Email content";
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Loads email template from file system
331
+ * @param template_type - Type of email template
332
+ * @param extension - File extension (html or txt)
333
+ * @returns Template content or undefined if not found
334
+ */
335
+ function load_template_file(
336
+ template_type: EmailTemplateType,
337
+ extension: "html" | "txt"
338
+ ): string | undefined {
339
+ const template_dir = get_email_template_directory();
340
+ const template_filename = `${template_type}.${extension}`;
341
+ const template_path = path.join(template_dir, template_filename);
342
+
343
+ if (!fs.existsSync(template_path)) {
344
+ return undefined;
345
+ }
346
+
347
+ try {
348
+ return fs.readFileSync(template_path, "utf-8");
349
+ } catch (error) {
350
+ const logger = create_app_logger();
351
+ logger.error("email_service_template_load_failed", {
352
+ filename: "email_service.ts",
353
+ line_number: 0,
354
+ template_path,
355
+ error: error instanceof Error ? error.message : "Unknown error",
356
+ });
357
+ return undefined;
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Simple template variable substitution
363
+ * Replaces {{variable_name}} with values from data object
364
+ * @param template - Template string with variables
365
+ * @param data - Data object for variable substitution
366
+ * @returns Template with variables substituted
367
+ */
368
+ function substitute_template_variables(
369
+ template: string,
370
+ data: EmailTemplateData
371
+ ): string {
372
+ let result = template;
373
+
374
+ // Replace {{variable}} with values from data
375
+ Object.entries(data).forEach(([key, value]) => {
376
+ if (value !== undefined) {
377
+ const regex = new RegExp(`{{\\s*${key}\\s*}}`, "g");
378
+ result = result.replace(regex, value);
379
+ }
380
+ });
381
+
382
+ return result;
383
+ }
384
+
385
+ /**
386
+ * Gets email subject for a given template type
387
+ * @param template_type - Type of email template
388
+ * @returns Email subject
389
+ */
390
+ function get_email_subject(template_type: EmailTemplateType): string {
391
+ const email_section = read_config_section("hazo_auth__email");
392
+ const template_config_key = `email_template__${template_type}`;
393
+ const subject_key = `${template_config_key}__subject`;
394
+
395
+ // Try to get subject from config
396
+ const subject = email_section?.[subject_key];
397
+ if (subject) {
398
+ return subject;
399
+ }
400
+
401
+ // Default subjects
402
+ switch (template_type) {
403
+ case "email_verification":
404
+ return "Verify Your Email Address";
405
+ case "forgot_password":
406
+ return "Reset Your Password";
407
+ case "password_changed":
408
+ return "Password Changed Successfully";
409
+ default:
410
+ return "Email from hazo_auth";
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Gets email templates (HTML and text) for a given template type
416
+ * Falls back to default templates if custom templates are not found
417
+ * @param template_type - Type of email template
418
+ * @param data - Template data for variable substitution
419
+ * @returns Object with html_body and text_body
420
+ */
421
+ function get_email_templates(
422
+ template_type: EmailTemplateType,
423
+ data: EmailTemplateData
424
+ ): { html_body: string; text_body: string } {
425
+ // Try to load custom templates
426
+ const html_template = load_template_file(template_type, "html");
427
+ const text_template = load_template_file(template_type, "txt");
428
+
429
+ // Use custom templates if found, otherwise use defaults
430
+ const html_body = html_template
431
+ ? substitute_template_variables(html_template, data)
432
+ : get_default_html_template(template_type, data);
433
+
434
+ const text_body = text_template
435
+ ? substitute_template_variables(text_template, data)
436
+ : get_default_text_template(template_type, data);
437
+
438
+ return { html_body, text_body };
439
+ }
440
+
441
+ /**
442
+ * Sends an email using hazo_notify
443
+ * @param options - Email options (to, from, subject, html_body, text_body)
444
+ * @returns Promise that resolves when email is sent
445
+ */
446
+ export async function send_email(options: EmailOptions): Promise<{ success: boolean; error?: string }> {
447
+ const logger = create_app_logger();
448
+
449
+ try {
450
+ // Get hazo_notify configuration instance
451
+ const notify_config = await get_hazo_notify_instance();
452
+
453
+ // Dynamic import to avoid build-time issues with hazo_notify
454
+ const hazo_notify_module = await import("hazo_notify");
455
+ const { send_email: hazo_notify_send_email } = hazo_notify_module;
456
+
457
+ // Get from email and from name (hazo_auth_config overrides hazo_notify_config)
458
+ // Priority: 1. options.from (explicit parameter), 2. hazo_auth_config.from_email, 3. hazo_notify_config.from_email
459
+ const from_email = options.from || await get_email_from(notify_config);
460
+ const from_name = await get_email_from_name(notify_config);
461
+
462
+ // Prepare hazo_notify email options
463
+ const hazo_notify_options: SendEmailOptions = {
464
+ to: options.to,
465
+ subject: options.subject,
466
+ content: {
467
+ ...(options.html_body && { html: options.html_body }),
468
+ ...(options.text_body && { text: options.text_body }),
469
+ },
470
+ // Use from_email and from_name (hazo_notify will use these instead of config defaults)
471
+ from: from_email,
472
+ from_name: from_name,
473
+ };
474
+
475
+ // Send email using hazo_notify
476
+ const result = await hazo_notify_send_email(hazo_notify_options, notify_config);
477
+
478
+ if (result.success) {
479
+ logger.info("email_sent", {
480
+ filename: "email_service.ts",
481
+ line_number: 0,
482
+ to: options.to,
483
+ from: options.from || notify_config.from_email,
484
+ subject: options.subject,
485
+ message_id: result.message_id,
486
+ });
487
+
488
+ return { success: true };
489
+ } else {
490
+ const error_message = result.error || result.message || "Unknown error";
491
+
492
+ logger.error("email_send_failed", {
493
+ filename: "email_service.ts",
494
+ line_number: 0,
495
+ to: options.to,
496
+ from: options.from || notify_config.from_email,
497
+ subject: options.subject,
498
+ error: error_message,
499
+ raw_response: result.raw_response,
500
+ });
501
+
502
+ return { success: false, error: error_message };
503
+ }
504
+ } catch (error) {
505
+ const error_message = error instanceof Error ? error.message : "Unknown error";
506
+
507
+ logger.error("email_send_failed", {
508
+ filename: "email_service.ts",
509
+ line_number: 0,
510
+ to: options.to,
511
+ from: options.from,
512
+ subject: options.subject,
513
+ error: error_message,
514
+ });
515
+
516
+ return { success: false, error: error_message };
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Sends an email using a template
522
+ * @param template_type - Type of email template
523
+ * @param to - Recipient email address
524
+ * @param data - Template data for variable substitution
525
+ * @returns Promise that resolves when email is sent
526
+ */
527
+ export async function send_template_email(
528
+ template_type: EmailTemplateType,
529
+ to: string,
530
+ data: EmailTemplateData
531
+ ): Promise<{ success: boolean; error?: string }> {
532
+ const logger = create_app_logger();
533
+
534
+ try {
535
+ // Enhance data with URLs if token is provided
536
+ const enhanced_data: EmailTemplateData = { ...data };
537
+
538
+ if (data.token) {
539
+ if (template_type === "email_verification") {
540
+ enhanced_data.verification_url = get_verification_url(data.token);
541
+ } else if (template_type === "forgot_password") {
542
+ enhanced_data.reset_url = get_reset_password_url(data.token);
543
+ }
544
+ }
545
+
546
+ // Get email templates
547
+ const { html_body, text_body } = get_email_templates(template_type, enhanced_data);
548
+
549
+ // Get email subject
550
+ const subject = get_email_subject(template_type);
551
+
552
+ // Get hazo_notify config instance
553
+ const notify_config = await get_hazo_notify_instance();
554
+
555
+ // Get email from address and from name
556
+ // Priority: 1. hazo_auth_config.from_email/from_name, 2. hazo_notify_config.from_email/from_name
557
+ const from = await get_email_from(notify_config);
558
+
559
+ // Send email (from_name is handled inside send_email function)
560
+ return await send_email({
561
+ to,
562
+ from,
563
+ subject,
564
+ html_body,
565
+ text_body,
566
+ });
567
+ } catch (error) {
568
+ const error_message = error instanceof Error ? error.message : "Unknown error";
569
+
570
+ logger.error("email_template_send_failed", {
571
+ filename: "email_service.ts",
572
+ line_number: 0,
573
+ template_type,
574
+ to,
575
+ error: error_message,
576
+ });
577
+
578
+ return { success: false, error: error_message };
579
+ }
580
+ }
581
+