@@ -5,9 +5,168 @@ import { decryptSecret } from "@/lib/secrets";
55export type MailPayload = {
66 to : string ;
77 subject : string ;
8- text : string ;
8+ text ?: string ;
9+ html ?: string ;
910} ;
1011
12+ function escapeHtml ( value : string ) : string {
13+ return value
14+ . replace ( / & / g, "&" )
15+ . replace ( / < / g, "<" )
16+ . replace ( / > / g, ">" )
17+ . replace ( / \" / g, """ )
18+ . replace ( / ' / g, "'" ) ;
19+ }
20+
21+ function wrapMailHtml ( title : string , contentHtml : string , footer ?: string ) : string {
22+ return `<!doctype html>
23+ <html lang="en">
24+ <body style="margin:0;padding:0;background:#f3f6fb;font-family:'Segoe UI',Arial,sans-serif;color:#0f172a;">
25+ <table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="padding:28px 12px;">
26+ <tr>
27+ <td align="center">
28+ <table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="max-width:620px;background:#ffffff;border:1px solid #dbe4f0;border-radius:14px;overflow:hidden;">
29+ <tr>
30+ <td style="padding:20px 24px;background:linear-gradient(120deg,#0f172a,#1e293b);color:#ffffff;">
31+ <h1 style="margin:0;font-size:20px;line-height:1.2;font-weight:700;">ServerCommander</h1>
32+ <p style="margin:8px 0 0 0;font-size:13px;opacity:0.9;">${ escapeHtml ( title ) } </p>
33+ </td>
34+ </tr>
35+ <tr>
36+ <td style="padding:24px;">
37+ ${ contentHtml }
38+ </td>
39+ </tr>
40+ <tr>
41+ <td style="padding:14px 24px;background:#f8fafc;border-top:1px solid #e2e8f0;color:#475569;font-size:12px;line-height:1.5;">
42+ ${ escapeHtml ( footer ?? "Automatic security message from ServerCommander." ) }
43+ </td>
44+ </tr>
45+ </table>
46+ </td>
47+ </tr>
48+ </table>
49+ </body>
50+ </html>` ;
51+ }
52+
53+ export function buildWelcomeCredentialsMail ( input : {
54+ displayName : string ;
55+ username : string ;
56+ temporaryPassword : string ;
57+ } ) : { subject : string ; text : string ; html : string } {
58+ const subject = "Welcome to ServerCommander" ;
59+ const text = [
60+ `Hello ${ input . displayName } ,` ,
61+ "" ,
62+ "Your ServerCommander account has been created." ,
63+ `Username: ${ input . username } ` ,
64+ `Temporary password: ${ input . temporaryPassword } ` ,
65+ "" ,
66+ "At first login you must change your password." ,
67+ ] . join ( "\n" ) ;
68+
69+ const html = wrapMailHtml (
70+ "Your account is ready" ,
71+ `<p style="margin:0 0 12px 0;font-size:15px;">Hello ${ escapeHtml ( input . displayName ) } ,</p>
72+ <p style="margin:0 0 16px 0;font-size:14px;color:#334155;">Your ServerCommander account was created. Use these initial credentials:</p>
73+ <table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="border:1px solid #e2e8f0;border-radius:10px;background:#f8fafc;">
74+ <tr><td style="padding:12px 14px;font-size:13px;color:#475569;">Username</td><td style="padding:12px 14px;font-size:14px;font-weight:600;color:#0f172a;">${ escapeHtml ( input . username ) } </td></tr>
75+ <tr><td style="padding:12px 14px;font-size:13px;color:#475569;border-top:1px solid #e2e8f0;">Temporary password</td><td style="padding:12px 14px;font-size:14px;font-weight:600;color:#0f172a;border-top:1px solid #e2e8f0;">${ escapeHtml ( input . temporaryPassword ) } </td></tr>
76+ </table>
77+ <p style="margin:16px 0 0 0;font-size:13px;color:#334155;">You will be asked to change this password at first login.</p>` ,
78+ "Do not share this email. If this account was not expected, contact an administrator immediately."
79+ ) ;
80+
81+ return { subject, text, html } ;
82+ }
83+
84+ export function buildLoginCodeMail ( input : {
85+ displayName : string ;
86+ code : string ;
87+ minutesValid : number ;
88+ } ) : { subject : string ; text : string ; html : string } {
89+ const subject = "ServerCommander Login Code" ;
90+ const text = [
91+ `Hello ${ input . displayName } ,` ,
92+ "" ,
93+ `Your login code is: ${ input . code } ` ,
94+ `This code expires in ${ input . minutesValid } minutes.` ,
95+ "" ,
96+ "If you did not request this login, contact your administrator." ,
97+ ] . join ( "\n" ) ;
98+
99+ const html = wrapMailHtml (
100+ "Two-factor authentication" ,
101+ `<p style="margin:0 0 12px 0;font-size:15px;">Hello ${ escapeHtml ( input . displayName ) } ,</p>
102+ <p style="margin:0 0 14px 0;font-size:14px;color:#334155;">Use this one-time login code:</p>
103+ <div style="display:inline-block;padding:12px 18px;border-radius:10px;background:#0f172a;color:#ffffff;font-size:26px;font-weight:700;letter-spacing:4px;">${ escapeHtml ( input . code ) } </div>
104+ <p style="margin:14px 0 0 0;font-size:13px;color:#334155;">This code expires in ${ input . minutesValid } minutes.</p>` ,
105+ "Never share this code with anyone."
106+ ) ;
107+
108+ return { subject, text, html } ;
109+ }
110+
111+ export function buildPasswordResetCodeMail ( input : {
112+ displayName : string ;
113+ code : string ;
114+ minutesValid : number ;
115+ } ) : { subject : string ; text : string ; html : string } {
116+ const subject = "ServerCommander Password Reset Code" ;
117+ const text = [
118+ `Hello ${ input . displayName } ,` ,
119+ "" ,
120+ `Your password reset code is: ${ input . code } ` ,
121+ `This code expires in ${ input . minutesValid } minutes.` ,
122+ ] . join ( "\n" ) ;
123+
124+ const html = wrapMailHtml (
125+ "Password reset request" ,
126+ `<p style="margin:0 0 12px 0;font-size:15px;">Hello ${ escapeHtml ( input . displayName ) } ,</p>
127+ <p style="margin:0 0 14px 0;font-size:14px;color:#334155;">Use this one-time code to reset your password:</p>
128+ <div style="display:inline-block;padding:12px 18px;border-radius:10px;background:#0f172a;color:#ffffff;font-size:26px;font-weight:700;letter-spacing:4px;">${ escapeHtml ( input . code ) } </div>
129+ <p style="margin:14px 0 0 0;font-size:13px;color:#334155;">This code expires in ${ input . minutesValid } minutes.</p>` ,
130+ "If you did not request a reset, you can ignore this message."
131+ ) ;
132+
133+ return { subject, text, html } ;
134+ }
135+
136+ export function buildSmtpTestMail ( input : {
137+ host : string ;
138+ port : number ;
139+ secure : boolean ;
140+ fromEmail : string ;
141+ } ) : { subject : string ; text : string ; html : string } {
142+ const subject = "ServerCommander SMTP Test" ;
143+ const transport = input . secure
144+ ? ( input . port === 465 ? "SSL/TLS (implicit)" : "STARTTLS" )
145+ : "unencrypted/optional TLS" ;
146+ const text = [
147+ "This is a test email from ServerCommander." ,
148+ "" ,
149+ `Host: ${ input . host } ` ,
150+ `Port: ${ input . port } ` ,
151+ `Transport: ${ transport } ` ,
152+ `From: ${ input . fromEmail } ` ,
153+ ] . join ( "\n" ) ;
154+
155+ const html = wrapMailHtml (
156+ "SMTP test successful" ,
157+ `<p style="margin:0 0 12px 0;font-size:15px;">This confirms that outgoing email is working.</p>
158+ <table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="border:1px solid #e2e8f0;border-radius:10px;background:#f8fafc;">
159+ <tr><td style="padding:12px 14px;font-size:13px;color:#475569;">Host</td><td style="padding:12px 14px;font-size:14px;font-weight:600;color:#0f172a;">${ escapeHtml ( input . host ) } </td></tr>
160+ <tr><td style="padding:12px 14px;font-size:13px;color:#475569;border-top:1px solid #e2e8f0;">Port</td><td style="padding:12px 14px;font-size:14px;font-weight:600;color:#0f172a;border-top:1px solid #e2e8f0;">${ input . port } </td></tr>
161+ <tr><td style="padding:12px 14px;font-size:13px;color:#475569;border-top:1px solid #e2e8f0;">Transport</td><td style="padding:12px 14px;font-size:14px;font-weight:600;color:#0f172a;border-top:1px solid #e2e8f0;">${ escapeHtml ( transport ) } </td></tr>
162+ <tr><td style="padding:12px 14px;font-size:13px;color:#475569;border-top:1px solid #e2e8f0;">From</td><td style="padding:12px 14px;font-size:14px;font-weight:600;color:#0f172a;border-top:1px solid #e2e8f0;">${ escapeHtml ( input . fromEmail ) } </td></tr>
163+ </table>` ,
164+ "No further action required."
165+ ) ;
166+
167+ return { subject, text, html } ;
168+ }
169+
11170export async function getSmtpSettings ( ) {
12171 return db . smtpSettings . findUnique ( { where : { id : "default" } } ) ;
13172}
@@ -25,6 +184,9 @@ export async function sendMail(payload: MailPayload): Promise<void> {
25184 if ( ! cfg . host || ! cfg . port || ! cfg . username || ! cfg . passwordEnc || ! cfg . fromEmail ) {
26185 throw new Error ( "SMTP config incomplete" ) ;
27186 }
187+ if ( ! payload . text && ! payload . html ) {
188+ throw new Error ( "Mail payload requires text or html content" ) ;
189+ }
28190
29191 const useImplicitTls = cfg . secure && cfg . port === 465 ;
30192 const requireStartTls = cfg . secure && cfg . port !== 465 ;
@@ -49,5 +211,6 @@ export async function sendMail(payload: MailPayload): Promise<void> {
49211 to : payload . to ,
50212 subject : payload . subject ,
51213 text : payload . text ,
214+ html : payload . html ,
52215 } ) ;
53216}
0 commit comments