Skip to content

Commit b254c46

Browse files
feat: ability to add recovery email (#616)
* Add code logic * Add migrations * Remove commented code + minor ui details\ * Show nicer message when email is set * Remove change on db:studio * Add extra var to console log email * Remove unused comment * Remove console log * Ability to use enter to submit verification code * Fix display issue on verify-email * fix: requested changes --------- Co-authored-by: BlankParticle <[email protected]>
1 parent 36f4e6c commit b254c46

File tree

9 files changed

+4091
-36
lines changed

9 files changed

+4091
-36
lines changed

apps/platform/trpc/routers/userRouter/securityRouter.ts

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import { TOTPController, createTOTPKeyURI } from 'oslo/otp';
2828
import { lucia } from '~platform/utils/auth';
2929
import { storage } from '~platform/storage';
3030
import { env } from '~platform/env';
31+
import crypto from 'crypto';
32+
import { sendRecoveryEmailConfirmation } from '~platform/utils/mail/transactional';
3133

3234
const authStorage = storage.auth;
3335

@@ -968,5 +970,106 @@ export const securityRouter = router({
968970
await lucia.invalidateUserSessions(accountData.id);
969971

970972
return { success: true };
971-
})
973+
}),
974+
975+
setRecoveryEmail: accountProcedure
976+
.input(
977+
z.object({
978+
recoveryEmail: z.string().email()
979+
})
980+
)
981+
.mutation(async ({ ctx, input }) => {
982+
const { db, account } = ctx;
983+
984+
const hashedEmail = await new Argon2id().hash(input.recoveryEmail);
985+
// add recovery code to cash
986+
await db
987+
.update(accounts)
988+
.set({
989+
recoveryEmailHash: hashedEmail
990+
})
991+
.where(eq(accounts.id, account.id));
992+
993+
const accountData = await db.query.accounts.findFirst({
994+
where: eq(accounts.id, account.id),
995+
columns: {
996+
username: true
997+
}
998+
});
999+
if (!accountData) {
1000+
throw new TRPCError({
1001+
code: 'NOT_FOUND',
1002+
message: 'Account data not found'
1003+
});
1004+
}
1005+
1006+
const verificationCode = nanoIdToken();
1007+
await authStorage.setItem(
1008+
`recoveryEmailVerificationCode:${account.id}`,
1009+
verificationCode
1010+
);
1011+
1012+
const confirmationUrl = `${env.WEBAPP_URL}/recovery/verify-email/?code=${verificationCode}`;
1013+
1014+
// Send verification email
1015+
await sendRecoveryEmailConfirmation({
1016+
to: input.recoveryEmail,
1017+
username: accountData.username,
1018+
recoveryEmail: input.recoveryEmail,
1019+
confirmationUrl: confirmationUrl,
1020+
expiryDate: datePlus('15 minutes').toDateString(),
1021+
verificationCode
1022+
});
1023+
1024+
return { success: true };
1025+
}),
1026+
1027+
verifyRecoveryEmail: accountProcedure
1028+
.input(
1029+
z.object({
1030+
verificationCode: z.string()
1031+
})
1032+
)
1033+
.mutation(async ({ ctx, input }) => {
1034+
const { db, account } = ctx;
1035+
1036+
const storedCode = await authStorage.getItem(
1037+
`recoveryEmailVerificationCode:${account.id}`
1038+
);
1039+
if (storedCode !== input.verificationCode) {
1040+
throw new TRPCError({
1041+
code: 'BAD_REQUEST',
1042+
message: 'Invalid verification code'
1043+
});
1044+
}
1045+
await db
1046+
.update(accounts)
1047+
.set({ recoveryEmailVerifiedAt: new Date() })
1048+
.where(eq(accounts.id, account.id));
1049+
1050+
authStorage.removeItem(`recoveryEmailVerificationCode:${account.id}`);
1051+
1052+
return { success: true };
1053+
}),
1054+
1055+
getRecoveryEmailStatus: accountProcedure.query(async ({ ctx }) => {
1056+
const { db, account } = ctx;
1057+
const result = await db.query.accounts.findFirst({
1058+
where: eq(accounts.id, account.id),
1059+
columns: {
1060+
recoveryEmailHash: true,
1061+
recoveryEmailVerifiedAt: true
1062+
}
1063+
});
1064+
if (!result) {
1065+
throw new TRPCError({
1066+
code: 'NOT_FOUND',
1067+
message: 'Account data not found'
1068+
});
1069+
}
1070+
return {
1071+
isSet: Boolean(result.recoveryEmailHash),
1072+
isVerified: Boolean(result.recoveryEmailVerifiedAt)
1073+
};
1074+
})
9721075
});
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
export interface RecoveryEmailProps {
2+
to: string;
3+
username: string;
4+
recoveryEmail: string;
5+
confirmationUrl: string;
6+
expiryDate: string;
7+
verificationCode: string;
8+
}
9+
10+
export const recoveryEmailTemplatePlainText = ({
11+
to,
12+
username,
13+
recoveryEmail,
14+
confirmationUrl,
15+
expiryDate,
16+
verificationCode
17+
}: RecoveryEmailProps) =>
18+
`Hello ${username},
19+
20+
You have requested to link ${recoveryEmail} as your recovery email for your Uninbox account.
21+
22+
Confirm your recovery email at ${confirmationUrl}
23+
24+
If the button is not working, copy paste this code in your browser:
25+
${verificationCode}
26+
27+
This confirmation link will expire on ${expiryDate}. Make sure to confirm before the expiry date.
28+
29+
--------------------------------------------------------------------------------
30+
31+
This email was intended for ${to}. If you did not request to link a recovery email,
32+
you can ignore this email. If you are concerned about your account's safety,
33+
please contact us immediately.`;
34+
35+
export const recoveryEmailTemplate = ({
36+
to,
37+
username,
38+
recoveryEmail,
39+
confirmationUrl,
40+
expiryDate,
41+
verificationCode
42+
}: RecoveryEmailProps) =>
43+
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
44+
<html dir="ltr" lang="en">
45+
<head>
46+
<meta content="text/html; charset=UTF-8" http-equiv="Content-Type" />
47+
</head>
48+
<div style="display:none;overflow:hidden;line-height:1px;opacity:0;max-height:0;max-width:0">Confirm your recovery email for Uninbox<div> ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏ ‌​‍‎‏</div>
49+
</div>
50+
<body style="margin-left:auto;margin-right:auto;margin-top:auto;margin-bottom:auto;background-color:rgb(255,255,255);padding-left:0.5rem;padding-right:0.5rem;font-family:ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, Roboto, &quot;Helvetica Neue&quot;, Arial, &quot;Noto Sans&quot;, sans-serif, &quot;Apple Color Emoji&quot;, &quot;Segoe UI Emoji&quot;, &quot;Segoe UI Symbol&quot;, &quot;Noto Color Emoji&quot;">
51+
<table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:465px;margin-left:auto;margin-right:auto;margin-top:40px;margin-bottom:40px;border-radius:0.25rem;border-width:1px;border-style:solid;border-color:rgb(234,234,234);padding:20px">
52+
<tbody>
53+
<tr style="width:100%">
54+
<td>
55+
<table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-top:32px">
56+
<tbody>
57+
<tr>
58+
<td><img alt="Uninbox" height="100" src="https://avatars.githubusercontent.com/u/135225712?s=400&amp;u=72ad315d63b0326e5bb34377c3f59389373edc9a&amp;v=4" style="display:block;outline:none;border:none;text-decoration:none;margin-left:auto;margin-right:auto;margin-top:0px;margin-bottom:0px;border-radius:0.375rem" width="100" /></td>
59+
</tr>
60+
</tbody>
61+
</table>
62+
<p style="font-size:14px;line-height:12px;margin:16px 0;margin-top:2.5rem;color:rgb(0,0,0)">Hello <strong>${username}</strong>,</p>
63+
<p style="font-size:14px;line-height:12px;margin:16px 0;color:rgb(0,0,0)">You have requested to link <strong>${recoveryEmail}</strong> as your recovery email for your Uninbox account.</p>
64+
<table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:32px;margin-top:32px;text-align:center">
65+
<tbody>
66+
<tr>
67+
<td><a href="${confirmationUrl}" style="line-height:100%;text-decoration:none;display:inline-block;max-width:100%;border-radius:0.25rem;background-color:rgb(0,0,0);padding-left:1.25rem;padding-right:1.25rem;padding-top:0.75rem;padding-bottom:0.75rem;text-align:center;font-size:12px;font-weight:600;color:rgb(255,255,255);text-decoration-line:none;padding:12px 20px 12px 20px" target="_blank"><span><!--[if mso]><i style="letter-spacing: 20px;mso-font-width:-100%;mso-text-raise:18" hidden>&nbsp;</i><![endif]--></span><span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px">Confirm Recovery Email</span><span><!--[if mso]><i style="letter-spacing: 20px;mso-font-width:-100%" hidden>&nbsp;</i><![endif]--></span></a></td>
68+
</tr>
69+
</tbody>
70+
</table>
71+
<p style="font-size:14px;line-height:24px;margin:16px 0;color:rgb(0,0,0)">If the button is not working, copy paste this code in your browser:</p>
72+
<div style="background-color:#f0f0f0;border-radius:4px;padding:10px;margin-bottom:20px;">
73+
<code style="word-break:break-all;display:block;font-family:monospace;font-size:12px;color:#333;">${verificationCode}</code>
74+
</div>
75+
<table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation">
76+
<tbody>
77+
<tr>
78+
<td>
79+
<p style="font-size:14px;line-height:20px;margin:16px 0;color:rgb(0,0,0)">This confirmation link will expire on <strong>${expiryDate}</strong>. Make sure to confirm before the expiry date.</p>
80+
</td>
81+
</tr>
82+
</tbody>
83+
</table>
84+
<hr style="width:100%;border:none;border-top:1px solid #eaeaea;margin-left:0px;margin-right:0px;margin-top:26px;margin-bottom:26px;border-width:1px;border-style:solid;border-color:rgb(234,234,234)" />
85+
<p style="font-size:12px;line-height:24px;margin:16px 0;color:rgb(102,102,102)">This email was intended for<!-- --> <span style="color:rgb(0,0,0)">${to}</span>. If you did not request to link a recovery email, you can ignore this email. If you are concerned about your account&#x27;s safety, please contact us immediately.</p>
86+
</td>
87+
</tr>
88+
</tbody>
89+
</table>
90+
</body>
91+
</html>
92+
`;

apps/platform/utils/mail/transactional.ts

Lines changed: 111 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import {
33
inviteTemplatePlainText,
44
type InviteEmailProps
55
} from './inviteTemplate';
6+
import {
7+
recoveryEmailTemplate,
8+
recoveryEmailTemplatePlainText,
9+
type RecoveryEmailProps
10+
} from './setRecoveryEmailTemplate';
611
import { env } from '~platform/env';
712

813
type PostalResponse =
@@ -29,52 +34,124 @@ type PostalResponse =
2934
};
3035
};
3136

32-
export async function sendInviteEmail({
33-
invitingOrgName,
34-
to,
35-
invitedName,
36-
expiryDate,
37-
inviteUrl
38-
}: InviteEmailProps) {
37+
type EmailData = {
38+
to: string[];
39+
cc: string[];
40+
from: string;
41+
sender: string;
42+
subject: string;
43+
plain_body: string;
44+
html_body: string;
45+
attachments: unknown[];
46+
headers: Record<string, string>;
47+
};
48+
49+
async function sendEmail(emailData: EmailData): Promise<PostalResponse> {
50+
if (env.MAILBRIDGE_LOCAL_MODE) {
51+
console.info('Mailbridge local mode enabled, sending email to console');
52+
console.info(JSON.stringify(emailData, null, 2));
53+
return {
54+
status: 'success',
55+
time: Date.now(),
56+
flags: {},
57+
data: {
58+
message_id: 'console',
59+
messages: {}
60+
}
61+
};
62+
}
63+
3964
const config = env.MAILBRIDGE_TRANSACTIONAL_CREDENTIALS;
40-
const sendMailPostalResponse = (await fetch(
65+
const sendMailPostalResponse = await fetch(
4166
`${config.apiUrl}/api/v1/send/message`,
4267
{
4368
method: 'POST',
4469
headers: {
4570
'X-Server-API-Key': `${config.apiKey}`,
4671
'Content-Type': 'application/json'
4772
},
48-
body: JSON.stringify({
49-
to: [to],
50-
cc: [],
51-
from: `${config.sendAsName} <${config.sendAsEmail}>`,
52-
sender: config.sendAsEmail,
53-
subject: `You have been invited to join ${invitingOrgName} on Uninbox`,
54-
plain_body: inviteTemplatePlainText({
55-
expiryDate,
56-
invitedName,
57-
inviteUrl,
58-
invitingOrgName: invitingOrgName,
59-
to
60-
}),
61-
html_body: inviteTemplate({
62-
expiryDate,
63-
invitedName,
64-
inviteUrl,
65-
invitingOrgName: invitingOrgName,
66-
to
67-
}),
68-
attachments: [],
69-
headers: {}
70-
})
73+
body: JSON.stringify(emailData)
7174
}
7275
)
7376
.then((res) => res.json())
7477
.catch((e) => {
75-
console.error('🚨 error sending invite email', e);
76-
})) as PostalResponse;
78+
console.error('🚨 error sending email', e);
79+
return {
80+
status: 'parameter-error',
81+
time: Date.now(),
82+
flags: {},
83+
data: { message_id: 'console', messages: {} }
84+
};
85+
});
7786

78-
if (!sendMailPostalResponse) return;
7987
return sendMailPostalResponse;
8088
}
89+
90+
export async function sendInviteEmail({
91+
invitingOrgName,
92+
to,
93+
invitedName,
94+
expiryDate,
95+
inviteUrl
96+
}: InviteEmailProps) {
97+
const config = env.MAILBRIDGE_TRANSACTIONAL_CREDENTIALS;
98+
return await sendEmail({
99+
to: [to],
100+
cc: [],
101+
from: `${config.sendAsName} <${config.sendAsEmail}>`,
102+
sender: config.sendAsEmail,
103+
subject: `You have been invited to join ${invitingOrgName} on Uninbox`,
104+
plain_body: inviteTemplatePlainText({
105+
to,
106+
expiryDate,
107+
invitedName,
108+
inviteUrl,
109+
invitingOrgName
110+
}),
111+
html_body: inviteTemplate({
112+
to,
113+
expiryDate,
114+
invitedName,
115+
inviteUrl,
116+
invitingOrgName
117+
}),
118+
attachments: [],
119+
headers: {}
120+
});
121+
}
122+
123+
export async function sendRecoveryEmailConfirmation({
124+
to,
125+
username,
126+
recoveryEmail,
127+
confirmationUrl,
128+
expiryDate,
129+
verificationCode
130+
}: RecoveryEmailProps) {
131+
const config = env.MAILBRIDGE_TRANSACTIONAL_CREDENTIALS;
132+
return await sendEmail({
133+
to: [to],
134+
cc: [],
135+
from: `${config.sendAsName} <${config.sendAsEmail}>`,
136+
sender: config.sendAsEmail,
137+
subject: `Confirm your recovery email for Uninbox`,
138+
plain_body: recoveryEmailTemplatePlainText({
139+
to,
140+
username,
141+
recoveryEmail,
142+
confirmationUrl,
143+
expiryDate,
144+
verificationCode
145+
}),
146+
html_body: recoveryEmailTemplate({
147+
to,
148+
username,
149+
recoveryEmail,
150+
confirmationUrl,
151+
expiryDate,
152+
verificationCode
153+
}),
154+
attachments: [],
155+
headers: {}
156+
});
157+
}

0 commit comments

Comments
 (0)