@novacraft-engineering/mailbox 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 (114) hide show
  1. package/.env.example +110 -0
  2. package/LICENSE +21 -0
  3. package/README.md +75 -0
  4. package/app/api/mail/accessors/route.ts +197 -0
  5. package/app/api/mail/attachments/route.ts +54 -0
  6. package/app/api/mail/company-signature/route.ts +36 -0
  7. package/app/api/mail/contacts/route.ts +13 -0
  8. package/app/api/mail/emails/[id]/attachments/download/route.ts +69 -0
  9. package/app/api/mail/emails/[id]/attachments/forward/route.ts +65 -0
  10. package/app/api/mail/emails/[id]/route.ts +127 -0
  11. package/app/api/mail/emails/route.ts +116 -0
  12. package/app/api/mail/events/route.ts +12 -0
  13. package/app/api/mail/fonts/route.ts +44 -0
  14. package/app/api/mail/inbox/attachments/download/route.ts +50 -0
  15. package/app/api/mail/inbox/attachments/forward/route.ts +54 -0
  16. package/app/api/mail/inbox/attachments/route.ts +99 -0
  17. package/app/api/mail/inbox/body/route.ts +14 -0
  18. package/app/api/mail/inbox/counts/route.ts +25 -0
  19. package/app/api/mail/inbox/route.ts +506 -0
  20. package/app/api/mail/link-check/route.ts +95 -0
  21. package/app/api/mail/login/route.ts +38 -0
  22. package/app/api/mail/logout/route.ts +9 -0
  23. package/app/api/mail/maintenance/attachments/route.ts +26 -0
  24. package/app/api/mail/maintenance/bodies/route.ts +30 -0
  25. package/app/api/mail/maintenance/list-columns/route.ts +25 -0
  26. package/app/api/mail/maintenance/threads/route.ts +32 -0
  27. package/app/api/mail/me/route.ts +13 -0
  28. package/app/api/mail/outgoing-upload/route.ts +37 -0
  29. package/app/api/mail/password/route.ts +64 -0
  30. package/app/api/mail/pixel/[id]/route.ts +20 -0
  31. package/app/api/mail/push/route.ts +42 -0
  32. package/app/api/mail/render-template/route.ts +58 -0
  33. package/app/api/mail/request-reset/route.ts +65 -0
  34. package/app/api/mail/reset/route.ts +46 -0
  35. package/app/api/mail/send/route.ts +205 -0
  36. package/app/api/mail/settings/route.ts +35 -0
  37. package/app/api/mail/share/attachment/route.ts +91 -0
  38. package/app/api/mail/share/route.ts +130 -0
  39. package/app/api/mail/signature-logo/route.ts +67 -0
  40. package/app/api/mail/stash/route.ts +55 -0
  41. package/app/api/mail/threads/route.ts +18 -0
  42. package/app/api/mail/upload/route.ts +43 -0
  43. package/app/api/share/[id]/route.ts +85 -0
  44. package/app/apple-icon.png +0 -0
  45. package/app/brand/[file]/route.ts +37 -0
  46. package/app/globals.css +14 -0
  47. package/app/icon.png +0 -0
  48. package/app/layout.tsx +30 -0
  49. package/app/mail/AccessCheck.tsx +45 -0
  50. package/app/mail/AttachmentLightbox.tsx +241 -0
  51. package/app/mail/ConfirmDialog.tsx +88 -0
  52. package/app/mail/MailSelect.tsx +138 -0
  53. package/app/mail/RichEditor.tsx +859 -0
  54. package/app/mail/layout.tsx +20 -0
  55. package/app/mail/page.module.css +6320 -0
  56. package/app/mail/page.tsx +7971 -0
  57. package/app/mail/pwa.ts +191 -0
  58. package/app/mail/reset/page.tsx +132 -0
  59. package/app/mail/search.ts +172 -0
  60. package/app/manifest.ts +21 -0
  61. package/app/page.tsx +5 -0
  62. package/app/robots.ts +22 -0
  63. package/app/share/[id]/page.tsx +166 -0
  64. package/app/share/[id]/share.module.css +148 -0
  65. package/eslint.config.mjs +9 -0
  66. package/lib/accent-ramp.ts +54 -0
  67. package/lib/attachments.ts +52 -0
  68. package/lib/brand.client.ts +52 -0
  69. package/lib/brand.ts +87 -0
  70. package/lib/d1.ts +64 -0
  71. package/lib/default-signature.ts +68 -0
  72. package/lib/dev-auth.ts +222 -0
  73. package/lib/email-html.test.ts +74 -0
  74. package/lib/email-html.ts +169 -0
  75. package/lib/emails/index.ts +281 -0
  76. package/lib/emails/templates/academy-followup.html +68 -0
  77. package/lib/emails/templates/auto-reply.html +33 -0
  78. package/lib/emails/templates/contact-followup.html +58 -0
  79. package/lib/emails/templates/field-row.html +4 -0
  80. package/lib/emails/templates/notification.html +23 -0
  81. package/lib/fonts.ts +23 -0
  82. package/lib/link-safety.ts +81 -0
  83. package/lib/mail-provider.ts +183 -0
  84. package/lib/mailbox.ts +1888 -0
  85. package/lib/password.ts +71 -0
  86. package/lib/public-url.ts +14 -0
  87. package/lib/push.ts +37 -0
  88. package/lib/r2.ts +157 -0
  89. package/lib/rate-limit.ts +43 -0
  90. package/lib/session.test.ts +43 -0
  91. package/lib/session.ts +80 -0
  92. package/lib/signature.ts +28 -0
  93. package/lib/threads.test.ts +96 -0
  94. package/lib/threads.ts +72 -0
  95. package/lib/turso.ts +53 -0
  96. package/next.config.ts +51 -0
  97. package/package.json +69 -0
  98. package/public/brand/README.md +35 -0
  99. package/public/icon-192.png +0 -0
  100. package/public/icon-512.png +0 -0
  101. package/public/icon-maskable-512.png +0 -0
  102. package/public/sw.js +41 -0
  103. package/scripts/backfill-attachments.mjs +96 -0
  104. package/scripts/backfill-content-ids.mjs +101 -0
  105. package/scripts/backfill-list-columns.mjs +80 -0
  106. package/scripts/offload-bodies.mjs +152 -0
  107. package/scripts/sample-files.mjs +119 -0
  108. package/scripts/seed-dev.mjs +240 -0
  109. package/tools/README.md +18 -0
  110. package/tools/db-backup.py +158 -0
  111. package/tools/doh.py +46 -0
  112. package/tools/import-mbox.py +703 -0
  113. package/tools/import-takeout.sh +49 -0
  114. package/tsconfig.json +35 -0
@@ -0,0 +1,281 @@
1
+ import { BRAND } from '../brand'
2
+ /**
3
+ * Tiny email-template runtime.
4
+ *
5
+ * Each template lives as a real `.html` file under `./templates/` with
6
+ * Mustache-style `{{placeholder}}` variables. Files are read once at module
7
+ * load (server-only) and cached.
8
+ *
9
+ * Why this shape:
10
+ * - Designers / non-engineers can edit raw HTML without touching TS.
11
+ * - Inboxes only support inline CSS, so the templates keep their styles inline.
12
+ * - No mustache/handlebars dependency — just a `replaceAll` loop. If you
13
+ * ever outgrow it, drop in `mustache` or migrate to react-email.
14
+ */
15
+
16
+ import { readFileSync } from 'node:fs'
17
+ import path from 'node:path'
18
+
19
+ export type EmailMode = 'project' | 'academy'
20
+ export type EmailField = { label: string; value: string }
21
+
22
+ const BRAND_URL = BRAND.websiteUrl
23
+ const BRAND_HOST = BRAND.website
24
+
25
+ // ── Template loading ───────────────────────────────────────────────────────
26
+ const TEMPLATE_DIR = path.join(process.cwd(), 'lib/emails/templates')
27
+
28
+ function loadTemplate(name: string): string {
29
+ return readFileSync(path.join(TEMPLATE_DIR, name), 'utf8')
30
+ }
31
+
32
+ // Read once at module init — cheap and avoids per-request disk hits.
33
+ const TPL_NOTIFICATION = loadTemplate('notification.html')
34
+ const TPL_AUTO_REPLY = loadTemplate('auto-reply.html')
35
+ const TPL_FIELD_ROW = loadTemplate('field-row.html')
36
+ const TPL_ACADEMY_FOLLOWUP = loadTemplate('academy-followup.html')
37
+ const TPL_CONTACT_FOLLOWUP = loadTemplate('contact-followup.html')
38
+
39
+ // ── Rendering primitives ───────────────────────────────────────────────────
40
+ /** Escape user-provided values before interpolating into HTML. */
41
+ export function escapeHtml(s: string): string {
42
+ return s
43
+ .replace(/&/g, '&')
44
+ .replace(/</g, '&lt;')
45
+ .replace(/>/g, '&gt;')
46
+ .replace(/"/g, '&quot;')
47
+ .replace(/'/g, '&#39;')
48
+ }
49
+
50
+ /**
51
+ * Replace every `{{key}}` in `tpl` with `vars[key]`.
52
+ * Values are inserted as-is — callers must escape any user-provided strings.
53
+ * (We intentionally don't auto-escape: row HTML is pre-rendered and needs to
54
+ * pass through untouched.)
55
+ */
56
+ function render(tpl: string, vars: Record<string, string>): string {
57
+ return Object.entries(vars).reduce(
58
+ (acc, [k, v]) => acc.replaceAll(`{{${k}}}`, v),
59
+ tpl,
60
+ )
61
+ }
62
+
63
+ /** Render the field table by repeating the row template once per field. */
64
+ function renderRows(
65
+ fields: EmailField[],
66
+ variant: 'notification' | 'reply',
67
+ ): string {
68
+ const labelBg = variant === 'notification' ? '#110A1F' : '#0A0613'
69
+ const valueBg = variant === 'notification' ? '#0A0613' : '#110A1F'
70
+ const labelWidth = variant === 'notification' ? '160' : '140'
71
+
72
+ return fields
73
+ .map((f) =>
74
+ render(TPL_FIELD_ROW, {
75
+ labelBg,
76
+ valueBg,
77
+ labelWidth,
78
+ label: escapeHtml(f.label || 'Field'),
79
+ value: escapeHtml(f.value || '—'),
80
+ }),
81
+ )
82
+ .join('')
83
+ }
84
+
85
+ // ── Public API ─────────────────────────────────────────────────────────────
86
+ export function renderNotificationEmail(
87
+ mode: EmailMode,
88
+ fields: EmailField[],
89
+ ): string {
90
+ return render(TPL_NOTIFICATION, {
91
+ title:
92
+ mode === 'academy' ? 'New Academy Registration' : 'New Project Inquiry',
93
+ host: BRAND_HOST,
94
+ date: new Date().toUTCString(),
95
+ rows: renderRows(fields, 'notification'),
96
+ })
97
+ }
98
+
99
+ export function notificationSubject(mode: EmailMode, email: string): string {
100
+ return mode === 'academy'
101
+ ? `Academy registration — ${email}`
102
+ : `Project inquiry — ${email}`
103
+ }
104
+
105
+ export function renderAutoReplyEmail(
106
+ mode: EmailMode,
107
+ fields: EmailField[],
108
+ ): string {
109
+ const isAcademy = mode === 'academy'
110
+ return render(TPL_AUTO_REPLY, {
111
+ headline: isAcademy ? 'Your spot is reserved.' : 'We got your brief.',
112
+ kicker: isAcademy ? 'Academy' : 'Studio',
113
+ lead: isAcademy
114
+ ? `Thanks for registering for ${BRAND.name} Academy. A human on our team will reach out within 24 hours with cohort details and payment info.`
115
+ : "Thanks for reaching out. A human on our team will read this and reply within 8 business hours (Mon–Fri). The average response time is 1 hour. If we need anything else to scope your project, we'll ask.",
116
+ rows: renderRows(fields, 'reply'),
117
+ brandUrl: BRAND_URL,
118
+ brandHost: BRAND_HOST,
119
+ })
120
+ }
121
+
122
+ export function autoReplySubject(mode: EmailMode): string {
123
+ return mode === 'academy'
124
+ ? `Your ${BRAND.name} Academy registration — we've got it`
125
+ : `We got your project brief — ${BRAND.name}`
126
+ }
127
+
128
+ // ── Academy follow-up (sent manually via dev backdoor) ──────────────────────
129
+ export type AcademyFollowupVars = {
130
+ firstName: string
131
+ courses: string
132
+ cohortDate: string
133
+ headline?: string
134
+ introText?: string
135
+ cohortBoxTitle?: string
136
+ cohortDateQuestion?: string
137
+ connectLead?: string
138
+ connectBullet1?: string
139
+ connectBullet2?: string
140
+ connectBullet3?: string
141
+ quickStartTitle?: string
142
+ quickStartText?: string
143
+ quickStartBullet1?: string
144
+ quickStartBullet2?: string
145
+ closingText?: string
146
+ signatureName?: string
147
+ signatureRole?: string
148
+ logoUrl?: string
149
+ }
150
+
151
+ export function renderAcademyFollowupEmail(vars: AcademyFollowupVars): string {
152
+ return render(TPL_ACADEMY_FOLLOWUP, {
153
+ logoUrl: vars.logoUrl ?? BRAND.logoUrl,
154
+ headline: escapeHtml(vars.headline ?? 'Your cohort details are here.'),
155
+ firstName: escapeHtml(vars.firstName),
156
+ introText: escapeHtml(
157
+ vars.introText ??
158
+ `Thanks again for registering for ${BRAND.name} Academy. You're confirmed for ${vars.courses}.`,
159
+ ),
160
+ courses: escapeHtml(vars.courses),
161
+ cohortDate: escapeHtml(vars.cohortDate),
162
+ cohortBoxTitle: escapeHtml(vars.cohortBoxTitle ?? 'Next Cohort'),
163
+ cohortDateQuestion: escapeHtml(
164
+ vars.cohortDateQuestion ??
165
+ 'Does this date work for you, or would you prefer a later cohort? Just reply and let us know.',
166
+ ),
167
+ connectLead: escapeHtml(
168
+ vars.connectLead ??
169
+ "If you can send your phone number also via email, we'd love to connect on WhatsApp or a quick call so we can:",
170
+ ),
171
+ connectBullet1: escapeHtml(vars.connectBullet1 ?? 'Walk you through the curriculum'),
172
+ connectBullet2: escapeHtml(vars.connectBullet2 ?? 'Confirm payment details'),
173
+ connectBullet3: escapeHtml(
174
+ vars.connectBullet3 ?? "Make sure we're both on the same page before day one",
175
+ ),
176
+ quickStartTitle: escapeHtml(vars.quickStartTitle ?? 'Quick Start (Email Only)'),
177
+ quickStartText: escapeHtml(
178
+ vars.quickStartText ??
179
+ 'If you already know you want in and would rather handle everything over email, just reply here with:',
180
+ ),
181
+ quickStartBullet1: escapeHtml(
182
+ vars.quickStartBullet1 ??
183
+ `Confirmation that you're good for ${vars.cohortDate}, or reply with your preferred month and we will send you payment details to get started.`,
184
+ ),
185
+ closingText: escapeHtml(vars.closingText ?? "We'd love to hear from you."),
186
+ signatureName: escapeHtml(vars.signatureName ?? 'Joseph'),
187
+ signatureRole: escapeHtml(vars.signatureRole ?? `${BRAND.name} Academy`),
188
+ brandUrl: BRAND_URL,
189
+ brandHost: BRAND_HOST,
190
+ })
191
+ }
192
+
193
+ export function academyFollowupSubject(courses: string): string {
194
+ return `${BRAND.name} Academy — your cohort details · ${courses}`
195
+ }
196
+
197
+ // ── Contact follow-up (sent manually via dev backdoor) ─────────────────────
198
+ export type ContactFollowupVars = {
199
+ firstName: string
200
+ projectType: string
201
+ headline?: string
202
+ introText?: string
203
+ scopeLead?: string
204
+ detailsLead?: string
205
+ detailBullet1?: string
206
+ detailBullet2?: string
207
+ detailBullet3?: string
208
+ includeAcademy?: string
209
+ academyHeadline?: string
210
+ academyText?: string
211
+ academyBullet1?: string
212
+ academyBullet2?: string
213
+ academyCta?: string
214
+ closingText?: string
215
+ signatureName?: string
216
+ signatureRole?: string
217
+ logoUrl?: string
218
+ }
219
+
220
+ function renderAcademySection(vars: ContactFollowupVars): string {
221
+ if (vars.includeAcademy !== 'true') return ''
222
+ return `
223
+ <div style="background:#110A1F;border:1px solid #2C1F4A;border-radius:12px;padding:20px 24px;margin:0 0 24px">
224
+ <div style="font:600 11px/1 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#A578FF;letter-spacing:.18em;text-transform:uppercase;margin:0 0 10px">
225
+ ${escapeHtml(vars.academyHeadline ?? `${BRAND.name} Academy`)}
226
+ </div>
227
+ <p style="font:400 14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#C8BFD9;margin:0 0 12px">
228
+ ${escapeHtml(vars.academyText ?? 'Interested in levelling up your skills while we scope your project? You can register for an upcoming cohort.')}
229
+ </p>
230
+ <ul style="font:400 14px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#C8BFD9;margin:0 0 12px;padding-left:20px">
231
+ <li>${escapeHtml(vars.academyBullet1 ?? 'Graphic Design — UI/UX, brand systems, motion')}</li>
232
+ <li>${escapeHtml(vars.academyBullet2 ?? 'Programming — full-stack web & mobile development')}</li>
233
+ </ul>
234
+ <p style="font:400 14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#C8BFD9;margin:0">
235
+ ${escapeHtml(vars.academyCta ?? 'Just reply to this email with the course you want and we will send you the next cohort dates and payment details.')}
236
+ </p>
237
+ </div>`
238
+ }
239
+
240
+ export function renderContactFollowupEmail(vars: ContactFollowupVars): string {
241
+ return render(TPL_CONTACT_FOLLOWUP, {
242
+ logoUrl: vars.logoUrl ?? BRAND.logoUrl,
243
+ headline: escapeHtml(vars.headline ?? 'A few quick questions about your project.'),
244
+ firstName: escapeHtml(vars.firstName),
245
+ introText: escapeHtml(
246
+ vars.introText ??
247
+ "Thanks for reaching out. We're excited about what you're building and want to make sure we scope this properly from day one. To move fast, could you clarify a few things?",
248
+ ),
249
+ projectType: escapeHtml(vars.projectType),
250
+ scopeLead: escapeHtml(
251
+ vars.scopeLead ??
252
+ "We want to understand your product, your users, and your timeline so we can propose the right team and approach.",
253
+ ),
254
+ detailsLead: escapeHtml(
255
+ vars.detailsLead ??
256
+ "Here is what would help us most right now:",
257
+ ),
258
+ detailBullet1: escapeHtml(
259
+ vars.detailBullet1 ?? 'What problem are you solving, and who is the primary user?',
260
+ ),
261
+ detailBullet2: escapeHtml(
262
+ vars.detailBullet2 ?? 'Do you have existing designs, wireframes, or a product brief we can review?',
263
+ ),
264
+ detailBullet3: escapeHtml(
265
+ vars.detailBullet3 ?? 'What does success look like in 90 days? Launch, revenue, user count?',
266
+ ),
267
+ academySection: renderAcademySection(vars),
268
+ closingText: escapeHtml(
269
+ vars.closingText ??
270
+ "Reply here with as much or as little as you have — we will take it from there.",
271
+ ),
272
+ signatureName: escapeHtml(vars.signatureName ?? `The ${BRAND.name} team`),
273
+ signatureRole: escapeHtml(vars.signatureRole ?? 'Engineering Studio'),
274
+ brandUrl: BRAND_URL,
275
+ brandHost: BRAND_HOST,
276
+ })
277
+ }
278
+
279
+ export function contactFollowupSubject(projectType: string): string {
280
+ return `Re: your project — ${projectType} · ${BRAND.name}`
281
+ }
@@ -0,0 +1,68 @@
1
+ <!doctype html>
2
+ <html>
3
+ <body style="margin:0;background:#050309;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
4
+ <div style="max-width:600px;margin:0 auto;padding:36px 24px">
5
+ <img src="{{logoUrl}}" alt="Podzyme" width="42" height="48" style="display:block;margin-bottom:18px" />
6
+ <div style="font:700 24px/1.2 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#F5F2FA;margin:0 0 8px">
7
+ {{headline}}
8
+ </div>
9
+ <div style="font:500 12px/1 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#A578FF;letter-spacing:.18em;text-transform:uppercase;margin:0 0 22px">
10
+ Podzyme · Academy
11
+ </div>
12
+
13
+ <p style="font:400 15px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#C8BFD9;margin:0 0 24px">
14
+ Hi {{firstName}},
15
+ </p>
16
+
17
+ <p style="font:400 15px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#C8BFD9;margin:0 0 24px">
18
+ {{introText}}
19
+ </p>
20
+
21
+ <div style="background:#110A1F;border:1px solid #2C1F4A;border-radius:12px;padding:20px 24px;margin:0 0 24px">
22
+ <div style="font:600 11px/1 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#A578FF;letter-spacing:.18em;text-transform:uppercase;margin:0 0 10px">
23
+ {{cohortBoxTitle}}
24
+ </div>
25
+ <div style="font:700 20px/1.3 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#F5F2FA;margin:0 0 8px">
26
+ {{cohortDate}}
27
+ </div>
28
+ <p style="font:400 14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#C8BFD9;margin:0">
29
+ {{cohortDateQuestion}}
30
+ </p>
31
+ </div>
32
+
33
+ <p style="font:400 15px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#C8BFD9;margin:0 0 16px">
34
+ {{connectLead}}
35
+ </p>
36
+ <ul style="font:400 14px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#C8BFD9;margin:0 0 24px;padding-left:20px">
37
+ <li>{{connectBullet1}}</li>
38
+ <li>{{connectBullet2}}</li>
39
+ <li>{{connectBullet3}}</li>
40
+ </ul>
41
+
42
+ <div style="background:#110A1F;border:1px solid #2C1F4A;border-radius:12px;padding:20px 24px;margin:0 0 24px">
43
+ <div style="font:600 11px/1 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#A578FF;letter-spacing:.18em;text-transform:uppercase;margin:0 0 10px">
44
+ {{quickStartTitle}}
45
+ </div>
46
+ <p style="font:400 14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#C8BFD9;margin:0 0 12px">
47
+ {{quickStartText}}
48
+ </p>
49
+ <ul style="font:400 14px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#C8BFD9;margin:0;padding-left:20px">
50
+ <li>{{quickStartBullet1}}</li>
51
+ </ul>
52
+ </div>
53
+
54
+ <p style="font:400 15px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#C8BFD9;margin:0 0 24px">
55
+ {{closingText}}
56
+ </p>
57
+
58
+ <p style="font:400 13px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#8E84A8;margin:24px 0 0">
59
+ — {{signatureName}}<br>
60
+ {{signatureRole}}
61
+ </p>
62
+ <p style="font:400 12px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#5A5170;margin:18px 0 0">
63
+ <a href="{{brandUrl}}" style="color:#A578FF;text-decoration:none">{{brandHost}}</a>
64
+ </p>
65
+
66
+ </div>
67
+ </body>
68
+ </html>
@@ -0,0 +1,33 @@
1
+ <!doctype html>
2
+ <html>
3
+ <body style="margin:0;background:#050309;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
4
+ <div style="max-width:600px;margin:0 auto;padding:36px 24px">
5
+ <div style="display:inline-block;width:42px;height:48px;background:linear-gradient(135deg,#6B25E0,#EE3F88);clip-path:polygon(50% 0,100% 25%,100% 75%,50% 100%,0 75%,0 25%);margin-bottom:18px"></div>
6
+ <div style="font:700 24px/1.2 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#F5F2FA;margin:0 0 8px">
7
+ {{headline}}
8
+ </div>
9
+ <div style="font:500 12px/1 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#A578FF;letter-spacing:.18em;text-transform:uppercase;margin:0 0 22px">
10
+ Podzyme · {{kicker}}
11
+ </div>
12
+
13
+ <p style="font:400 15px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#C8BFD9;margin:0 0 24px">
14
+ {{lead}}
15
+ </p>
16
+
17
+ <div style="font:600 11px/1 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#A578FF;letter-spacing:.18em;text-transform:uppercase;margin:0 0 10px">
18
+ Your submission
19
+ </div>
20
+ <table style="width:100%;border-collapse:collapse;border:1px solid #2C1F4A;border-radius:10px;overflow:hidden">
21
+ {{rows}}
22
+ </table>
23
+
24
+ <p style="font:400 13px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#8E84A8;margin:24px 0 0">
25
+ Need to update something? Just reply to this email — we'll see it.
26
+ </p>
27
+ <p style="font:400 12px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#5A5170;margin:18px 0 0">
28
+ — The Podzyme team · <a href="{{brandUrl}}" style="color:#A578FF;text-decoration:none">{{brandHost}}</a>
29
+ </p>
30
+
31
+ </div>
32
+ </body>
33
+ </html>
@@ -0,0 +1,58 @@
1
+ <!doctype html>
2
+ <html>
3
+ <body style="margin:0;background:#050309;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
4
+ <div style="max-width:600px;margin:0 auto;padding:36px 24px">
5
+ <img src="{{logoUrl}}" alt="Podzyme" width="42" height="48" style="display:block;margin-bottom:18px" />
6
+ <div style="font:700 24px/1.2 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#F5F2FA;margin:0 0 8px">
7
+ {{headline}}
8
+ </div>
9
+ <div style="font:500 12px/1 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#A578FF;letter-spacing:.18em;text-transform:uppercase;margin:0 0 22px">
10
+ Podzyme · Studio
11
+ </div>
12
+
13
+ <p style="font:400 15px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#C8BFD9;margin:0 0 24px">
14
+ Hi {{firstName}},
15
+ </p>
16
+
17
+ <p style="font:400 15px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#C8BFD9;margin:0 0 24px">
18
+ {{introText}}
19
+ </p>
20
+
21
+ <div style="background:#110A1F;border:1px solid #2C1F4A;border-radius:12px;padding:20px 24px;margin:0 0 24px">
22
+ <div style="font:600 11px/1 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#A578FF;letter-spacing:.18em;text-transform:uppercase;margin:0 0 10px">
23
+ What we're thinking
24
+ </div>
25
+ <div style="font:700 20px/1.3 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#F5F2FA;margin:0 0 8px">
26
+ {{projectType}}
27
+ </div>
28
+ <p style="font:400 14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#C8BFD9;margin:0">
29
+ {{scopeLead}}
30
+ </p>
31
+ </div>
32
+
33
+ <p style="font:400 15px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#C8BFD9;margin:0 0 16px">
34
+ {{detailsLead}}
35
+ </p>
36
+ <ul style="font:400 14px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#C8BFD9;margin:0 0 24px;padding-left:20px">
37
+ <li>{{detailBullet1}}</li>
38
+ <li>{{detailBullet2}}</li>
39
+ <li>{{detailBullet3}}</li>
40
+ </ul>
41
+
42
+ {{academySection}}
43
+
44
+ <p style="font:400 15px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#C8BFD9;margin:0 0 24px">
45
+ {{closingText}}
46
+ </p>
47
+
48
+ <p style="font:400 13px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#8E84A8;margin:24px 0 0">
49
+ — {{signatureName}}<br>
50
+ {{signatureRole}}
51
+ </p>
52
+ <p style="font:400 12px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#5A5170;margin:18px 0 0">
53
+ <a href="{{brandUrl}}" style="color:#A578FF;text-decoration:none">{{brandHost}}</a>
54
+ </p>
55
+
56
+ </div>
57
+ </body>
58
+ </html>
@@ -0,0 +1,4 @@
1
+ <tr>
2
+ <td style="padding:10px 14px;background:{{labelBg}};color:#8E84A8;font:600 12px/1.4 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;text-transform:uppercase;letter-spacing:.08em;border-bottom:1px solid #2C1F4A;vertical-align:top;width:{{labelWidth}}px">{{label}}</td>
3
+ <td style="padding:10px 14px;background:{{valueBg}};color:#F5F2FA;font:400 14px/1.55 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;border-bottom:1px solid #2C1F4A;white-space:pre-wrap">{{value}}</td>
4
+ </tr>
@@ -0,0 +1,23 @@
1
+ <!doctype html>
2
+ <html>
3
+ <body style="margin:0;background:#050309;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif">
4
+ <div style="max-width:600px;margin:0 auto;padding:32px 24px">
5
+
6
+ <div style="font:700 22px/1.2 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#F5F2FA;margin:0 0 6px">
7
+ {{title}}
8
+ </div>
9
+ <div style="font:500 12px/1 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#A578FF;letter-spacing:.18em;text-transform:uppercase;margin:0 0 28px">
10
+ Podzyme · Inbox
11
+ </div>
12
+
13
+ <table style="width:100%;border-collapse:collapse;border:1px solid #2C1F4A;border-radius:10px;overflow:hidden">
14
+ {{rows}}
15
+ </table>
16
+
17
+ <p style="font:400 12px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#5A5170;margin:24px 0 0">
18
+ Sent from {{host}} · {{date}}
19
+ </p>
20
+
21
+ </div>
22
+ </body>
23
+ </html>
package/lib/fonts.ts ADDED
@@ -0,0 +1,23 @@
1
+ export type CustomFont = { name: string; url: string }
2
+
3
+ export const BUILTIN_FONTS = ['Arial', 'Georgia', 'Times New Roman', 'Verdana', 'Tahoma', 'Trebuchet MS', 'Calibri', 'Courier New']
4
+
5
+ const FORMATS: Record<string, string> = { woff2: 'woff2', woff: 'woff', ttf: 'truetype', otf: 'opentype' }
6
+
7
+ export function fontFaceCss(fonts: CustomFont[], origin = ''): string {
8
+ return fonts
9
+ .filter(font => font.url)
10
+ .map(font => {
11
+ const extension = font.url.split('.').pop()?.toLowerCase() ?? ''
12
+ const format = FORMATS[extension] ? ` format('${FORMATS[extension]}')` : ''
13
+ return `@font-face{font-family:'${font.name.replace(/'/g, '')}';src:url('${origin}${font.url}')${format};font-display:swap;}`
14
+ })
15
+ .join('')
16
+ }
17
+
18
+ export const FONT_SIZES = ['10px', '11px', '12px', '13px', '14px', '15px', '16px', '18px', '20px', '24px', '28px', '32px']
19
+
20
+ export type BaseFont = { family: string; size: string }
21
+ export const EMPTY_FONT: BaseFont = { family: '', size: '' }
22
+
23
+ export const fontStack = (family: string) => (family ? `'${family.replace(/'/g, '')}',Arial,Helvetica,sans-serif` : 'Arial,Helvetica,sans-serif')
@@ -0,0 +1,81 @@
1
+ export type LinkVerdict = 'safe' | 'suspicious' | 'dangerous' | 'unknown'
2
+
3
+ export type LinkFinding = {
4
+ url: string
5
+ verdict: LinkVerdict
6
+ reasons: string[]
7
+ }
8
+
9
+ const URL_RE = /\b(?:https?:\/\/|www\.)[^\s<>"'()]+[^\s<>"'().,;:!?]/gi
10
+
11
+ // Free hosts and shorteners that hide the real destination.
12
+ const SHORTENERS = new Set([
13
+ 'bit.ly', 'tinyurl.com', 't.co', 'goo.gl', 'ow.ly', 'is.gd', 'buff.ly', 'rebrand.ly',
14
+ 'cutt.ly', 'shorturl.at', 'rb.gy', 'tiny.cc', 'lnkd.in', 'bit.do', 't.ly',
15
+ ])
16
+
17
+ // TLDs disproportionately used for throwaway phishing domains.
18
+ const RISKY_TLDS = new Set(['zip', 'mov', 'tk', 'ml', 'ga', 'cf', 'gq', 'top', 'xyz', 'click', 'link', 'work', 'country', 'kim'])
19
+
20
+ export function extractUrls(text: string): string[] {
21
+ const found = text.match(URL_RE) ?? []
22
+ return Array.from(new Set(found.map(entry => (entry.startsWith('www.') ? `https://${entry}` : entry))))
23
+ }
24
+
25
+ export function normalizeUrl(raw: string): string {
26
+ return raw.startsWith('www.') ? `https://${raw}` : raw
27
+ }
28
+
29
+ /** Offline checks — catch the common email-phishing shapes with no network call. */
30
+ export function inspectUrl(raw: string, anchorText?: string): LinkFinding {
31
+ const url = normalizeUrl(raw.trim())
32
+ const reasons: string[] = []
33
+ let parsed: URL
34
+ try {
35
+ parsed = new URL(url)
36
+ } catch {
37
+ return { url, verdict: 'unknown', reasons: ['Malformed URL'] }
38
+ }
39
+
40
+ const host = parsed.hostname.toLowerCase()
41
+
42
+ if (parsed.protocol === 'http:') reasons.push('Unencrypted connection (http)')
43
+ if (parsed.username || parsed.password) reasons.push('Credentials embedded in the URL')
44
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) reasons.push('Raw IP address instead of a domain')
45
+ if (host.startsWith('xn--') || host.includes('.xn--')) reasons.push('Punycode domain — may impersonate another brand')
46
+ if (SHORTENERS.has(host)) reasons.push('Shortened link — real destination is hidden')
47
+
48
+ const tld = host.split('.').pop() ?? ''
49
+ if (RISKY_TLDS.has(tld)) reasons.push(`High-risk domain ending (.${tld})`)
50
+ if (host.split('.').length > 4) reasons.push('Unusually deep subdomain chain')
51
+ if (parsed.href.length > 300) reasons.push('Excessively long URL')
52
+
53
+ // The strongest email-phishing signal: the visible text claims one destination,
54
+ // the href points somewhere else.
55
+ if (anchorText) {
56
+ const claimed = anchorText.match(/\b(?:https?:\/\/)?((?:[\w-]+\.)+[a-z]{2,})\b/i)?.[1]?.toLowerCase()
57
+ if (claimed) {
58
+ const claimedRoot = claimed.split('.').slice(-2).join('.')
59
+ const actualRoot = host.split('.').slice(-2).join('.')
60
+ if (claimedRoot !== actualRoot) {
61
+ reasons.push(`Link text says "${claimedRoot}" but points to ${actualRoot}`)
62
+ }
63
+ }
64
+ }
65
+
66
+ const severe = reasons.some(
67
+ reason =>
68
+ reason.startsWith('Link text says') ||
69
+ reason.startsWith('Credentials') ||
70
+ reason.startsWith('Raw IP') ||
71
+ reason.startsWith('Punycode'),
72
+ )
73
+ if (severe) return { url, verdict: 'dangerous', reasons }
74
+ if (reasons.length) return { url, verdict: 'suspicious', reasons }
75
+ return { url, verdict: 'safe', reasons }
76
+ }
77
+
78
+ export function mergeVerdict(local: LinkVerdict, remote: LinkVerdict): LinkVerdict {
79
+ const rank: Record<LinkVerdict, number> = { safe: 0, unknown: 1, suspicious: 2, dangerous: 3 }
80
+ return rank[remote] >= rank[local] ? remote : local
81
+ }