@learncard/email-templates 1.0.2 → 1.0.4

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 (80) hide show
  1. package/README.md +43 -43
  2. package/dist/previews/account-approved-scenarios.d.ts +2 -1
  3. package/dist/previews/account-approved-scenarios.d.ts.map +1 -1
  4. package/dist/previews/endorsement-request-scenarios.d.ts +2 -1
  5. package/dist/previews/endorsement-request-scenarios.d.ts.map +1 -1
  6. package/dist/previews/guardian-approval-scenarios.d.ts +2 -1
  7. package/dist/previews/guardian-approval-scenarios.d.ts.map +1 -1
  8. package/dist/previews/guardian-credential-scenarios.d.ts +2 -1
  9. package/dist/previews/guardian-credential-scenarios.d.ts.map +1 -1
  10. package/dist/previews/inbox-claim-scenarios.d.ts +2 -1
  11. package/dist/previews/inbox-claim-scenarios.d.ts.map +1 -1
  12. package/dist/previews/recovery-key-scenarios.d.ts +2 -1
  13. package/dist/previews/recovery-key-scenarios.d.ts.map +1 -1
  14. package/dist/previews/verification-code-scenarios.d.ts +2 -1
  15. package/dist/previews/verification-code-scenarios.d.ts.map +1 -1
  16. package/dist/templates/account-approved.d.ts +1 -1
  17. package/dist/templates/account-approved.d.ts.map +1 -1
  18. package/dist/templates/credential-awaiting-guardian.d.ts +1 -1
  19. package/dist/templates/credential-awaiting-guardian.d.ts.map +1 -1
  20. package/dist/templates/email-verification.d.ts +1 -1
  21. package/dist/templates/email-verification.d.ts.map +1 -1
  22. package/dist/templates/endorsement-request.d.ts +1 -1
  23. package/dist/templates/endorsement-request.d.ts.map +1 -1
  24. package/dist/templates/guardian-approval.d.ts +1 -1
  25. package/dist/templates/guardian-approval.d.ts.map +1 -1
  26. package/dist/templates/guardian-approved-claim.d.ts +1 -1
  27. package/dist/templates/guardian-approved-claim.d.ts.map +1 -1
  28. package/dist/templates/guardian-credential-approval.d.ts +1 -1
  29. package/dist/templates/guardian-credential-approval.d.ts.map +1 -1
  30. package/dist/templates/guardian-email-otp.d.ts +1 -1
  31. package/dist/templates/guardian-email-otp.d.ts.map +1 -1
  32. package/dist/templates/guardian-rejected-credential.d.ts +1 -1
  33. package/dist/templates/guardian-rejected-credential.d.ts.map +1 -1
  34. package/dist/templates/inbox-claim.d.ts +1 -1
  35. package/dist/templates/inbox-claim.d.ts.map +1 -1
  36. package/dist/templates/recovery-key.d.ts +1 -1
  37. package/dist/templates/recovery-key.d.ts.map +1 -1
  38. package/dist/templates/verification-code.d.ts +1 -1
  39. package/dist/templates/verification-code.d.ts.map +1 -1
  40. package/dist/templates/verification-code.js +9 -9
  41. package/dist/templates/verification-code.js.map +1 -1
  42. package/dist/tenant-registry.d.ts.map +1 -1
  43. package/dist/tenant-registry.js +17 -0
  44. package/dist/tenant-registry.js.map +1 -1
  45. package/package.json +49 -39
  46. package/src/__tests__/render.test.ts +432 -0
  47. package/src/__tests__/sms.test.ts +95 -0
  48. package/src/__tests__/tenant-registry.test.ts +281 -0
  49. package/src/branding.ts +61 -0
  50. package/src/components/CodeBlock.tsx +61 -0
  51. package/src/components/EmailButton.tsx +36 -0
  52. package/src/components/IssuerLogo.tsx +41 -0
  53. package/src/components/Layout.tsx +192 -0
  54. package/src/components/LinkFallback.tsx +50 -0
  55. package/src/index.ts +50 -0
  56. package/src/previews/_fixtures.tsx +51 -0
  57. package/src/previews/account-approved-scenarios.tsx +31 -0
  58. package/src/previews/endorsement-request-scenarios.tsx +50 -0
  59. package/src/previews/guardian-approval-scenarios.tsx +39 -0
  60. package/src/previews/guardian-credential-scenarios.tsx +88 -0
  61. package/src/previews/inbox-claim-scenarios.tsx +66 -0
  62. package/src/previews/recovery-key-scenarios.tsx +32 -0
  63. package/src/previews/verification-code-scenarios.tsx +60 -0
  64. package/src/render.ts +409 -0
  65. package/src/sms.ts +68 -0
  66. package/src/templates/account-approved.tsx +105 -0
  67. package/src/templates/credential-awaiting-guardian.tsx +137 -0
  68. package/src/templates/email-verification.tsx +110 -0
  69. package/src/templates/endorsement-request.tsx +157 -0
  70. package/src/templates/guardian-approval.tsx +122 -0
  71. package/src/templates/guardian-approved-claim.tsx +114 -0
  72. package/src/templates/guardian-credential-approval.tsx +127 -0
  73. package/src/templates/guardian-email-otp.tsx +101 -0
  74. package/src/templates/guardian-rejected-credential.tsx +103 -0
  75. package/src/templates/inbox-claim.tsx +141 -0
  76. package/src/templates/index.ts +35 -0
  77. package/src/templates/recovery-key.tsx +102 -0
  78. package/src/templates/verification-code.tsx +164 -0
  79. package/src/tenant-registry.ts +226 -0
  80. package/LICENSE +0 -21
@@ -0,0 +1,164 @@
1
+ /**
2
+ * VerificationCode — shared template for all code-based verification emails.
3
+ *
4
+ * Used by:
5
+ * - login-verification-code (lca-api firebase.ts)
6
+ * - recovery-email-code (lca-api keys.ts)
7
+ * - embed-email-verification (brain-service contact-methods.ts)
8
+ * - contact-method-verification (brain-service contact-methods.ts)
9
+ *
10
+ * The `variant` prop controls the subject line and descriptive text.
11
+ */
12
+
13
+ import { Text } from '@react-email/components';
14
+ import * as React from 'react';
15
+
16
+ import type { TenantBranding } from '../branding';
17
+ import { DEFAULT_BRANDING } from '../branding';
18
+ import { Layout } from '../components/Layout';
19
+ import { CodeBlock } from '../components/CodeBlock';
20
+
21
+ export type VerificationCodeVariant =
22
+ | 'login'
23
+ | 'recovery-email'
24
+ | 'embed-verification'
25
+ | 'contact-method';
26
+
27
+ export interface VerificationCodeProps {
28
+ branding: TenantBranding;
29
+ verificationCode: string;
30
+ verificationEmail?: string;
31
+ variant: VerificationCodeVariant;
32
+ }
33
+
34
+ const VARIANT_CONFIG: Record<
35
+ VerificationCodeVariant,
36
+ {
37
+ subject: (brandName: string) => string;
38
+ heading: (brandName: string) => string;
39
+ description: (email: string | undefined, brandName: string) => string;
40
+ expiry: string;
41
+ }
42
+ > = {
43
+ login: {
44
+ subject: b => `Your ${b} login code`,
45
+ heading: b => `Your ${b} Login Code`,
46
+ description: (email, b) =>
47
+ email
48
+ ? `Here's your secure 6-digit code to log into ${b}`
49
+ : `Here's your secure 6-digit code to log into ${b}`,
50
+ expiry: 'This code will expire in 5 minutes. If you didn\u2019t request this, you can safely ignore this email.',
51
+ },
52
+ 'recovery-email': {
53
+ subject: () => 'Verify your recovery email',
54
+ heading: b => `Your ${b} Verification Code`,
55
+ description: (email, b) =>
56
+ email
57
+ ? `Here's your secure 6-digit code to add ${email} as a recovery method for ${b}`
58
+ : `Here's your secure 6-digit code to add a recovery method for ${b}`,
59
+ expiry: 'This code will expire in 5 minutes. If you didn\u2019t request this, you can safely ignore this email.',
60
+ },
61
+ 'embed-verification': {
62
+ subject: b => `Your ${b} verification code`,
63
+ heading: b => `Your ${b} Verification Code`,
64
+ description: email =>
65
+ email
66
+ ? `Enter this code to verify ${email} and claim your credential:`
67
+ : 'Enter this code to verify your email address and claim your credential:',
68
+ expiry: 'This code will expire in 10 minutes. If you didn\u2019t request this, you can safely ignore this email.',
69
+ },
70
+ 'contact-method': {
71
+ subject: b => `Your ${b} verification code`,
72
+ heading: b => `Your ${b} Verification Code`,
73
+ description: () => 'Enter this code in the app to verify your contact information.',
74
+ expiry: 'This code will expire in 24 hours. If you didn\u2019t request this, you can safely ignore this email.',
75
+ },
76
+ };
77
+
78
+ export const VerificationCode: React.FC<VerificationCodeProps> = ({
79
+ branding,
80
+ verificationCode,
81
+ verificationEmail,
82
+ variant,
83
+ }) => {
84
+ const config = VARIANT_CONFIG[variant];
85
+
86
+ return (
87
+ <Layout branding={branding} preview={`Your code: ${verificationCode}`}>
88
+ <Text style={headingStyle}>{config.heading(branding.brandName)}</Text>
89
+
90
+ <Text style={paragraph}>Hello,</Text>
91
+
92
+ <Text style={paragraph}>
93
+ {config.description(verificationEmail, branding.brandName)}
94
+ </Text>
95
+
96
+ <CodeBlock code={verificationCode} label="Verification code" />
97
+
98
+ <Text style={muted}>{config.expiry}</Text>
99
+
100
+ <Text style={paragraph}>
101
+ {branding.brandName} is your private, digital passport for learning and work. It
102
+ lets you securely collect and share your verified skills and achievements online.
103
+ </Text>
104
+
105
+ <Text style={signOff}>
106
+ Sincerely,
107
+ <br />
108
+ The {branding.brandName} Team
109
+ </Text>
110
+ </Layout>
111
+ );
112
+ };
113
+
114
+ export const getVerificationCodeSubject = (
115
+ branding: TenantBranding,
116
+ variant: VerificationCodeVariant
117
+ ): string => VARIANT_CONFIG[variant].subject(branding.brandName);
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Styles
121
+ // ---------------------------------------------------------------------------
122
+
123
+ const headingStyle: React.CSSProperties = {
124
+ fontSize: 24,
125
+ fontWeight: 600,
126
+ color: '#111827',
127
+ margin: '0 0 24px',
128
+ };
129
+
130
+ const paragraph: React.CSSProperties = {
131
+ fontSize: 16,
132
+ color: '#374151',
133
+ lineHeight: '24px',
134
+ margin: '0 0 24px',
135
+ };
136
+
137
+ const muted: React.CSSProperties = {
138
+ fontSize: 14,
139
+ color: '#6b7280',
140
+ lineHeight: '20px',
141
+ margin: '0 0 24px',
142
+ };
143
+
144
+ const signOff: React.CSSProperties = {
145
+ fontSize: 14,
146
+ color: '#374151',
147
+ lineHeight: '20px',
148
+ margin: '24px 0 0',
149
+ };
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Preview (used by `bun run dev` / react-email dev server)
153
+ // ---------------------------------------------------------------------------
154
+
155
+ export default function Preview() {
156
+ return (
157
+ <VerificationCode
158
+ branding={DEFAULT_BRANDING}
159
+ verificationCode="847293"
160
+ verificationEmail="jane@example.com"
161
+ variant="login"
162
+ />
163
+ );
164
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Server-side tenant resolution for brain-service and lca-api.
3
+ *
4
+ * Resolution order (first match wins):
5
+ * 1. Explicit `X-Tenant-Id` header — native apps, any client that knows its tenant
6
+ * 2. `Origin` / `Referer` header hostname — web apps (e.g. alpha.vetpass.app → vetpass)
7
+ * 3. `DEFAULT_TENANT_ID` env var — per-tenant deploys, cron jobs, server-to-server
8
+ * 4. Fallback to 'learncard'
9
+ *
10
+ * The registry maps tenant IDs to email branding overrides. Missing fields
11
+ * fall back to LearnCard defaults via `resolveBranding()`.
12
+ */
13
+
14
+ import type { TenantBranding } from './branding';
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Types
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export interface ResolvedTenant {
21
+ /** Canonical tenant identifier, e.g. 'vetpass', 'learncard'. */
22
+ id: string;
23
+
24
+ /** Partial email branding — pass to `resolveBranding()` for full defaults. */
25
+ emailBranding: Partial<TenantBranding>;
26
+
27
+ /** How the tenant was resolved — useful for logging / debugging. */
28
+ resolvedVia: 'header' | 'origin' | 'env' | 'default';
29
+ }
30
+
31
+ export interface RequestHeaders {
32
+ /** Value of the X-Tenant-Id header. */
33
+ 'x-tenant-id'?: string | undefined;
34
+
35
+ /** Origin header (e.g. "https://alpha.vetpass.app"). */
36
+ origin?: string | undefined;
37
+
38
+ /** Referer header (fallback for Origin). */
39
+ referer?: string | undefined;
40
+
41
+ [key: string]: string | string[] | undefined;
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Origin → tenant ID mapping
46
+ //
47
+ // Add new entries when a tenant gets a web deployment.
48
+ // Supports exact hostname match and wildcard subdomains.
49
+ // ---------------------------------------------------------------------------
50
+
51
+ const ORIGIN_MAP: Record<string, string> = {
52
+ // VetPass
53
+ 'vetpass.app': 'vetpass',
54
+ 'alpha.vetpass.app': 'vetpass',
55
+ 'staging.vetpass.app': 'vetpass',
56
+
57
+ // ScoutPass
58
+ 'scout.org': 'scoutpass',
59
+ 'www.scout.org': 'scoutpass',
60
+ 'pass.scout.org': 'scoutpass',
61
+ 'staging.pass.scout.org': 'scoutpass',
62
+
63
+ // LearnCard
64
+ 'learncard.app': 'learncard',
65
+ 'staging.learncard.app': 'learncard',
66
+ 'app.learncard.com': 'learncard',
67
+
68
+ // Local dev
69
+ 'localhost': 'learncard',
70
+ };
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Tenant email branding registry
74
+ //
75
+ // Only override fields that differ from LearnCard defaults.
76
+ // Missing fields → resolveBranding() fills in DEFAULT_BRANDING.
77
+ // ---------------------------------------------------------------------------
78
+
79
+ const TENANT_EMAIL_BRANDING: Record<string, Partial<TenantBranding>> = {
80
+ learncard: {},
81
+
82
+ vetpass: {
83
+ brandName: 'VetPass',
84
+ logoUrl: 'https://vetpass.app/assets/icon/icon.png',
85
+ logoAlt: 'VetPass',
86
+ primaryColor: '#1B5E20',
87
+ primaryTextColor: '#ffffff',
88
+ supportEmail: 'support@vetpass.app',
89
+ websiteUrl: 'https://www.vetpass.app',
90
+ appUrl: 'https://vetpass.app',
91
+ fromDomain: 'vetpass.app',
92
+ copyrightHolder: 'VetPass',
93
+ },
94
+
95
+ scoutpass: {
96
+ brandName: 'ScoutPass',
97
+ logoUrl: 'https://pass.scout.org/assets/icon/icon.png',
98
+ logoAlt: 'ScoutPass',
99
+ primaryColor: '#00BA88',
100
+ primaryTextColor: '#ffffff',
101
+ supportEmail: 'support@scout.org',
102
+ websiteUrl: 'https://scout.org/scoutpass',
103
+ appUrl: 'https://pass.scout.org',
104
+ fromDomain: 'scoutnetwork.org',
105
+ copyrightHolder: 'ScoutPass',
106
+ },
107
+ };
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Resolution
111
+ // ---------------------------------------------------------------------------
112
+
113
+ /**
114
+ * Extract a hostname from a URL string. Returns undefined if parsing fails.
115
+ */
116
+ const extractHostname = (urlString: string): string | undefined => {
117
+ try {
118
+ return new URL(urlString).hostname;
119
+ } catch {
120
+ return undefined;
121
+ }
122
+ };
123
+
124
+ /**
125
+ * Look up tenant ID from a hostname, checking exact match first,
126
+ * then stripping subdomains progressively (e.g. "foo.bar.vetpass.app" → "vetpass.app").
127
+ */
128
+ const tenantIdFromHostname = (hostname: string): string | undefined => {
129
+ if (ORIGIN_MAP[hostname]) return ORIGIN_MAP[hostname];
130
+
131
+ // Strip subdomains: "alpha.vetpass.app" → "vetpass.app"
132
+ const parts = hostname.split('.');
133
+
134
+ for (let i = 1; i < parts.length - 1; i++) {
135
+ const parent = parts.slice(i).join('.');
136
+
137
+ if (ORIGIN_MAP[parent]) return ORIGIN_MAP[parent];
138
+ }
139
+
140
+ return undefined;
141
+ };
142
+
143
+ /**
144
+ * Resolve the tenant from HTTP request headers.
145
+ *
146
+ * @param headers — Request headers object. Accepts plain objects, Node IncomingHttpHeaders, etc.
147
+ * @param envTenantId — Override for `process.env.DEFAULT_TENANT_ID` (useful for testing).
148
+ */
149
+ export const resolveTenantFromRequest = (
150
+ headers: RequestHeaders,
151
+ envTenantId?: string
152
+ ): ResolvedTenant => {
153
+ // 1. Explicit X-Tenant-Id header (native apps, any informed client)
154
+ const explicit = normalizeHeader(headers['x-tenant-id']);
155
+
156
+ if (explicit) {
157
+ return buildTenant(explicit, 'header');
158
+ }
159
+
160
+ // 2. Origin / Referer header (web apps)
161
+ const origin = normalizeHeader(headers.origin) || normalizeHeader(headers.referer);
162
+
163
+ if (origin) {
164
+ const hostname = extractHostname(origin);
165
+
166
+ if (hostname) {
167
+ const matched = tenantIdFromHostname(hostname);
168
+
169
+ if (matched) {
170
+ return buildTenant(matched, 'origin');
171
+ }
172
+ }
173
+ }
174
+
175
+ // 3. Deploy-level env var
176
+ const envDefault = envTenantId ?? process.env.DEFAULT_TENANT_ID;
177
+
178
+ if (envDefault) {
179
+ return buildTenant(envDefault, 'env');
180
+ }
181
+
182
+ // 4. Safe default
183
+ return buildTenant('learncard', 'default');
184
+ };
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Helpers
188
+ // ---------------------------------------------------------------------------
189
+
190
+ const normalizeHeader = (value: string | string[] | undefined): string | undefined => {
191
+ if (Array.isArray(value)) return value[0];
192
+
193
+ return value || undefined;
194
+ };
195
+
196
+ const buildTenant = (id: string, resolvedVia: ResolvedTenant['resolvedVia']): ResolvedTenant => ({
197
+ id,
198
+ emailBranding: TENANT_EMAIL_BRANDING[id] ?? {},
199
+ resolvedVia,
200
+ });
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Admin helpers (for extending the registry at runtime via env vars)
204
+ // ---------------------------------------------------------------------------
205
+
206
+ /**
207
+ * Register additional origin → tenant mappings at startup.
208
+ * Useful for dev/staging domains configured via env vars.
209
+ */
210
+ export const registerOriginMapping = (hostname: string, tenantId: string): void => {
211
+ ORIGIN_MAP[hostname] = tenantId;
212
+ };
213
+
214
+ /**
215
+ * Register or update email branding for a tenant at startup.
216
+ * Merges with any existing branding (does not replace).
217
+ */
218
+ export const registerTenantBranding = (
219
+ tenantId: string,
220
+ branding: Partial<TenantBranding>
221
+ ): void => {
222
+ TENANT_EMAIL_BRANDING[tenantId] = {
223
+ ...TENANT_EMAIL_BRANDING[tenantId],
224
+ ...branding,
225
+ };
226
+ };
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2025 Learning Economy Foundation <sdk@learningeconomy.io>
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.