@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.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +211 -0
  3. package/dist/emails/bodyweight-goal-reached.d.ts +14 -0
  4. package/dist/emails/bodyweight-goal-reached.d.ts.map +1 -0
  5. package/dist/emails/bodyweight-goal-reached.js +177 -0
  6. package/dist/emails/bodyweight-goal-reached.js.map +1 -0
  7. package/dist/emails/client-accepted-invitation.d.ts +10 -0
  8. package/dist/emails/client-accepted-invitation.d.ts.map +1 -0
  9. package/dist/emails/client-accepted-invitation.js +99 -0
  10. package/dist/emails/client-accepted-invitation.js.map +1 -0
  11. package/dist/emails/coach-invite.d.ts +10 -0
  12. package/dist/emails/coach-invite.d.ts.map +1 -0
  13. package/dist/emails/coach-invite.js +126 -0
  14. package/dist/emails/coach-invite.js.map +1 -0
  15. package/dist/emails/coach-removed-client.d.ts +8 -0
  16. package/dist/emails/coach-removed-client.d.ts.map +1 -0
  17. package/dist/emails/coach-removed-client.js +80 -0
  18. package/dist/emails/coach-removed-client.js.map +1 -0
  19. package/dist/emails/direct-message.d.ts +11 -0
  20. package/dist/emails/direct-message.d.ts.map +1 -0
  21. package/dist/emails/direct-message.js +103 -0
  22. package/dist/emails/direct-message.js.map +1 -0
  23. package/dist/emails/feature-discovery.d.ts +11 -0
  24. package/dist/emails/feature-discovery.d.ts.map +1 -0
  25. package/dist/emails/feature-discovery.js +121 -0
  26. package/dist/emails/feature-discovery.js.map +1 -0
  27. package/dist/emails/first-workout-assigned.d.ts +10 -0
  28. package/dist/emails/first-workout-assigned.d.ts.map +1 -0
  29. package/dist/emails/first-workout-assigned.js +98 -0
  30. package/dist/emails/first-workout-assigned.js.map +1 -0
  31. package/dist/emails/first-workout-completed.d.ts +11 -0
  32. package/dist/emails/first-workout-completed.d.ts.map +1 -0
  33. package/dist/emails/first-workout-completed.js +129 -0
  34. package/dist/emails/first-workout-completed.js.map +1 -0
  35. package/dist/emails/index.d.ts +7 -0
  36. package/dist/emails/index.d.ts.map +1 -0
  37. package/dist/emails/index.js +7 -0
  38. package/dist/emails/index.js.map +1 -0
  39. package/dist/emails/new-follower.d.ts +11 -0
  40. package/dist/emails/new-follower.d.ts.map +1 -0
  41. package/dist/emails/new-follower.js +98 -0
  42. package/dist/emails/new-follower.js.map +1 -0
  43. package/dist/emails/subscription-canceled.d.ts +10 -0
  44. package/dist/emails/subscription-canceled.d.ts.map +1 -0
  45. package/dist/emails/subscription-canceled.js +131 -0
  46. package/dist/emails/subscription-canceled.js.map +1 -0
  47. package/dist/emails/support-email.d.ts +8 -0
  48. package/dist/emails/support-email.d.ts.map +1 -0
  49. package/dist/emails/support-email.js +40 -0
  50. package/dist/emails/support-email.js.map +1 -0
  51. package/dist/emails/team-invite.d.ts +11 -0
  52. package/dist/emails/team-invite.d.ts.map +1 -0
  53. package/dist/emails/team-invite.js +100 -0
  54. package/dist/emails/team-invite.js.map +1 -0
  55. package/dist/emails/team-member-removed-email.d.ts +8 -0
  56. package/dist/emails/team-member-removed-email.d.ts.map +1 -0
  57. package/dist/emails/team-member-removed-email.js +97 -0
  58. package/dist/emails/team-member-removed-email.js.map +1 -0
  59. package/dist/emails/tracked-magic-link-activate.d.ts +7 -0
  60. package/dist/emails/tracked-magic-link-activate.d.ts.map +1 -0
  61. package/dist/emails/tracked-magic-link-activate.js +93 -0
  62. package/dist/emails/tracked-magic-link-activate.js.map +1 -0
  63. package/dist/emails/tracked-magic-link.d.ts +7 -0
  64. package/dist/emails/tracked-magic-link.d.ts.map +1 -0
  65. package/dist/emails/tracked-magic-link.js +101 -0
  66. package/dist/emails/tracked-magic-link.js.map +1 -0
  67. package/dist/emails/week-one-checkin.d.ts +10 -0
  68. package/dist/emails/week-one-checkin.d.ts.map +1 -0
  69. package/dist/emails/week-one-checkin.js +141 -0
  70. package/dist/emails/week-one-checkin.js.map +1 -0
  71. package/dist/emails/welcome.d.ts +8 -0
  72. package/dist/emails/welcome.d.ts.map +1 -0
  73. package/dist/emails/welcome.js +146 -0
  74. package/dist/emails/welcome.js.map +1 -0
  75. package/dist/index.d.ts +22 -0
  76. package/dist/index.d.ts.map +1 -0
  77. package/dist/index.js +24 -0
  78. package/dist/index.js.map +1 -0
  79. package/dist/utils/email-validation.d.ts +48 -0
  80. package/dist/utils/email-validation.d.ts.map +1 -0
  81. package/dist/utils/email-validation.js +72 -0
  82. package/dist/utils/email-validation.js.map +1 -0
  83. package/dist/utils/index.d.ts +8 -0
  84. package/dist/utils/index.d.ts.map +1 -0
  85. package/dist/utils/index.js +8 -0
  86. package/dist/utils/index.js.map +1 -0
  87. package/dist/utils/username-validation.d.ts +54 -0
  88. package/dist/utils/username-validation.d.ts.map +1 -0
  89. package/dist/utils/username-validation.js +76 -0
  90. package/dist/utils/username-validation.js.map +1 -0
  91. package/package.json +78 -0
  92. package/src/emails/bodyweight-goal-reached.tsx +396 -0
  93. package/src/emails/client-accepted-invitation.tsx +258 -0
  94. package/src/emails/coach-invite.tsx +270 -0
  95. package/src/emails/coach-removed-client.tsx +212 -0
  96. package/src/emails/direct-message.tsx +249 -0
  97. package/src/emails/feature-discovery.tsx +289 -0
  98. package/src/emails/first-workout-assigned.tsx +255 -0
  99. package/src/emails/first-workout-completed.tsx +312 -0
  100. package/src/emails/index.tsx +6 -0
  101. package/src/emails/new-follower.tsx +260 -0
  102. package/src/emails/subscription-canceled.tsx +311 -0
  103. package/src/emails/support-email.tsx +80 -0
  104. package/src/emails/team-invite.tsx +262 -0
  105. package/src/emails/team-member-removed-email.tsx +240 -0
  106. package/src/emails/tracked-magic-link-activate.tsx +252 -0
  107. package/src/emails/tracked-magic-link.tsx +264 -0
  108. package/src/emails/week-one-checkin.tsx +353 -0
  109. package/src/emails/welcome.tsx +341 -0
  110. package/src/index.ts +57 -0
  111. package/src/utils/email-validation.test.ts +78 -0
  112. package/src/utils/email-validation.ts +80 -0
  113. package/src/utils/index.ts +13 -0
  114. package/src/utils/username-validation.test.ts +118 -0
  115. package/src/utils/username-validation.ts +89 -0
  116. 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
+ });