@tracked/emails 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.
- package/LICENSE +21 -0
- package/README.md +211 -0
- package/dist/emails/bodyweight-goal-reached.d.ts +14 -0
- package/dist/emails/bodyweight-goal-reached.d.ts.map +1 -0
- package/dist/emails/bodyweight-goal-reached.js +177 -0
- package/dist/emails/bodyweight-goal-reached.js.map +1 -0
- package/dist/emails/client-accepted-invitation.d.ts +10 -0
- package/dist/emails/client-accepted-invitation.d.ts.map +1 -0
- package/dist/emails/client-accepted-invitation.js +99 -0
- package/dist/emails/client-accepted-invitation.js.map +1 -0
- package/dist/emails/coach-invite.d.ts +10 -0
- package/dist/emails/coach-invite.d.ts.map +1 -0
- package/dist/emails/coach-invite.js +126 -0
- package/dist/emails/coach-invite.js.map +1 -0
- package/dist/emails/coach-removed-client.d.ts +8 -0
- package/dist/emails/coach-removed-client.d.ts.map +1 -0
- package/dist/emails/coach-removed-client.js +80 -0
- package/dist/emails/coach-removed-client.js.map +1 -0
- package/dist/emails/direct-message.d.ts +11 -0
- package/dist/emails/direct-message.d.ts.map +1 -0
- package/dist/emails/direct-message.js +103 -0
- package/dist/emails/direct-message.js.map +1 -0
- package/dist/emails/feature-discovery.d.ts +11 -0
- package/dist/emails/feature-discovery.d.ts.map +1 -0
- package/dist/emails/feature-discovery.js +121 -0
- package/dist/emails/feature-discovery.js.map +1 -0
- package/dist/emails/first-workout-assigned.d.ts +10 -0
- package/dist/emails/first-workout-assigned.d.ts.map +1 -0
- package/dist/emails/first-workout-assigned.js +98 -0
- package/dist/emails/first-workout-assigned.js.map +1 -0
- package/dist/emails/first-workout-completed.d.ts +11 -0
- package/dist/emails/first-workout-completed.d.ts.map +1 -0
- package/dist/emails/first-workout-completed.js +129 -0
- package/dist/emails/first-workout-completed.js.map +1 -0
- package/dist/emails/index.d.ts +7 -0
- package/dist/emails/index.d.ts.map +1 -0
- package/dist/emails/index.js +7 -0
- package/dist/emails/index.js.map +1 -0
- package/dist/emails/new-follower.d.ts +11 -0
- package/dist/emails/new-follower.d.ts.map +1 -0
- package/dist/emails/new-follower.js +98 -0
- package/dist/emails/new-follower.js.map +1 -0
- package/dist/emails/subscription-canceled.d.ts +10 -0
- package/dist/emails/subscription-canceled.d.ts.map +1 -0
- package/dist/emails/subscription-canceled.js +131 -0
- package/dist/emails/subscription-canceled.js.map +1 -0
- package/dist/emails/support-email.d.ts +8 -0
- package/dist/emails/support-email.d.ts.map +1 -0
- package/dist/emails/support-email.js +40 -0
- package/dist/emails/support-email.js.map +1 -0
- package/dist/emails/team-invite.d.ts +11 -0
- package/dist/emails/team-invite.d.ts.map +1 -0
- package/dist/emails/team-invite.js +100 -0
- package/dist/emails/team-invite.js.map +1 -0
- package/dist/emails/team-member-removed-email.d.ts +8 -0
- package/dist/emails/team-member-removed-email.d.ts.map +1 -0
- package/dist/emails/team-member-removed-email.js +97 -0
- package/dist/emails/team-member-removed-email.js.map +1 -0
- package/dist/emails/tracked-magic-link-activate.d.ts +7 -0
- package/dist/emails/tracked-magic-link-activate.d.ts.map +1 -0
- package/dist/emails/tracked-magic-link-activate.js +93 -0
- package/dist/emails/tracked-magic-link-activate.js.map +1 -0
- package/dist/emails/tracked-magic-link.d.ts +7 -0
- package/dist/emails/tracked-magic-link.d.ts.map +1 -0
- package/dist/emails/tracked-magic-link.js +101 -0
- package/dist/emails/tracked-magic-link.js.map +1 -0
- package/dist/emails/week-one-checkin.d.ts +10 -0
- package/dist/emails/week-one-checkin.d.ts.map +1 -0
- package/dist/emails/week-one-checkin.js +141 -0
- package/dist/emails/week-one-checkin.js.map +1 -0
- package/dist/emails/welcome.d.ts +8 -0
- package/dist/emails/welcome.d.ts.map +1 -0
- package/dist/emails/welcome.js +146 -0
- package/dist/emails/welcome.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/utils/email-validation.d.ts +48 -0
- package/dist/utils/email-validation.d.ts.map +1 -0
- package/dist/utils/email-validation.js +72 -0
- package/dist/utils/email-validation.js.map +1 -0
- package/dist/utils/index.d.ts +8 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +8 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/username-validation.d.ts +54 -0
- package/dist/utils/username-validation.d.ts.map +1 -0
- package/dist/utils/username-validation.js +76 -0
- package/dist/utils/username-validation.js.map +1 -0
- package/package.json +78 -0
- package/src/emails/bodyweight-goal-reached.tsx +396 -0
- package/src/emails/client-accepted-invitation.tsx +258 -0
- package/src/emails/coach-invite.tsx +270 -0
- package/src/emails/coach-removed-client.tsx +212 -0
- package/src/emails/direct-message.tsx +249 -0
- package/src/emails/feature-discovery.tsx +289 -0
- package/src/emails/first-workout-assigned.tsx +255 -0
- package/src/emails/first-workout-completed.tsx +312 -0
- package/src/emails/index.tsx +6 -0
- package/src/emails/new-follower.tsx +260 -0
- package/src/emails/subscription-canceled.tsx +311 -0
- package/src/emails/support-email.tsx +80 -0
- package/src/emails/team-invite.tsx +262 -0
- package/src/emails/team-member-removed-email.tsx +240 -0
- package/src/emails/tracked-magic-link-activate.tsx +252 -0
- package/src/emails/tracked-magic-link.tsx +264 -0
- package/src/emails/week-one-checkin.tsx +353 -0
- package/src/emails/welcome.tsx +341 -0
- package/src/index.ts +57 -0
- package/src/utils/email-validation.test.ts +78 -0
- package/src/utils/email-validation.ts +80 -0
- package/src/utils/index.ts +13 -0
- package/src/utils/username-validation.test.ts +118 -0
- package/src/utils/username-validation.ts +89 -0
- package/static/tracked-logo.png +0 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import {
|
|
3
|
+
Body,
|
|
4
|
+
Button,
|
|
5
|
+
Column,
|
|
6
|
+
Container,
|
|
7
|
+
Head,
|
|
8
|
+
Hr,
|
|
9
|
+
Html,
|
|
10
|
+
Img,
|
|
11
|
+
Link,
|
|
12
|
+
Preview,
|
|
13
|
+
Row,
|
|
14
|
+
Section,
|
|
15
|
+
Text,
|
|
16
|
+
} from "@react-email/components";
|
|
17
|
+
|
|
18
|
+
interface WelcomeEmailProps {
|
|
19
|
+
userName: string;
|
|
20
|
+
appUrl: string;
|
|
21
|
+
websiteUrl: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const baseUrl = "https://tracked.gg/android-chrome-192x192.png";
|
|
25
|
+
|
|
26
|
+
export const WelcomeEmail = ({
|
|
27
|
+
userName,
|
|
28
|
+
appUrl,
|
|
29
|
+
websiteUrl = "https://tracked.gg",
|
|
30
|
+
}: WelcomeEmailProps) => {
|
|
31
|
+
return (
|
|
32
|
+
<Html>
|
|
33
|
+
<Head>
|
|
34
|
+
<meta name="color-scheme" content="light only" />
|
|
35
|
+
<meta name="supported-color-schemes" content="light only" />
|
|
36
|
+
</Head>
|
|
37
|
+
<Preview>Welcome to Tracked - Let's start your fitness journey!</Preview>
|
|
38
|
+
<Body style={main}>
|
|
39
|
+
<Container style={container}>
|
|
40
|
+
<Section style={box}>
|
|
41
|
+
<Row style={{ marginBottom: "8px" }}>
|
|
42
|
+
<Column style={{ width: "auto", verticalAlign: "middle" }}>
|
|
43
|
+
<Img src={`${baseUrl}`} width="28" height="28" alt="Tracked" />
|
|
44
|
+
</Column>
|
|
45
|
+
<Column
|
|
46
|
+
style={{
|
|
47
|
+
width: "auto",
|
|
48
|
+
verticalAlign: "middle",
|
|
49
|
+
paddingLeft: "4px",
|
|
50
|
+
}}
|
|
51
|
+
>
|
|
52
|
+
<Text
|
|
53
|
+
style={{
|
|
54
|
+
fontSize: "28px",
|
|
55
|
+
fontWeight: "900",
|
|
56
|
+
fontFamily:
|
|
57
|
+
"Raleway, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
|
58
|
+
color: "#020617",
|
|
59
|
+
margin: "0",
|
|
60
|
+
lineHeight: "32px",
|
|
61
|
+
letterSpacing: "0.5px",
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
TRACKED
|
|
65
|
+
</Text>
|
|
66
|
+
</Column>
|
|
67
|
+
</Row>
|
|
68
|
+
<Hr style={hr} />
|
|
69
|
+
|
|
70
|
+
<Text style={heading}>Welcome to Tracked!</Text>
|
|
71
|
+
<Text style={paragraph}>
|
|
72
|
+
Hi {userName}, we're excited to have you join our community.
|
|
73
|
+
Tracked is here to help you achieve your training goals with
|
|
74
|
+
powerful tracking tools.
|
|
75
|
+
</Text>
|
|
76
|
+
|
|
77
|
+
<Section style={featureBox}>
|
|
78
|
+
<Text style={featureHeading}>What you can do with Tracked:</Text>
|
|
79
|
+
<ul style={featureList}>
|
|
80
|
+
<li style={featureItem}>
|
|
81
|
+
<strong>Track Workouts:</strong> Log your exercises, sets, and
|
|
82
|
+
reps with the best tracking tools on the market.
|
|
83
|
+
</li>
|
|
84
|
+
<li style={featureItem}>
|
|
85
|
+
<strong>Monitor Progress:</strong> Visualize your strength
|
|
86
|
+
gains and workout history
|
|
87
|
+
</li>
|
|
88
|
+
<li style={featureItem}>
|
|
89
|
+
<strong>Stay Accountable:</strong> Share your journey with the
|
|
90
|
+
community
|
|
91
|
+
</li>
|
|
92
|
+
</ul>
|
|
93
|
+
</Section>
|
|
94
|
+
|
|
95
|
+
<div
|
|
96
|
+
style={{
|
|
97
|
+
marginTop: "24px",
|
|
98
|
+
marginBottom: "24px",
|
|
99
|
+
textAlign: "left" as const,
|
|
100
|
+
}}
|
|
101
|
+
>
|
|
102
|
+
<a
|
|
103
|
+
href={appUrl}
|
|
104
|
+
style={{
|
|
105
|
+
backgroundColor: "#0f172a",
|
|
106
|
+
borderRadius: "8px",
|
|
107
|
+
fontSize: "16px",
|
|
108
|
+
fontWeight: "bold",
|
|
109
|
+
textDecoration: "none",
|
|
110
|
+
padding: "12px 32px",
|
|
111
|
+
display: "inline-block",
|
|
112
|
+
}}
|
|
113
|
+
>
|
|
114
|
+
<span style={{ color: "#ffffff", textDecoration: "none" }}>
|
|
115
|
+
Open the App
|
|
116
|
+
</span>
|
|
117
|
+
</a>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<Section style={tipBox}>
|
|
121
|
+
<Text style={tipHeading}>Tip:</Text>
|
|
122
|
+
<Text style={tipText}>
|
|
123
|
+
Consistency is key to seeing results, and we make it easy to
|
|
124
|
+
track your progress every step of the way. Try your best to STAY
|
|
125
|
+
IN THE GREEN!
|
|
126
|
+
</Text>
|
|
127
|
+
</Section>
|
|
128
|
+
|
|
129
|
+
<Row style={row}>
|
|
130
|
+
<Column style={column}>
|
|
131
|
+
<Button
|
|
132
|
+
style={appButton}
|
|
133
|
+
href="https://apps.apple.com/app/tracked-training/id6450913418"
|
|
134
|
+
>
|
|
135
|
+
<Img
|
|
136
|
+
src="https://cdn.trckd.ca/assets/app-store-black.png"
|
|
137
|
+
alt="Download on the App Store"
|
|
138
|
+
style={img}
|
|
139
|
+
/>
|
|
140
|
+
</Button>
|
|
141
|
+
</Column>
|
|
142
|
+
<Column style={column}>
|
|
143
|
+
<Button
|
|
144
|
+
style={appButton}
|
|
145
|
+
href="https://play.google.com/store/apps/details?id=com.tracked.mobile"
|
|
146
|
+
>
|
|
147
|
+
<Img
|
|
148
|
+
src="https://play.google.com/intl/en_us/badges/static/images/badges/en_badge_web_generic.png"
|
|
149
|
+
alt="Google Play"
|
|
150
|
+
style={img}
|
|
151
|
+
/>
|
|
152
|
+
</Button>
|
|
153
|
+
</Column>
|
|
154
|
+
</Row>
|
|
155
|
+
|
|
156
|
+
<div
|
|
157
|
+
style={{
|
|
158
|
+
textAlign: "left" as const,
|
|
159
|
+
margin: "24px 0",
|
|
160
|
+
}}
|
|
161
|
+
>
|
|
162
|
+
<a
|
|
163
|
+
href="https://www.discord.gg/trackedgg"
|
|
164
|
+
style={{
|
|
165
|
+
backgroundColor: "#5865F2",
|
|
166
|
+
borderRadius: "8px",
|
|
167
|
+
fontSize: "16px",
|
|
168
|
+
fontWeight: "bold",
|
|
169
|
+
textDecoration: "none",
|
|
170
|
+
padding: "12px 32px",
|
|
171
|
+
display: "inline-block",
|
|
172
|
+
}}
|
|
173
|
+
>
|
|
174
|
+
<span style={{ color: "#ffffff", textDecoration: "none" }}>
|
|
175
|
+
Join our Discord Community
|
|
176
|
+
</span>
|
|
177
|
+
</a>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<Hr style={hr} />
|
|
181
|
+
<Text style={footer}>
|
|
182
|
+
Copyright © Tracked Training Platform Inc. <br /> 9101 Horne
|
|
183
|
+
Street, Vancouver, BC
|
|
184
|
+
</Text>
|
|
185
|
+
|
|
186
|
+
<Container>
|
|
187
|
+
<Link
|
|
188
|
+
href={`${websiteUrl}/terms`}
|
|
189
|
+
style={{ ...footer, paddingRight: 10 }}
|
|
190
|
+
>
|
|
191
|
+
Terms
|
|
192
|
+
</Link>
|
|
193
|
+
<Link style={{ ...footer, paddingRight: 10 }}> | </Link>
|
|
194
|
+
<Link
|
|
195
|
+
href={`${websiteUrl}/privacy`}
|
|
196
|
+
style={{ ...footer, paddingRight: 10 }}
|
|
197
|
+
>
|
|
198
|
+
Privacy
|
|
199
|
+
</Link>
|
|
200
|
+
<Link style={{ ...footer, paddingRight: 10 }}> | </Link>
|
|
201
|
+
<Link
|
|
202
|
+
href={`${websiteUrl}/support`}
|
|
203
|
+
style={{ ...footer, paddingRight: 10 }}
|
|
204
|
+
>
|
|
205
|
+
Support
|
|
206
|
+
</Link>
|
|
207
|
+
</Container>
|
|
208
|
+
|
|
209
|
+
<Text style={footer}>
|
|
210
|
+
This is a service notification by the Tracked Training Platform.
|
|
211
|
+
</Text>
|
|
212
|
+
</Section>
|
|
213
|
+
</Container>
|
|
214
|
+
</Body>
|
|
215
|
+
</Html>
|
|
216
|
+
);
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const main = {
|
|
220
|
+
backgroundColor: "#020617", // slate-950
|
|
221
|
+
fontFamily:
|
|
222
|
+
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const container = {
|
|
226
|
+
backgroundColor: "#020617", // slate-950
|
|
227
|
+
margin: "0 auto",
|
|
228
|
+
padding: "20px 0 48px",
|
|
229
|
+
marginBottom: "64px",
|
|
230
|
+
borderRadius: "8px",
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const box = {
|
|
234
|
+
padding: "0 24px",
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const hr = {
|
|
238
|
+
borderColor: "#4ade80", // green-400
|
|
239
|
+
margin: "24px 0",
|
|
240
|
+
borderWidth: "1px",
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const paragraph = {
|
|
244
|
+
color: "#ffffff", // white
|
|
245
|
+
fontSize: "16px",
|
|
246
|
+
lineHeight: "24px",
|
|
247
|
+
textAlign: "left" as const,
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const heading = {
|
|
251
|
+
color: "#ffffff", // white
|
|
252
|
+
fontSize: "24px",
|
|
253
|
+
lineHeight: "32px",
|
|
254
|
+
fontWeight: "bold",
|
|
255
|
+
marginBottom: "16px",
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const featureBox = {
|
|
259
|
+
backgroundColor: "#1e293b",
|
|
260
|
+
padding: "16px 24px",
|
|
261
|
+
borderRadius: "8px",
|
|
262
|
+
margin: "24px 0",
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const featureHeading = {
|
|
266
|
+
color: "#ffffff",
|
|
267
|
+
fontSize: "16px",
|
|
268
|
+
fontWeight: "bold",
|
|
269
|
+
marginBottom: "12px",
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const featureList = {
|
|
273
|
+
margin: "0",
|
|
274
|
+
paddingLeft: "20px",
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const featureItem = {
|
|
278
|
+
color: "#e2e8f0",
|
|
279
|
+
fontSize: "14px",
|
|
280
|
+
lineHeight: "24px",
|
|
281
|
+
marginBottom: "8px",
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const tipBox = {
|
|
285
|
+
backgroundColor: "#1e293b",
|
|
286
|
+
padding: "16px 24px",
|
|
287
|
+
borderRadius: "8px",
|
|
288
|
+
margin: "24px 0",
|
|
289
|
+
borderLeft: "4px solid #4ade80",
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const tipHeading = {
|
|
293
|
+
color: "#4ade80",
|
|
294
|
+
fontSize: "14px",
|
|
295
|
+
fontWeight: "bold",
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const tipText = {
|
|
299
|
+
color: "#e2e8f0",
|
|
300
|
+
fontSize: "14px",
|
|
301
|
+
lineHeight: "20px",
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const row = {
|
|
305
|
+
display: "flex",
|
|
306
|
+
flexDirection: "row" as const,
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const column = {
|
|
310
|
+
flex: "0 0 48%",
|
|
311
|
+
"@media (maxWidth: 600px)": {
|
|
312
|
+
flex: "0 0 100%",
|
|
313
|
+
marginBottom: "10px",
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
const img = {
|
|
318
|
+
maxWidth: "100%",
|
|
319
|
+
height: "auto",
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const appButton = {
|
|
323
|
+
backgroundColor: "transparent",
|
|
324
|
+
borderRadius: "8px",
|
|
325
|
+
color: "#ffffff", // white
|
|
326
|
+
fontSize: "16px",
|
|
327
|
+
fontWeight: "bold",
|
|
328
|
+
textDecoration: "none",
|
|
329
|
+
textAlign: "center" as const,
|
|
330
|
+
display: "block",
|
|
331
|
+
width: "100%",
|
|
332
|
+
maxWidth: "150px",
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const footer = {
|
|
336
|
+
color: "#94a3b8", // slate-400 for subtle footer text
|
|
337
|
+
fontSize: "12px",
|
|
338
|
+
lineHeight: "16px",
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
export default WelcomeEmail;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { BodyweightGoalReachedEmail } from "./emails/bodyweight-goal-reached.js";
|
|
2
|
+
import { ClientAcceptedInvitationEmail } from "./emails/client-accepted-invitation.js";
|
|
3
|
+
import { CoachInviteEmail } from "./emails/coach-invite.js";
|
|
4
|
+
import { CoachRemovedClientEmail } from "./emails/coach-removed-client.js";
|
|
5
|
+
import { DirectMessageEmail } from "./emails/direct-message.js";
|
|
6
|
+
import { FeatureDiscoveryEmail } from "./emails/feature-discovery.js";
|
|
7
|
+
import { FirstWorkoutAssignedEmail } from "./emails/first-workout-assigned.js";
|
|
8
|
+
import { FirstWorkoutCompletedEmail } from "./emails/first-workout-completed.js";
|
|
9
|
+
import { NewFollowerEmail } from "./emails/new-follower.js";
|
|
10
|
+
import { SubscriptionCanceledEmail } from "./emails/subscription-canceled.js";
|
|
11
|
+
import { SupportEmail } from "./emails/support-email.js";
|
|
12
|
+
import { TeamInviteEmail } from "./emails/team-invite.js";
|
|
13
|
+
import { TeamMemberRemovedEmail } from "./emails/team-member-removed-email.js";
|
|
14
|
+
import { TrackedMagicLink } from "./emails/tracked-magic-link.js";
|
|
15
|
+
import { TrackedMagicLinkActivate } from "./emails/tracked-magic-link-activate.js";
|
|
16
|
+
import { WeekOneCheckinEmail } from "./emails/week-one-checkin.js";
|
|
17
|
+
import { WelcomeEmail } from "./emails/welcome.js";
|
|
18
|
+
|
|
19
|
+
// Import validation utilities
|
|
20
|
+
import {
|
|
21
|
+
isPrivateRelayEmail,
|
|
22
|
+
isValidEmailFormat,
|
|
23
|
+
shouldSendEmailTo,
|
|
24
|
+
} from "./utils/email-validation.js";
|
|
25
|
+
import {
|
|
26
|
+
isAnonymousUsername,
|
|
27
|
+
getSafeDisplayName,
|
|
28
|
+
} from "./utils/username-validation.js";
|
|
29
|
+
|
|
30
|
+
export {
|
|
31
|
+
BodyweightGoalReachedEmail,
|
|
32
|
+
ClientAcceptedInvitationEmail,
|
|
33
|
+
CoachInviteEmail,
|
|
34
|
+
CoachRemovedClientEmail,
|
|
35
|
+
DirectMessageEmail,
|
|
36
|
+
FeatureDiscoveryEmail,
|
|
37
|
+
FirstWorkoutAssignedEmail,
|
|
38
|
+
FirstWorkoutCompletedEmail,
|
|
39
|
+
NewFollowerEmail,
|
|
40
|
+
SubscriptionCanceledEmail,
|
|
41
|
+
SupportEmail,
|
|
42
|
+
TeamInviteEmail,
|
|
43
|
+
TeamMemberRemovedEmail,
|
|
44
|
+
TrackedMagicLink,
|
|
45
|
+
TrackedMagicLinkActivate,
|
|
46
|
+
WeekOneCheckinEmail,
|
|
47
|
+
WelcomeEmail,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Export validation utilities
|
|
51
|
+
export {
|
|
52
|
+
isPrivateRelayEmail,
|
|
53
|
+
isValidEmailFormat,
|
|
54
|
+
shouldSendEmailTo,
|
|
55
|
+
isAnonymousUsername,
|
|
56
|
+
getSafeDisplayName,
|
|
57
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
isPrivateRelayEmail,
|
|
4
|
+
isValidEmailFormat,
|
|
5
|
+
shouldSendEmailTo,
|
|
6
|
+
} from './email-validation';
|
|
7
|
+
|
|
8
|
+
describe('email-validation', () => {
|
|
9
|
+
describe('isPrivateRelayEmail', () => {
|
|
10
|
+
it('should detect Apple private relay emails', () => {
|
|
11
|
+
expect(isPrivateRelayEmail('6g65mj8rd5@privaterelay.appleid.com')).toBe(
|
|
12
|
+
true
|
|
13
|
+
);
|
|
14
|
+
expect(
|
|
15
|
+
isPrivateRelayEmail('randomstring@privaterelay.appleid.com')
|
|
16
|
+
).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should allow normal emails', () => {
|
|
20
|
+
expect(isPrivateRelayEmail('user@gmail.com')).toBe(false);
|
|
21
|
+
expect(isPrivateRelayEmail('john@example.com')).toBe(false);
|
|
22
|
+
expect(isPrivateRelayEmail('test@company.co.uk')).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should be case insensitive', () => {
|
|
26
|
+
expect(isPrivateRelayEmail('TEST@PRIVATERELAY.APPLEID.COM')).toBe(true);
|
|
27
|
+
expect(isPrivateRelayEmail('Test@PrivateRelay.AppleID.com')).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('isValidEmailFormat', () => {
|
|
32
|
+
it('should validate correct email formats', () => {
|
|
33
|
+
expect(isValidEmailFormat('user@example.com')).toBe(true);
|
|
34
|
+
expect(isValidEmailFormat('test.user@company.co.uk')).toBe(true);
|
|
35
|
+
expect(isValidEmailFormat('name+tag@domain.com')).toBe(true);
|
|
36
|
+
expect(isValidEmailFormat('123@numbers.net')).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should reject invalid email formats', () => {
|
|
40
|
+
expect(isValidEmailFormat('notanemail')).toBe(false);
|
|
41
|
+
expect(isValidEmailFormat('missing@domain')).toBe(false);
|
|
42
|
+
expect(isValidEmailFormat('@nodomain.com')).toBe(false);
|
|
43
|
+
expect(isValidEmailFormat('noat.com')).toBe(false);
|
|
44
|
+
expect(isValidEmailFormat('spaces in@email.com')).toBe(false);
|
|
45
|
+
expect(isValidEmailFormat('')).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('shouldSendEmailTo', () => {
|
|
50
|
+
it('should allow valid emails', () => {
|
|
51
|
+
expect(shouldSendEmailTo('user@example.com')).toBe(true);
|
|
52
|
+
expect(shouldSendEmailTo('valid.email@company.co.uk')).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should reject null/undefined emails', () => {
|
|
56
|
+
expect(shouldSendEmailTo(null)).toBe(false);
|
|
57
|
+
expect(shouldSendEmailTo(undefined)).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should reject private relay emails', () => {
|
|
61
|
+
expect(
|
|
62
|
+
shouldSendEmailTo('randomstring@privaterelay.appleid.com')
|
|
63
|
+
).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should reject invalid email formats', () => {
|
|
67
|
+
expect(shouldSendEmailTo('notanemail')).toBe(false);
|
|
68
|
+
expect(shouldSendEmailTo('missing@domain')).toBe(false);
|
|
69
|
+
expect(shouldSendEmailTo('')).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should handle edge cases', () => {
|
|
73
|
+
expect(shouldSendEmailTo(' ')).toBe(false);
|
|
74
|
+
expect(shouldSendEmailTo('valid@email.com ')).toBe(false); // trailing space fails regex
|
|
75
|
+
expect(shouldSendEmailTo('valid@email.com')).toBe(true); // properly formatted email works
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email Validation Utilities
|
|
3
|
+
*
|
|
4
|
+
* Handles validation for email addresses to determine if we should send emails.
|
|
5
|
+
* This includes filtering out private relay emails and validating email formats.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Check if email is from a private relay service
|
|
10
|
+
*
|
|
11
|
+
* Apple's "Hide My Email" feature creates forwarding addresses like:
|
|
12
|
+
* - 6g65mj8rd5@privaterelay.appleid.com
|
|
13
|
+
* - user@icloud.com (when using Hide My Email)
|
|
14
|
+
*
|
|
15
|
+
* Note: These emails ARE technically deliverable (Apple forwards them),
|
|
16
|
+
* but we may want to skip them for privacy/marketing reasons.
|
|
17
|
+
*
|
|
18
|
+
* @param email - The email address to check
|
|
19
|
+
* @returns true if the email is from a private relay service
|
|
20
|
+
*/
|
|
21
|
+
export function isPrivateRelayEmail(email: string): boolean {
|
|
22
|
+
const privateRelayPatterns = [
|
|
23
|
+
"@privaterelay.appleid.com",
|
|
24
|
+
// Note: @icloud.com can be real emails, only filter if it's clearly a relay
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
return privateRelayPatterns.some((pattern) =>
|
|
28
|
+
email.toLowerCase().includes(pattern),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Validate basic email format
|
|
34
|
+
*
|
|
35
|
+
* @param email - The email address to validate
|
|
36
|
+
* @returns true if the email has a valid format
|
|
37
|
+
*/
|
|
38
|
+
export function isValidEmailFormat(email: string): boolean {
|
|
39
|
+
// Basic email regex: something@something.something
|
|
40
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
41
|
+
return emailRegex.test(email);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Determine if we should send emails to this address
|
|
46
|
+
*
|
|
47
|
+
* This is the main function to use when deciding whether to send an email.
|
|
48
|
+
* It checks for:
|
|
49
|
+
* - Null/undefined emails
|
|
50
|
+
* - Private relay emails (Apple Hide My Email)
|
|
51
|
+
* - Invalid email formats
|
|
52
|
+
*
|
|
53
|
+
* @param email - The email address to check
|
|
54
|
+
* @returns true if we should send emails to this address
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```typescript
|
|
58
|
+
* if (shouldSendEmailTo(user.email)) {
|
|
59
|
+
* await sendWelcomeEmail(user.email);
|
|
60
|
+
* }
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export function shouldSendEmailTo(email: string | null | undefined): boolean {
|
|
64
|
+
// No email provided
|
|
65
|
+
if (!email) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Skip private relay emails
|
|
70
|
+
if (isPrivateRelayEmail(email)) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Validate email format
|
|
75
|
+
if (!isValidEmailFormat(email)) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email and Username Validation Utilities
|
|
3
|
+
*
|
|
4
|
+
* Export all validation utilities for use across the application.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
isPrivateRelayEmail,
|
|
9
|
+
isValidEmailFormat,
|
|
10
|
+
shouldSendEmailTo,
|
|
11
|
+
} from "./email-validation";
|
|
12
|
+
|
|
13
|
+
export { isAnonymousUsername, getSafeDisplayName } from "./username-validation";
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { isAnonymousUsername, getSafeDisplayName } from './username-validation';
|
|
3
|
+
|
|
4
|
+
describe('username-validation', () => {
|
|
5
|
+
describe('isAnonymousUsername', () => {
|
|
6
|
+
it('should detect UUIDs as anonymous', () => {
|
|
7
|
+
expect(isAnonymousUsername('01944f9e-8e64-7a78-9e1e-3daba7b13e9f')).toBe(
|
|
8
|
+
true
|
|
9
|
+
);
|
|
10
|
+
expect(isAnonymousUsername('550e8400-e29b-41d4-a716-446655440000')).toBe(
|
|
11
|
+
true
|
|
12
|
+
);
|
|
13
|
+
expect(isAnonymousUsername('123e4567-e89b-12d3-a456-426614174000')).toBe(
|
|
14
|
+
true
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should allow real usernames', () => {
|
|
19
|
+
expect(isAnonymousUsername('john_doe')).toBe(false);
|
|
20
|
+
expect(isAnonymousUsername('user123')).toBe(false);
|
|
21
|
+
expect(isAnonymousUsername('Jane-Smith')).toBe(false);
|
|
22
|
+
expect(isAnonymousUsername('athlete2024')).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should treat null/undefined as anonymous', () => {
|
|
26
|
+
expect(isAnonymousUsername(null)).toBe(true);
|
|
27
|
+
expect(isAnonymousUsername(undefined)).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should be case insensitive for UUIDs', () => {
|
|
31
|
+
expect(isAnonymousUsername('01944F9E-8E64-7A78-9E1E-3DABA7B13E9F')).toBe(
|
|
32
|
+
true
|
|
33
|
+
);
|
|
34
|
+
expect(isAnonymousUsername('01944f9E-8e64-7A78-9E1e-3dAbA7b13E9f')).toBe(
|
|
35
|
+
true
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should handle empty strings', () => {
|
|
40
|
+
expect(isAnonymousUsername('')).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('getSafeDisplayName', () => {
|
|
45
|
+
it('should prefer real username over other names', () => {
|
|
46
|
+
expect(
|
|
47
|
+
getSafeDisplayName('john_doe', 'John', 'John Doe Smith')
|
|
48
|
+
).toBe('john_doe');
|
|
49
|
+
expect(getSafeDisplayName('athlete123', 'Jane', 'Jane Smith')).toBe(
|
|
50
|
+
'athlete123'
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should fall back to given name when username is UUID', () => {
|
|
55
|
+
const uuid = '01944f9e-8e64-7a78-9e1e-3daba7b13e9f';
|
|
56
|
+
expect(getSafeDisplayName(uuid, 'John', 'John Doe Smith')).toBe('John');
|
|
57
|
+
expect(getSafeDisplayName(uuid, 'Jane', 'Jane Smith')).toBe('Jane');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should fall back to full name when no given name', () => {
|
|
61
|
+
const uuid = '01944f9e-8e64-7a78-9e1e-3daba7b13e9f';
|
|
62
|
+
expect(getSafeDisplayName(uuid, null, 'John Doe Smith')).toBe(
|
|
63
|
+
'John Doe Smith'
|
|
64
|
+
);
|
|
65
|
+
expect(getSafeDisplayName(uuid, undefined, 'Jane Smith')).toBe(
|
|
66
|
+
'Jane Smith'
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should use default fallback when no names available', () => {
|
|
71
|
+
const uuid = '01944f9e-8e64-7a78-9e1e-3daba7b13e9f';
|
|
72
|
+
expect(getSafeDisplayName(uuid, null, null)).toBe('there');
|
|
73
|
+
expect(getSafeDisplayName(null, null, null)).toBe('there');
|
|
74
|
+
expect(getSafeDisplayName(undefined, undefined, undefined)).toBe('there');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should use custom fallback when provided', () => {
|
|
78
|
+
const uuid = '01944f9e-8e64-7a78-9e1e-3daba7b13e9f';
|
|
79
|
+
expect(getSafeDisplayName(uuid, null, null, 'friend')).toBe('friend');
|
|
80
|
+
expect(getSafeDisplayName(null, null, null, 'athlete')).toBe('athlete');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should handle whitespace-only names', () => {
|
|
84
|
+
const uuid = '01944f9e-8e64-7a78-9e1e-3daba7b13e9f';
|
|
85
|
+
expect(getSafeDisplayName(uuid, ' ', ' ', 'friend')).toBe('friend');
|
|
86
|
+
expect(getSafeDisplayName(uuid, '', '', 'athlete')).toBe('athlete');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should trim whitespace from valid names', () => {
|
|
90
|
+
expect(getSafeDisplayName('john_doe', ' John ', 'John Doe')).toBe(
|
|
91
|
+
'john_doe'
|
|
92
|
+
);
|
|
93
|
+
const uuid = '01944f9e-8e64-7a78-9e1e-3daba7b13e9f';
|
|
94
|
+
expect(getSafeDisplayName(uuid, ' John ', 'John Doe')).toBe(' John '); // Note: function doesn't trim, just checks length
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should handle complex scenarios', () => {
|
|
98
|
+
// User with username
|
|
99
|
+
expect(getSafeDisplayName('athlete_pro', 'Mike', 'Mike Johnson')).toBe(
|
|
100
|
+
'athlete_pro'
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// New user with UUID, given name
|
|
104
|
+
const uuid = '01944f9e-8e64-7a78-9e1e-3daba7b13e9f';
|
|
105
|
+
expect(getSafeDisplayName(uuid, 'Sarah', 'Sarah Williams')).toBe(
|
|
106
|
+
'Sarah'
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// User with only full name
|
|
110
|
+
expect(getSafeDisplayName(uuid, null, 'Robert Brown')).toBe(
|
|
111
|
+
'Robert Brown'
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// Completely anonymous
|
|
115
|
+
expect(getSafeDisplayName(uuid, null, null, 'coach')).toBe('coach');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|