@novacraft-engineering/mailbox 0.1.0 → 0.2.1

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 (38) hide show
  1. package/README.md +7 -2
  2. package/app/api/mail/accessors/route.ts +28 -24
  3. package/app/api/mail/emails/[id]/attachments/download/route.ts +3 -1
  4. package/app/api/mail/emails/[id]/attachments/forward/route.ts +3 -1
  5. package/app/api/mail/emails/[id]/route.ts +17 -9
  6. package/app/api/mail/emails/route.ts +35 -4
  7. package/app/api/mail/inbox/counts/route.ts +5 -4
  8. package/app/api/mail/inbox/route.ts +41 -262
  9. package/app/api/mail/maintenance/received/route.ts +64 -0
  10. package/app/api/mail/pixel/[id]/route.ts +8 -2
  11. package/app/api/mail/request-reset/route.ts +29 -25
  12. package/app/api/mail/signature-logo/route.ts +33 -8
  13. package/app/api/mail/threads/route.ts +1 -1
  14. package/app/brand/[file]/placeholder.ts +70 -0
  15. package/app/brand/[file]/route.ts +7 -1
  16. package/app/layout.tsx +10 -2
  17. package/app/mail/MailSelect.tsx +23 -4
  18. package/app/mail/RichEditor.tsx +167 -53
  19. package/app/mail/Ticker.tsx +40 -0
  20. package/app/mail/page.module.css +643 -76
  21. package/app/mail/page.tsx +1062 -247
  22. package/app/mail/search.ts +1 -1
  23. package/app/share/[id]/page.tsx +5 -3
  24. package/app/share/[id]/share.module.css +5 -5
  25. package/lib/email-html.ts +9 -1
  26. package/lib/emails/index.ts +26 -0
  27. package/lib/emails/templates/action.html +47 -0
  28. package/lib/fonts.ts +7 -1
  29. package/lib/mail-provider.ts +50 -5
  30. package/lib/mailbox.ts +303 -34
  31. package/lib/receive.ts +318 -0
  32. package/lib/sent-access.ts +22 -0
  33. package/lib/ses-send.ts +49 -0
  34. package/package.json +2 -2
  35. package/public/brand/README.md +5 -1
  36. package/scripts/sample-files.mjs +208 -28
  37. package/scripts/seed-dev.mjs +69 -26
  38. package/tools/README.md +1 -1
@@ -89,7 +89,7 @@ export function parseQuery(raw: string): ParsedQuery {
89
89
  }
90
90
 
91
91
  /** "7d" / "2w" / "3m" / "1y" -> milliseconds. */
92
- function duration(value: string): number | null {
92
+ export function duration(value: string): number | null {
93
93
  const match = value.match(/^(\d+)\s*([dwmy])$/)
94
94
  if (!match) return null
95
95
  const amount = Number(match[1])
@@ -4,6 +4,8 @@ import { CLIENT_BRAND } from '@/lib/brand.client'
4
4
  import { use, useCallback, useEffect, useState } from 'react'
5
5
  import styles from './share.module.css'
6
6
 
7
+ const brandStyle = { '--brand-accent': CLIENT_BRAND.accent } as React.CSSProperties
8
+
7
9
  type Meta = {
8
10
  filename: string
9
11
  size: number
@@ -90,7 +92,7 @@ export default function SharePage({ params }: { params: Promise<{ id: string }>
90
92
 
91
93
  if (gone) {
92
94
  return (
93
- <main className={styles.wrap}>
95
+ <main className={styles.wrap} style={brandStyle}>
94
96
  <div className={styles.card}>
95
97
  <span className={styles.mark} aria-hidden />
96
98
  <h1 className={styles.title}>This link is no longer available</h1>
@@ -105,7 +107,7 @@ export default function SharePage({ params }: { params: Promise<{ id: string }>
105
107
 
106
108
  if (!meta) {
107
109
  return (
108
- <main className={styles.wrap}>
110
+ <main className={styles.wrap} style={brandStyle}>
109
111
  <div className={styles.card}>
110
112
  <span className={styles.skelMark} />
111
113
  <span className={styles.skelLine} />
@@ -116,7 +118,7 @@ export default function SharePage({ params }: { params: Promise<{ id: string }>
116
118
  }
117
119
 
118
120
  return (
119
- <main className={styles.wrap}>
121
+ <main className={styles.wrap} style={brandStyle}>
120
122
  <div className={styles.card}>
121
123
  <span className={styles.mark} aria-hidden />
122
124
  <p className={styles.eyebrow}>{CLIENT_BRAND.legalName}</p>
@@ -37,7 +37,7 @@
37
37
  width: 38px;
38
38
  height: 28px;
39
39
  margin: 0 auto 18px;
40
- background: #6d28d9;
40
+ background: var(--brand-accent, #6d28d9);
41
41
  mask: url('/brand/mark.png') no-repeat center / contain;
42
42
  -webkit-mask: url('/brand/mark.png') no-repeat center / contain;
43
43
  }
@@ -94,8 +94,8 @@
94
94
  }
95
95
  .field input:focus {
96
96
  outline: none;
97
- border-color: #6d28d9;
98
- box-shadow: 0 0 0 3px rgba(169, 3, 23, .12);
97
+ border-color: var(--brand-accent, #6d28d9);
98
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--brand-accent, #6d28d9) 18%, transparent);
99
99
  }
100
100
 
101
101
  .error {
@@ -110,7 +110,7 @@
110
110
  padding: 12px 18px;
111
111
  border: 1px solid transparent;
112
112
  border-radius: 10px;
113
- background: #6d28d9;
113
+ background: var(--brand-accent, #6d28d9);
114
114
  color: #fff;
115
115
  font: inherit;
116
116
  font-size: 15px;
@@ -118,7 +118,7 @@
118
118
  cursor: pointer;
119
119
  transition: background .16s ease, transform .16s ease;
120
120
  }
121
- .go:hover:not(:disabled) { background: #8e0213; transform: translateY(-1px); }
121
+ .go:hover:not(:disabled) { background: color-mix(in srgb, var(--brand-accent, #6d28d9) 86%, #000); transform: translateY(-1px); }
122
122
  .go:active:not(:disabled) { transform: translateY(0) scale(.99); }
123
123
  .go:disabled { opacity: .55; cursor: default; }
124
124
 
package/lib/email-html.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { lineSpacingOf, paragraphGap } from './fonts'
1
2
  import { BRAND } from './brand'
2
3
  /**
3
4
  * Turn editor HTML into HTML an email client will render.
@@ -32,14 +33,17 @@ const STYLES: Record<string, string> = {
32
33
  img: 'max-width:100%;height:auto;display:block;border:0;',
33
34
  }
34
35
 
35
- export function inlineEmailStyles(html: string, base?: { family?: string; size?: string }): string {
36
+ export function inlineEmailStyles(html: string, base?: { family?: string; size?: string; lineSpacing?: number }): string {
36
37
  const family = base?.family?.replace(/'/g, '').trim()
37
38
  const size = base?.size?.trim()
39
+ const spacing = lineSpacingOf(base)
40
+ const gap = paragraphGap(spacing)
38
41
  const styles = Object.fromEntries(
39
42
  Object.entries(STYLES).map(([tag, style]) => {
40
43
  let adjusted = style
41
44
  if (family) adjusted = adjusted.replace('font-family:Arial,Helvetica,sans-serif;', `font-family:'${family}',Arial,Helvetica,sans-serif;`)
42
45
  if (size && !/^h[123]$/.test(tag)) adjusted = adjusted.replace('font-size:15px;', `font-size:${size};`)
46
+ adjusted = adjusted.replace('line-height:1.65;', `line-height:${spacing};`).replace('margin:0 0 14px;', `margin:0 0 ${gap};`)
43
47
  return [tag, adjusted]
44
48
  }),
45
49
  )
@@ -167,3 +171,7 @@ export function outlookSafeImages(html: string): string {
167
171
  return `<table role="presentation" border="0" cellpadding="0" cellspacing="0"${aligned} style="border-collapse:separate;${outer}"><tr><td style="${cell}">${inner}</td></tr></table>`
168
172
  })
169
173
  }
174
+
175
+ /** The open-tracking pixel this app appends to outgoing mail, at its current and its former path. */
176
+ export const OWN_PIXEL = /<img[^>]*\/api\/(?:dev\/)?mail\/pixel\/[^>]*>/gi
177
+ export const stripOwnPixel = (html: string) => html.replace(OWN_PIXEL, '')
@@ -35,6 +35,7 @@ const TPL_AUTO_REPLY = loadTemplate('auto-reply.html')
35
35
  const TPL_FIELD_ROW = loadTemplate('field-row.html')
36
36
  const TPL_ACADEMY_FOLLOWUP = loadTemplate('academy-followup.html')
37
37
  const TPL_CONTACT_FOLLOWUP = loadTemplate('contact-followup.html')
38
+ const TPL_ACTION = loadTemplate('action.html')
38
39
 
39
40
  // ── Rendering primitives ───────────────────────────────────────────────────
40
41
  /** Escape user-provided values before interpolating into HTML. */
@@ -279,3 +280,28 @@ export function renderContactFollowupEmail(vars: ContactFollowupVars): string {
279
280
  export function contactFollowupSubject(projectType: string): string {
280
281
  return `Re: your project — ${projectType} · ${BRAND.name}`
281
282
  }
283
+
284
+ export type ActionEmailVars = {
285
+ eyebrow: string
286
+ accent: string
287
+ title: string
288
+ body: string
289
+ actionLabel: string
290
+ actionUrl: string
291
+ expiry: string
292
+ footer: string
293
+ }
294
+
295
+ /** One-button transactional mail — resets, invites — in the tenant's own accent. */
296
+ export function renderActionEmail(vars: ActionEmailVars): string {
297
+ return render(TPL_ACTION, {
298
+ eyebrow: escapeHtml(vars.eyebrow),
299
+ accent: escapeHtml(vars.accent),
300
+ title: escapeHtml(vars.title),
301
+ body: escapeHtml(vars.body),
302
+ actionLabel: escapeHtml(vars.actionLabel),
303
+ actionUrl: escapeHtml(vars.actionUrl),
304
+ expiry: escapeHtml(vars.expiry),
305
+ footer: escapeHtml(vars.footer),
306
+ })
307
+ }
@@ -0,0 +1,47 @@
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:40px 24px">
5
+
6
+ <div style="font:500 12px/1 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:{{accent}};letter-spacing:.18em;text-transform:uppercase;margin:0 0 20px">
7
+ {{eyebrow}}
8
+ </div>
9
+
10
+ <div style="border:1px solid #2C1F4A;border-radius:14px;background:#0A0613;padding:32px 30px">
11
+
12
+ <div style="font:700 22px/1.25 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#F5F2FA;margin:0 0 12px">
13
+ {{title}}
14
+ </div>
15
+
16
+ <div style="font:400 15px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#B4AEC6;margin:0 0 26px">
17
+ {{body}}
18
+ </div>
19
+
20
+ <table role="presentation" cellpadding="0" cellspacing="0" style="border-collapse:collapse">
21
+ <tr>
22
+ <td style="border-radius:10px;background:{{accent}}">
23
+ <a href="{{actionUrl}}" style="display:inline-block;padding:13px 26px;font:600 14px/1 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#FFFFFF;text-decoration:none">
24
+ {{actionLabel}}
25
+ </a>
26
+ </td>
27
+ </tr>
28
+ </table>
29
+
30
+ <div style="font:400 13px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#7A7290;margin:26px 0 0;padding:16px 0 0;border-top:1px solid #1E1533">
31
+ {{expiry}}
32
+ </div>
33
+
34
+ <div style="font:400 12px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#5A5170;margin:14px 0 0;word-break:break-all">
35
+ If the button does not open, paste this into your browser:<br />
36
+ <span style="color:#7A7290">{{actionUrl}}</span>
37
+ </div>
38
+
39
+ </div>
40
+
41
+ <div style="font:400 12px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#5A5170;margin:22px 0 0">
42
+ {{footer}}
43
+ </div>
44
+
45
+ </div>
46
+ </body>
47
+ </html>
package/lib/fonts.ts CHANGED
@@ -17,7 +17,13 @@ export function fontFaceCss(fonts: CustomFont[], origin = ''): string {
17
17
 
18
18
  export const FONT_SIZES = ['10px', '11px', '12px', '13px', '14px', '15px', '16px', '18px', '20px', '24px', '28px', '32px']
19
19
 
20
- export type BaseFont = { family: string; size: string }
20
+ export type BaseFont = { family: string; size: string; lineSpacing?: number }
21
21
  export const EMPTY_FONT: BaseFont = { family: '', size: '' }
22
22
 
23
+ export const LINE_SPACINGS = [1, 1.15, 1.3, 1.5, 1.65, 2]
24
+ export const DEFAULT_LINE_SPACING = 1.3
25
+ export const lineSpacingOf = (base?: { lineSpacing?: number } | null) =>
26
+ base?.lineSpacing && Number.isFinite(base.lineSpacing) && base.lineSpacing >= 0.8 && base.lineSpacing <= 3 ? base.lineSpacing : DEFAULT_LINE_SPACING
27
+ export const paragraphGap = (spacing: number) => `${Math.round(spacing * 6)}px`
28
+
23
29
  export const fontStack = (family: string) => (family ? `'${family.replace(/'/g, '')}',Arial,Helvetica,sans-serif` : 'Arial,Helvetica,sans-serif')
@@ -3,11 +3,14 @@
3
3
  * file speaks these types; everything vendor-specific lives below. Switching providers
4
4
  * is then a config change plus one adapter, not a rewrite.
5
5
  *
6
- * Selected with MAIL_PROVIDER=brevo|resend (defaults to resend so nothing changes until
7
- * the switch is deliberate).
6
+ * Selected with MAIL_PROVIDER=ses|brevo|resend (defaults to resend so nothing changes
7
+ * until the switch is deliberate).
8
8
  */
9
9
 
10
- export type MailProvider = 'resend' | 'brevo'
10
+ import { randomBytes } from 'node:crypto'
11
+ import { sesSendRaw } from './ses-send'
12
+
13
+ export type MailProvider = 'resend' | 'brevo' | 'ses'
11
14
 
12
15
  export type SendAttachment = { filename: string; content: string; contentType?: string }
13
16
 
@@ -45,7 +48,9 @@ export type NormalizedInbound = {
45
48
  }
46
49
 
47
50
  export function activeProvider(): MailProvider {
48
- return process.env.MAIL_PROVIDER === 'brevo' ? 'brevo' : 'resend'
51
+ const configured = process.env.MAIL_PROVIDER
52
+ if (configured === 'brevo' || configured === 'ses') return configured
53
+ return 'resend'
49
54
  }
50
55
 
51
56
  function splitAddress(raw: string): { email: string; name?: string } {
@@ -110,8 +115,48 @@ async function sendViaBrevo(payload: SendPayload): Promise<SendResult> {
110
115
  return { id: result.messageId ? String(result.messageId).replace(/^<|>$/g, '') : null }
111
116
  }
112
117
 
118
+ function encodeHeaderWord(value: string): string {
119
+ return /^[\x20-\x7E]*$/.test(value) ? value : `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`
120
+ }
121
+
122
+ function buildRawMime(payload: SendPayload): string {
123
+ const boundary = `nc_${randomBytes(12).toString('hex')}`
124
+ const sender = payload.fromName ? `${encodeHeaderWord(payload.fromName)} <${payload.from}>` : payload.from
125
+ const lines = [
126
+ `From: ${sender}`,
127
+ `To: ${payload.to.join(', ')}`,
128
+ ...(payload.cc?.length ? [`Cc: ${payload.cc.join(', ')}`] : []),
129
+ ...(payload.replyTo ? [`Reply-To: ${payload.replyTo}`] : []),
130
+ `Subject: ${encodeHeaderWord(payload.subject)}`,
131
+ 'MIME-Version: 1.0',
132
+ ...Object.entries(payload.headers ?? {}).map(([name, value]) => `${name}: ${value}`),
133
+ ]
134
+
135
+ const text = payload.text ?? ''
136
+ const html = payload.html ?? ''
137
+ if (html && text) {
138
+ lines.push(`Content-Type: multipart/alternative; boundary="${boundary}"`, '',
139
+ `--${boundary}`, 'Content-Type: text/plain; charset=UTF-8', 'Content-Transfer-Encoding: base64', '',
140
+ Buffer.from(text, 'utf8').toString('base64'), '',
141
+ `--${boundary}`, 'Content-Type: text/html; charset=UTF-8', 'Content-Transfer-Encoding: base64', '',
142
+ Buffer.from(html, 'utf8').toString('base64'), '', `--${boundary}--`, '')
143
+ } else {
144
+ lines.push(`Content-Type: text/${html ? 'html' : 'plain'}; charset=UTF-8`, 'Content-Transfer-Encoding: base64', '',
145
+ Buffer.from(html || text, 'utf8').toString('base64'), '')
146
+ }
147
+ return lines.join('\r\n')
148
+ }
149
+
150
+ async function sendViaSes(payload: SendPayload): Promise<SendResult> {
151
+ return { id: await sesSendRaw(buildRawMime(payload)) }
152
+ }
153
+
113
154
  export function sendMail(payload: SendPayload): Promise<SendResult> {
114
- return activeProvider() === 'brevo' ? sendViaBrevo(payload) : sendViaResend(payload)
155
+ switch (activeProvider()) {
156
+ case 'ses': return sendViaSes(payload)
157
+ case 'brevo': return sendViaBrevo(payload)
158
+ default: return sendViaResend(payload)
159
+ }
115
160
  }
116
161
 
117
162
  // ── Receiving ───────────────────────────────────────────────────