@novacraft-engineering/mailbox 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -2
- package/app/api/mail/accessors/route.ts +28 -24
- package/app/api/mail/emails/[id]/attachments/download/route.ts +3 -1
- package/app/api/mail/emails/[id]/attachments/forward/route.ts +3 -1
- package/app/api/mail/emails/[id]/route.ts +17 -9
- package/app/api/mail/emails/route.ts +35 -4
- package/app/api/mail/inbox/counts/route.ts +5 -4
- package/app/api/mail/inbox/route.ts +41 -262
- package/app/api/mail/maintenance/received/route.ts +64 -0
- package/app/api/mail/pixel/[id]/route.ts +8 -2
- package/app/api/mail/request-reset/route.ts +29 -25
- package/app/api/mail/signature-logo/route.ts +33 -8
- package/app/api/mail/threads/route.ts +1 -1
- package/app/brand/[file]/placeholder.ts +70 -0
- package/app/brand/[file]/route.ts +7 -1
- package/app/layout.tsx +10 -2
- package/app/mail/MailSelect.tsx +14 -3
- package/app/mail/RichEditor.tsx +168 -52
- package/app/mail/Ticker.tsx +40 -0
- package/app/mail/page.module.css +624 -70
- package/app/mail/page.tsx +1059 -244
- package/app/mail/search.ts +1 -1
- package/app/share/[id]/page.tsx +5 -3
- package/app/share/[id]/share.module.css +5 -5
- package/lib/email-html.ts +9 -1
- package/lib/emails/index.ts +26 -0
- package/lib/emails/templates/action.html +47 -0
- package/lib/fonts.ts +7 -1
- package/lib/mail-provider.ts +50 -5
- package/lib/mailbox.ts +303 -34
- package/lib/receive.ts +318 -0
- package/lib/sent-access.ts +22 -0
- package/lib/ses-send.ts +49 -0
- package/package.json +2 -2
- package/public/brand/README.md +5 -1
- package/scripts/sample-files.mjs +208 -28
- package/scripts/seed-dev.mjs +69 -26
- package/tools/README.md +1 -1
package/app/mail/search.ts
CHANGED
|
@@ -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])
|
package/app/share/[id]/page.tsx
CHANGED
|
@@ -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
|
|
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: #
|
|
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, '')
|
package/lib/emails/index.ts
CHANGED
|
@@ -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')
|
package/lib/mail-provider.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ───────────────────────────────────────────────────
|