@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.
- package/.env.example +110 -0
- package/LICENSE +21 -0
- package/README.md +75 -0
- package/app/api/mail/accessors/route.ts +197 -0
- package/app/api/mail/attachments/route.ts +54 -0
- package/app/api/mail/company-signature/route.ts +36 -0
- package/app/api/mail/contacts/route.ts +13 -0
- package/app/api/mail/emails/[id]/attachments/download/route.ts +69 -0
- package/app/api/mail/emails/[id]/attachments/forward/route.ts +65 -0
- package/app/api/mail/emails/[id]/route.ts +127 -0
- package/app/api/mail/emails/route.ts +116 -0
- package/app/api/mail/events/route.ts +12 -0
- package/app/api/mail/fonts/route.ts +44 -0
- package/app/api/mail/inbox/attachments/download/route.ts +50 -0
- package/app/api/mail/inbox/attachments/forward/route.ts +54 -0
- package/app/api/mail/inbox/attachments/route.ts +99 -0
- package/app/api/mail/inbox/body/route.ts +14 -0
- package/app/api/mail/inbox/counts/route.ts +25 -0
- package/app/api/mail/inbox/route.ts +506 -0
- package/app/api/mail/link-check/route.ts +95 -0
- package/app/api/mail/login/route.ts +38 -0
- package/app/api/mail/logout/route.ts +9 -0
- package/app/api/mail/maintenance/attachments/route.ts +26 -0
- package/app/api/mail/maintenance/bodies/route.ts +30 -0
- package/app/api/mail/maintenance/list-columns/route.ts +25 -0
- package/app/api/mail/maintenance/threads/route.ts +32 -0
- package/app/api/mail/me/route.ts +13 -0
- package/app/api/mail/outgoing-upload/route.ts +37 -0
- package/app/api/mail/password/route.ts +64 -0
- package/app/api/mail/pixel/[id]/route.ts +20 -0
- package/app/api/mail/push/route.ts +42 -0
- package/app/api/mail/render-template/route.ts +58 -0
- package/app/api/mail/request-reset/route.ts +65 -0
- package/app/api/mail/reset/route.ts +46 -0
- package/app/api/mail/send/route.ts +205 -0
- package/app/api/mail/settings/route.ts +35 -0
- package/app/api/mail/share/attachment/route.ts +91 -0
- package/app/api/mail/share/route.ts +130 -0
- package/app/api/mail/signature-logo/route.ts +67 -0
- package/app/api/mail/stash/route.ts +55 -0
- package/app/api/mail/threads/route.ts +18 -0
- package/app/api/mail/upload/route.ts +43 -0
- package/app/api/share/[id]/route.ts +85 -0
- package/app/apple-icon.png +0 -0
- package/app/brand/[file]/route.ts +37 -0
- package/app/globals.css +14 -0
- package/app/icon.png +0 -0
- package/app/layout.tsx +30 -0
- package/app/mail/AccessCheck.tsx +45 -0
- package/app/mail/AttachmentLightbox.tsx +241 -0
- package/app/mail/ConfirmDialog.tsx +88 -0
- package/app/mail/MailSelect.tsx +138 -0
- package/app/mail/RichEditor.tsx +859 -0
- package/app/mail/layout.tsx +20 -0
- package/app/mail/page.module.css +6320 -0
- package/app/mail/page.tsx +7971 -0
- package/app/mail/pwa.ts +191 -0
- package/app/mail/reset/page.tsx +132 -0
- package/app/mail/search.ts +172 -0
- package/app/manifest.ts +21 -0
- package/app/page.tsx +5 -0
- package/app/robots.ts +22 -0
- package/app/share/[id]/page.tsx +166 -0
- package/app/share/[id]/share.module.css +148 -0
- package/eslint.config.mjs +9 -0
- package/lib/accent-ramp.ts +54 -0
- package/lib/attachments.ts +52 -0
- package/lib/brand.client.ts +52 -0
- package/lib/brand.ts +87 -0
- package/lib/d1.ts +64 -0
- package/lib/default-signature.ts +68 -0
- package/lib/dev-auth.ts +222 -0
- package/lib/email-html.test.ts +74 -0
- package/lib/email-html.ts +169 -0
- package/lib/emails/index.ts +281 -0
- package/lib/emails/templates/academy-followup.html +68 -0
- package/lib/emails/templates/auto-reply.html +33 -0
- package/lib/emails/templates/contact-followup.html +58 -0
- package/lib/emails/templates/field-row.html +4 -0
- package/lib/emails/templates/notification.html +23 -0
- package/lib/fonts.ts +23 -0
- package/lib/link-safety.ts +81 -0
- package/lib/mail-provider.ts +183 -0
- package/lib/mailbox.ts +1888 -0
- package/lib/password.ts +71 -0
- package/lib/public-url.ts +14 -0
- package/lib/push.ts +37 -0
- package/lib/r2.ts +157 -0
- package/lib/rate-limit.ts +43 -0
- package/lib/session.test.ts +43 -0
- package/lib/session.ts +80 -0
- package/lib/signature.ts +28 -0
- package/lib/threads.test.ts +96 -0
- package/lib/threads.ts +72 -0
- package/lib/turso.ts +53 -0
- package/next.config.ts +51 -0
- package/package.json +69 -0
- package/public/brand/README.md +35 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon-maskable-512.png +0 -0
- package/public/sw.js +41 -0
- package/scripts/backfill-attachments.mjs +96 -0
- package/scripts/backfill-content-ids.mjs +101 -0
- package/scripts/backfill-list-columns.mjs +80 -0
- package/scripts/offload-bodies.mjs +152 -0
- package/scripts/sample-files.mjs +119 -0
- package/scripts/seed-dev.mjs +240 -0
- package/tools/README.md +18 -0
- package/tools/db-backup.py +158 -0
- package/tools/doh.py +46 -0
- package/tools/import-mbox.py +703 -0
- package/tools/import-takeout.sh +49 -0
- package/tsconfig.json +35 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { CLIENT_BRAND } from '@/lib/brand.client'
|
|
4
|
+
import { use, useCallback, useEffect, useState } from 'react'
|
|
5
|
+
import styles from './share.module.css'
|
|
6
|
+
|
|
7
|
+
type Meta = {
|
|
8
|
+
filename: string
|
|
9
|
+
size: number
|
|
10
|
+
contentType: string | null
|
|
11
|
+
needsPassword: boolean
|
|
12
|
+
expiresAt: string | null
|
|
13
|
+
downloadsLeft: number | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function readableSize(bytes: number): string {
|
|
17
|
+
if (bytes < 1024) return `${bytes} B`
|
|
18
|
+
const units = ['KB', 'MB', 'GB', 'TB']
|
|
19
|
+
let value = bytes / 1024
|
|
20
|
+
let unit = 0
|
|
21
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
22
|
+
value /= 1024
|
|
23
|
+
unit += 1
|
|
24
|
+
}
|
|
25
|
+
return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Enough of a hint to set expectations before the file opens. */
|
|
29
|
+
function kindOf(filename: string, contentType: string | null): string {
|
|
30
|
+
const extension = filename.split('.').pop()?.toLowerCase() ?? ''
|
|
31
|
+
if (/^(jpg|jpeg|png|gif|webp|avif|heic|svg)$/.test(extension)) return 'Image'
|
|
32
|
+
if (/^(mp4|mov|webm|mkv|avi)$/.test(extension)) return 'Video'
|
|
33
|
+
if (/^(mp3|wav|m4a|aac|flac|ogg)$/.test(extension)) return 'Audio'
|
|
34
|
+
if (extension === 'pdf') return 'PDF'
|
|
35
|
+
if (/^(zip|rar|7z|tar|gz)$/.test(extension)) return 'Archive'
|
|
36
|
+
if (/^(doc|docx|rtf|odt)$/.test(extension)) return 'Document'
|
|
37
|
+
if (/^(xls|xlsx|csv|ods)$/.test(extension)) return 'Spreadsheet'
|
|
38
|
+
if (/^(ppt|pptx|key|odp)$/.test(extension)) return 'Presentation'
|
|
39
|
+
return contentType?.split('/')[0] ? `${contentType.split('/')[0]} file` : 'File'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default function SharePage({ params }: { params: Promise<{ id: string }> }) {
|
|
43
|
+
const { id } = use(params)
|
|
44
|
+
const [meta, setMeta] = useState<Meta | null>(null)
|
|
45
|
+
const [gone, setGone] = useState(false)
|
|
46
|
+
const [password, setPassword] = useState('')
|
|
47
|
+
const [busy, setBusy] = useState(false)
|
|
48
|
+
const [error, setError] = useState('')
|
|
49
|
+
const [done, setDone] = useState(false)
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
let cancelled = false
|
|
53
|
+
fetch(`/api/share/${id}`)
|
|
54
|
+
.then(async response => {
|
|
55
|
+
const data = await response.json().catch(() => null)
|
|
56
|
+
if (cancelled) return
|
|
57
|
+
if (!response.ok || !data?.ok) setGone(true)
|
|
58
|
+
else setMeta(data)
|
|
59
|
+
})
|
|
60
|
+
.catch(() => !cancelled && setGone(true))
|
|
61
|
+
return () => {
|
|
62
|
+
cancelled = true
|
|
63
|
+
}
|
|
64
|
+
}, [id])
|
|
65
|
+
|
|
66
|
+
const claim = useCallback(async () => {
|
|
67
|
+
setBusy(true)
|
|
68
|
+
setError('')
|
|
69
|
+
try {
|
|
70
|
+
const response = await fetch(`/api/share/${id}`, {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
headers: { 'Content-Type': 'application/json' },
|
|
73
|
+
body: JSON.stringify({ password }),
|
|
74
|
+
})
|
|
75
|
+
const data = await response.json().catch(() => null)
|
|
76
|
+
if (!response.ok || !data?.ok) {
|
|
77
|
+
setError(data?.error ?? 'That did not work.')
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
setDone(true)
|
|
81
|
+
// Navigating rather than opening a tab: a popup blocker would eat the tab,
|
|
82
|
+
// and the signed URL only lasts two minutes.
|
|
83
|
+
window.location.href = data.url
|
|
84
|
+
} catch {
|
|
85
|
+
setError('Could not reach the server. Try again.')
|
|
86
|
+
} finally {
|
|
87
|
+
setBusy(false)
|
|
88
|
+
}
|
|
89
|
+
}, [id, password])
|
|
90
|
+
|
|
91
|
+
if (gone) {
|
|
92
|
+
return (
|
|
93
|
+
<main className={styles.wrap}>
|
|
94
|
+
<div className={styles.card}>
|
|
95
|
+
<span className={styles.mark} aria-hidden />
|
|
96
|
+
<h1 className={styles.title}>This link is no longer available</h1>
|
|
97
|
+
<p className={styles.sub}>
|
|
98
|
+
It may have expired, been used its allowed number of times, or been withdrawn by
|
|
99
|
+
whoever sent it. Ask them for a new one.
|
|
100
|
+
</p>
|
|
101
|
+
</div>
|
|
102
|
+
</main>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!meta) {
|
|
107
|
+
return (
|
|
108
|
+
<main className={styles.wrap}>
|
|
109
|
+
<div className={styles.card}>
|
|
110
|
+
<span className={styles.skelMark} />
|
|
111
|
+
<span className={styles.skelLine} />
|
|
112
|
+
<span className={styles.skelLineShort} />
|
|
113
|
+
</div>
|
|
114
|
+
</main>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<main className={styles.wrap}>
|
|
120
|
+
<div className={styles.card}>
|
|
121
|
+
<span className={styles.mark} aria-hidden />
|
|
122
|
+
<p className={styles.eyebrow}>{CLIENT_BRAND.legalName}</p>
|
|
123
|
+
<h1 className={styles.title}>{meta.filename}</h1>
|
|
124
|
+
<p className={styles.meta}>
|
|
125
|
+
{kindOf(meta.filename, meta.contentType)} · {readableSize(meta.size)}
|
|
126
|
+
{meta.downloadsLeft != null && ` · ${meta.downloadsLeft} download${meta.downloadsLeft === 1 ? '' : 's'} left`}
|
|
127
|
+
</p>
|
|
128
|
+
|
|
129
|
+
{meta.needsPassword && !done && (
|
|
130
|
+
<label className={styles.field}>
|
|
131
|
+
<span>Password</span>
|
|
132
|
+
<input
|
|
133
|
+
type="password"
|
|
134
|
+
value={password}
|
|
135
|
+
autoFocus
|
|
136
|
+
placeholder="The password you were given"
|
|
137
|
+
onChange={event => setPassword(event.target.value)}
|
|
138
|
+
onKeyDown={event => event.key === 'Enter' && password && claim()}
|
|
139
|
+
/>
|
|
140
|
+
</label>
|
|
141
|
+
)}
|
|
142
|
+
|
|
143
|
+
{error && <p className={styles.error} role="alert">{error}</p>}
|
|
144
|
+
|
|
145
|
+
<button
|
|
146
|
+
type="button"
|
|
147
|
+
className={styles.go}
|
|
148
|
+
disabled={busy || done || (meta.needsPassword && !password)}
|
|
149
|
+
onClick={claim}
|
|
150
|
+
>
|
|
151
|
+
{done ? 'Starting download…' : busy ? 'Checking…' : 'Download'}
|
|
152
|
+
</button>
|
|
153
|
+
|
|
154
|
+
{meta.expiresAt && (
|
|
155
|
+
<p className={styles.foot}>
|
|
156
|
+
Available until {new Date(meta.expiresAt).toLocaleDateString('en-GB', {
|
|
157
|
+
day: 'numeric',
|
|
158
|
+
month: 'long',
|
|
159
|
+
year: 'numeric',
|
|
160
|
+
})}
|
|
161
|
+
</p>
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
164
|
+
</main>
|
|
165
|
+
)
|
|
166
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/* The one screen a recipient sees, and they are not a mailbox holder, so it
|
|
2
|
+
carries its own tokens rather than depending on the app's theme. */
|
|
3
|
+
|
|
4
|
+
@keyframes shareIn {
|
|
5
|
+
from { opacity: 0; transform: translate3d(0, 12px, 0) scale(.98); }
|
|
6
|
+
to { opacity: 1; transform: none; }
|
|
7
|
+
}
|
|
8
|
+
@keyframes sharePulse {
|
|
9
|
+
0%, 100% { opacity: .55; }
|
|
10
|
+
50% { opacity: 1; }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.wrap {
|
|
14
|
+
min-height: 100dvh;
|
|
15
|
+
display: flex;
|
|
16
|
+
align-items: center;
|
|
17
|
+
justify-content: center;
|
|
18
|
+
padding: 24px;
|
|
19
|
+
background: #f7f7fa;
|
|
20
|
+
font-family: Calibri, Carlito, 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.card {
|
|
24
|
+
width: 100%;
|
|
25
|
+
max-width: 420px;
|
|
26
|
+
padding: 34px 32px 28px;
|
|
27
|
+
border: 1px solid #e4e4ec;
|
|
28
|
+
border-radius: 16px;
|
|
29
|
+
background: #fff;
|
|
30
|
+
box-shadow: 0 18px 48px rgba(8, 6, 16, .10);
|
|
31
|
+
text-align: center;
|
|
32
|
+
animation: shareIn .3s cubic-bezier(.2, .7, .3, 1) both;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.mark {
|
|
36
|
+
display: block;
|
|
37
|
+
width: 38px;
|
|
38
|
+
height: 28px;
|
|
39
|
+
margin: 0 auto 18px;
|
|
40
|
+
background: #6d28d9;
|
|
41
|
+
mask: url('/brand/mark.png') no-repeat center / contain;
|
|
42
|
+
-webkit-mask: url('/brand/mark.png') no-repeat center / contain;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.eyebrow {
|
|
46
|
+
margin: 0 0 8px;
|
|
47
|
+
font-size: 11px;
|
|
48
|
+
font-weight: 600;
|
|
49
|
+
letter-spacing: .14em;
|
|
50
|
+
text-transform: uppercase;
|
|
51
|
+
color: #9a96a6;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.title {
|
|
55
|
+
margin: 0;
|
|
56
|
+
font-size: 19px;
|
|
57
|
+
font-weight: 700;
|
|
58
|
+
line-height: 1.35;
|
|
59
|
+
color: #16121f;
|
|
60
|
+
word-break: break-word;
|
|
61
|
+
text-wrap: balance;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.meta {
|
|
65
|
+
margin: 8px 0 0;
|
|
66
|
+
font-size: 13px;
|
|
67
|
+
color: #6e6a7c;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.field {
|
|
71
|
+
display: block;
|
|
72
|
+
margin-top: 22px;
|
|
73
|
+
text-align: left;
|
|
74
|
+
}
|
|
75
|
+
.field span {
|
|
76
|
+
display: block;
|
|
77
|
+
margin-bottom: 6px;
|
|
78
|
+
font-size: 11px;
|
|
79
|
+
font-weight: 600;
|
|
80
|
+
letter-spacing: .08em;
|
|
81
|
+
text-transform: uppercase;
|
|
82
|
+
color: #6e6a7c;
|
|
83
|
+
}
|
|
84
|
+
.field input {
|
|
85
|
+
width: 100%;
|
|
86
|
+
padding: 11px 13px;
|
|
87
|
+
border: 1px solid #d3d3de;
|
|
88
|
+
border-radius: 10px;
|
|
89
|
+
background: #fff;
|
|
90
|
+
font: inherit;
|
|
91
|
+
font-size: 15px;
|
|
92
|
+
color: #16121f;
|
|
93
|
+
transition: border-color .16s ease, box-shadow .16s ease;
|
|
94
|
+
}
|
|
95
|
+
.field input:focus {
|
|
96
|
+
outline: none;
|
|
97
|
+
border-color: #6d28d9;
|
|
98
|
+
box-shadow: 0 0 0 3px rgba(169, 3, 23, .12);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.error {
|
|
102
|
+
margin: 12px 0 0;
|
|
103
|
+
font-size: 13px;
|
|
104
|
+
color: #c0261f;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.go {
|
|
108
|
+
width: 100%;
|
|
109
|
+
margin-top: 20px;
|
|
110
|
+
padding: 12px 18px;
|
|
111
|
+
border: 1px solid transparent;
|
|
112
|
+
border-radius: 10px;
|
|
113
|
+
background: #6d28d9;
|
|
114
|
+
color: #fff;
|
|
115
|
+
font: inherit;
|
|
116
|
+
font-size: 15px;
|
|
117
|
+
font-weight: 600;
|
|
118
|
+
cursor: pointer;
|
|
119
|
+
transition: background .16s ease, transform .16s ease;
|
|
120
|
+
}
|
|
121
|
+
.go:hover:not(:disabled) { background: #8e0213; transform: translateY(-1px); }
|
|
122
|
+
.go:active:not(:disabled) { transform: translateY(0) scale(.99); }
|
|
123
|
+
.go:disabled { opacity: .55; cursor: default; }
|
|
124
|
+
|
|
125
|
+
.foot {
|
|
126
|
+
margin: 16px 0 0;
|
|
127
|
+
font-size: 12px;
|
|
128
|
+
color: #9a96a6;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* Shown while the file's details are still being fetched. */
|
|
132
|
+
.skelMark, .skelLine, .skelLineShort {
|
|
133
|
+
display: block;
|
|
134
|
+
margin: 0 auto;
|
|
135
|
+
border-radius: 8px;
|
|
136
|
+
background: #eceaf1;
|
|
137
|
+
animation: sharePulse 1.4s ease-in-out infinite;
|
|
138
|
+
}
|
|
139
|
+
.skelMark { width: 38px; height: 28px; margin-bottom: 20px; }
|
|
140
|
+
.skelLine { width: 78%; height: 17px; margin-bottom: 10px; }
|
|
141
|
+
.skelLineShort { width: 46%; height: 13px; }
|
|
142
|
+
|
|
143
|
+
@media (prefers-reduced-motion: reduce) {
|
|
144
|
+
.card { animation: none; }
|
|
145
|
+
.skelMark, .skelLine, .skelLineShort { animation: none; }
|
|
146
|
+
.go, .field input { transition: none; }
|
|
147
|
+
.go:hover:not(:disabled), .go:active:not(:disabled) { transform: none; }
|
|
148
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { defineConfig, globalIgnores } from 'eslint/config'
|
|
2
|
+
import nextVitals from 'eslint-config-next/core-web-vitals'
|
|
3
|
+
import nextTs from 'eslint-config-next/typescript'
|
|
4
|
+
|
|
5
|
+
export default defineConfig([
|
|
6
|
+
...nextVitals,
|
|
7
|
+
...nextTs,
|
|
8
|
+
globalIgnores(['.next/**', 'out/**', 'build/**', 'next-env.d.ts', 'public/sw.js']),
|
|
9
|
+
])
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export type ThemeBase = 'dark' | 'dim' | 'light'
|
|
2
|
+
|
|
3
|
+
export const ACCENT_RAMP: Record<ThemeBase, Record<string, number>> = {
|
|
4
|
+
dark: { 100: 92, 200: 84, 300: 73, 400: 62, 500: 52, 600: 44, 700: 37, 800: 25, 900: 16 },
|
|
5
|
+
dim: { 100: 90, 200: 82, 300: 71, 400: 60, 500: 51, 600: 43, 700: 36, 800: 27, 900: 20 },
|
|
6
|
+
light: { 100: 34, 200: 40, 300: 46, 400: 46, 500: 46, 600: 55, 700: 70, 800: 93, 900: 96 },
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function hexToHsl(hex: string): { h: number; s: number; l: number } | null {
|
|
10
|
+
const clean = hex.replace('#', '')
|
|
11
|
+
const full = clean.length === 3 ? clean.split('').map(char => char + char).join('') : clean
|
|
12
|
+
if (!/^[0-9a-fA-F]{6}$/.test(full)) return null
|
|
13
|
+
const red = parseInt(full.slice(0, 2), 16) / 255
|
|
14
|
+
const green = parseInt(full.slice(2, 4), 16) / 255
|
|
15
|
+
const blue = parseInt(full.slice(4, 6), 16) / 255
|
|
16
|
+
const max = Math.max(red, green, blue)
|
|
17
|
+
const min = Math.min(red, green, blue)
|
|
18
|
+
const lightness = (max + min) / 2
|
|
19
|
+
if (max === min) return { h: 0, s: 0, l: lightness * 100 }
|
|
20
|
+
const delta = max - min
|
|
21
|
+
const saturation = delta / (1 - Math.abs(2 * lightness - 1))
|
|
22
|
+
let hue: number
|
|
23
|
+
if (max === red) hue = ((green - blue) / delta) % 6
|
|
24
|
+
else if (max === green) hue = (blue - red) / delta + 2
|
|
25
|
+
else hue = (red - green) / delta + 4
|
|
26
|
+
return { h: (hue * 60 + 360) % 360, s: saturation * 100, l: lightness * 100 }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The accent scale for one base. The 500 stop is the colour every primary control is
|
|
31
|
+
* painted in, so it is the configured accent exactly rather than a shade near it; the
|
|
32
|
+
* other stops slide by the same amount so the scale keeps its shape.
|
|
33
|
+
*/
|
|
34
|
+
export function accentScale(hex: string, base: ThemeBase = 'light'): Record<string, string> {
|
|
35
|
+
const hsl = hexToHsl(hex)
|
|
36
|
+
if (!hsl) return {}
|
|
37
|
+
const saturation = Math.max(28, Math.min(92, hsl.s))
|
|
38
|
+
const shift = hsl.l - ACCENT_RAMP[base][500]
|
|
39
|
+
const scale: Record<string, string> = {}
|
|
40
|
+
for (const [stop, lightness] of Object.entries(ACCENT_RAMP[base])) {
|
|
41
|
+
const shifted = Math.max(4, Math.min(97, lightness + shift))
|
|
42
|
+
scale[`--nc-violet-${stop}`] =
|
|
43
|
+
stop === '500'
|
|
44
|
+
? `hsl(${hsl.h.toFixed(0)} ${hsl.s.toFixed(0)}% ${hsl.l.toFixed(0)}%)`
|
|
45
|
+
: `hsl(${hsl.h.toFixed(0)} ${saturation.toFixed(0)}% ${shifted.toFixed(0)}%)`
|
|
46
|
+
}
|
|
47
|
+
return scale
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function accentRampCss(hex: string, base: ThemeBase = 'light'): string {
|
|
51
|
+
return Object.entries(accentScale(hex, base))
|
|
52
|
+
.map(([name, value]) => `${name}:${value}`)
|
|
53
|
+
.join(';')
|
|
54
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which files are part of the message and which are attached to it.
|
|
3
|
+
*
|
|
4
|
+
* A signature logo and a footer badge arrive as ordinary MIME parts, so a mailbox that
|
|
5
|
+
* lists everything shows files on a message whose sender attached nothing.
|
|
6
|
+
*
|
|
7
|
+
* A Content-ID alone does not settle it. Forwarding through Outlook stamps one onto
|
|
8
|
+
* every image part, including photographs the sender genuinely attached, so treating
|
|
9
|
+
* any image that has one as decoration hides real files — accident photographs, in the
|
|
10
|
+
* case that found this. The body is the authority: a part is embedded when the message
|
|
11
|
+
* points at it, and that pointer is the whole reason it was given an id.
|
|
12
|
+
*
|
|
13
|
+
* Where the body cannot be read, everything is shown. Listing a signature logo is
|
|
14
|
+
* untidy; hiding a document somebody sent looks like the mail arrived broken.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type FileMeta = {
|
|
18
|
+
filename?: string
|
|
19
|
+
contentType?: string
|
|
20
|
+
size?: number
|
|
21
|
+
key?: string
|
|
22
|
+
contentId?: string
|
|
23
|
+
[extra: string]: unknown
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Every `cid:` the body points at, lowercased and unwrapped. */
|
|
27
|
+
export function referencedCids(html: string | null | undefined): Set<string> {
|
|
28
|
+
const found = new Set<string>()
|
|
29
|
+
if (!html) return found
|
|
30
|
+
for (const match of html.matchAll(/cid:([^"'\s>)\\]+)/gi)) {
|
|
31
|
+
found.add(match[1].replace(/^<|>$/g, '').trim().toLowerCase())
|
|
32
|
+
}
|
|
33
|
+
return found
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Only images are ever hidden, and only when the body draws them. A Content-ID on a PDF
|
|
38
|
+
* means a sender referenced a real document, which is still something they attached.
|
|
39
|
+
*/
|
|
40
|
+
export function isEmbedded(file: FileMeta, referenced: Set<string>): boolean {
|
|
41
|
+
if (referenced.size === 0) return false
|
|
42
|
+
const contentId = typeof file.contentId === 'string' ? file.contentId.replace(/^<|>$/g, '').trim().toLowerCase() : ''
|
|
43
|
+
if (!contentId) return false
|
|
44
|
+
if (!String(file.contentType ?? '').toLowerCase().startsWith('image/')) return false
|
|
45
|
+
return referenced.has(contentId)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The files a reader should be offered, in their original order. */
|
|
49
|
+
export function attachedFiles<T extends FileMeta>(files: T[], html?: string | null): T[] {
|
|
50
|
+
const referenced = referencedCids(html)
|
|
51
|
+
return files.filter(file => !isEmbedded(file, referenced))
|
|
52
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const name = process.env.NEXT_PUBLIC_BRAND_NAME?.trim() || 'Mailbox'
|
|
2
|
+
const domain = process.env.NEXT_PUBLIC_MAIL_DOMAIN?.trim().toLowerCase() || 'example.com'
|
|
3
|
+
const website = (process.env.NEXT_PUBLIC_BRAND_WEBSITE?.trim() || domain).replace(/^https?:\/\//, '').replace(/\/$/, '')
|
|
4
|
+
const publicUrl = (process.env.NEXT_PUBLIC_MAIL_PUBLIC_URL?.trim() || `https://mail.${domain}`).replace(/\/$/, '')
|
|
5
|
+
|
|
6
|
+
const legalName = process.env.NEXT_PUBLIC_BRAND_LEGAL_NAME?.trim() || name
|
|
7
|
+
|
|
8
|
+
export const CLIENT_BRAND = {
|
|
9
|
+
name,
|
|
10
|
+
legalName,
|
|
11
|
+
address: process.env.NEXT_PUBLIC_BRAND_ADDRESS?.trim() || '',
|
|
12
|
+
tel: process.env.NEXT_PUBLIC_BRAND_TEL?.trim() || '',
|
|
13
|
+
regulatory: process.env.NEXT_PUBLIC_BRAND_REGULATORY?.trim() || '',
|
|
14
|
+
disclaimer:
|
|
15
|
+
process.env.NEXT_PUBLIC_BRAND_DISCLAIMER?.trim() ||
|
|
16
|
+
`DISCLAIMER: The information contained in this e-mail is proprietary to ${legalName} and is intended only for the individual or entity to which it is addressed. It may contain information that is privileged, confidential or exempt from disclosure under applicable law. If you are not the intended recipient, any use, distribution, transmission, printing, copying or dissemination of this information is strictly prohibited. If you have received this communication in error, please delete it and notify us.`,
|
|
17
|
+
colors: {
|
|
18
|
+
accent: process.env.NEXT_PUBLIC_BRAND_COLOR_ACCENT?.trim() || 'rgb(109, 40, 217)',
|
|
19
|
+
link: process.env.NEXT_PUBLIC_BRAND_COLOR_LINK?.trim() || 'rgb(17, 85, 204)',
|
|
20
|
+
muted: process.env.NEXT_PUBLIC_BRAND_COLOR_MUTED?.trim() || 'rgb(107, 114, 128)',
|
|
21
|
+
},
|
|
22
|
+
slug: name.toLowerCase().replace(/[^a-z0-9]+/g, '') || 'mailbox',
|
|
23
|
+
domain,
|
|
24
|
+
website,
|
|
25
|
+
websiteUrl: `https://${website}`,
|
|
26
|
+
publicUrl,
|
|
27
|
+
markUrl: process.env.NEXT_PUBLIC_BRAND_MARK_URL?.trim() || `${publicUrl}/icon-192.png`,
|
|
28
|
+
accent: process.env.NEXT_PUBLIC_BRAND_ACCENT?.trim() || '#6d28d9',
|
|
29
|
+
chromeMarkUrl: process.env.NEXT_PUBLIC_BRAND_CHROME_MARK_URL?.trim() || '/brand/mark.png',
|
|
30
|
+
signatureMark: {
|
|
31
|
+
border: process.env.NEXT_PUBLIC_BRAND_SIGNATURE_MARK_BORDER?.trim() || 'none',
|
|
32
|
+
radius: process.env.NEXT_PUBLIC_BRAND_SIGNATURE_MARK_RADIUS?.trim() || '0',
|
|
33
|
+
},
|
|
34
|
+
addressDomains: (process.env.NEXT_PUBLIC_MAIL_ADDRESS_DOMAINS ?? '')
|
|
35
|
+
.split(',')
|
|
36
|
+
.map(value => value.trim().toLowerCase())
|
|
37
|
+
.filter(Boolean),
|
|
38
|
+
addresses: (process.env.NEXT_PUBLIC_MAIL_ADDRESSES ?? '')
|
|
39
|
+
.split(',')
|
|
40
|
+
.map(value => value.trim().toLowerCase())
|
|
41
|
+
.filter(Boolean),
|
|
42
|
+
} as const
|
|
43
|
+
|
|
44
|
+
export const LS = (key: string) => `${CLIENT_BRAND.slug}_mail_${key}`
|
|
45
|
+
|
|
46
|
+
/** Browser twin of signatureMarkStyle in lib/brand.ts; the two must agree or the preview lies about the sent mail. */
|
|
47
|
+
export function clientSignatureMarkStyle(width: number): string {
|
|
48
|
+
const { border, radius } = CLIENT_BRAND.signatureMark
|
|
49
|
+
const colour = border === 'accent' ? CLIENT_BRAND.accent : border
|
|
50
|
+
const frame = !colour || colour === 'none' ? 'border:0;' : `padding:8px;border:1px solid ${colour};border-radius:${radius}px;`
|
|
51
|
+
return `display:block;width:${width}px;height:auto;${frame}`
|
|
52
|
+
}
|
package/lib/brand.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export type MailRole = 'admin' | 'member'
|
|
2
|
+
export type MailSeat = { email: string; address: string; name: string; role: MailRole }
|
|
3
|
+
|
|
4
|
+
const env = (key: string, fallback = '') => (process.env[key] ?? fallback).trim()
|
|
5
|
+
|
|
6
|
+
function json<T>(key: string, fallback: T): T {
|
|
7
|
+
const raw = process.env[key]
|
|
8
|
+
if (!raw || !raw.trim()) return fallback
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(raw) as T
|
|
11
|
+
} catch {
|
|
12
|
+
return fallback
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const domain = env('MAIL_ADDRESS_DOMAIN', 'example.com').toLowerCase()
|
|
17
|
+
const name = env('BRAND_NAME', 'Mailbox')
|
|
18
|
+
const website = env('BRAND_WEBSITE', domain).replace(/^https?:\/\//, '').replace(/\/$/, '')
|
|
19
|
+
const publicUrl = env('MAIL_PUBLIC_URL', `https://mail.${domain}`).replace(/\/$/, '')
|
|
20
|
+
|
|
21
|
+
export const BRAND = {
|
|
22
|
+
name,
|
|
23
|
+
legalName: env('BRAND_LEGAL_NAME', name),
|
|
24
|
+
domain,
|
|
25
|
+
website,
|
|
26
|
+
websiteUrl: `https://${website}`,
|
|
27
|
+
publicUrl,
|
|
28
|
+
logoUrl: env('BRAND_LOGO_URL', `${publicUrl}/brand/mark-email.png`),
|
|
29
|
+
markUrl: env('BRAND_MARK_URL', `${publicUrl}/icon-192.png`),
|
|
30
|
+
address: env('BRAND_ADDRESS'),
|
|
31
|
+
tel: env('BRAND_TEL'),
|
|
32
|
+
regulatory: env('BRAND_REGULATORY'),
|
|
33
|
+
disclaimer: env(
|
|
34
|
+
'BRAND_DISCLAIMER',
|
|
35
|
+
`DISCLAIMER: The information contained in this e-mail is proprietary to ${env('BRAND_LEGAL_NAME', name)} and is intended only for the individual or entity to which it is addressed. It may contain information that is privileged, confidential or exempt from disclosure under applicable law. If you are not the intended recipient, any use, distribution, transmission, printing, copying or dissemination of this information is strictly prohibited. If you have received this communication in error, please delete it and notify us.`,
|
|
36
|
+
),
|
|
37
|
+
colors: {
|
|
38
|
+
accent: env('BRAND_COLOR_ACCENT', 'rgb(109, 40, 217)'),
|
|
39
|
+
link: env('BRAND_COLOR_LINK', 'rgb(17, 85, 204)'),
|
|
40
|
+
muted: env('BRAND_COLOR_MUTED', 'rgb(107, 114, 128)'),
|
|
41
|
+
},
|
|
42
|
+
chromeMarkUrl: env('BRAND_CHROME_MARK_URL', '/brand/mark.png'),
|
|
43
|
+
signatureMark: {
|
|
44
|
+
border: env('BRAND_SIGNATURE_MARK_BORDER', 'none'),
|
|
45
|
+
radius: env('BRAND_SIGNATURE_MARK_RADIUS', '0'),
|
|
46
|
+
},
|
|
47
|
+
iconUrl: env('BRAND_ICON_URL'),
|
|
48
|
+
appleIconUrl: env('BRAND_APPLE_ICON_URL', env('BRAND_ICON_URL')),
|
|
49
|
+
accentHex: env('BRAND_ACCENT_HEX', env('NEXT_PUBLIC_BRAND_ACCENT', '#6d28d9')),
|
|
50
|
+
supportEmail: env('MAIL_SUPPORT_EMAIL', `info@${domain}`),
|
|
51
|
+
vapidSubject: env('VAPID_SUBJECT', `mailto:info@${domain}`),
|
|
52
|
+
} as const
|
|
53
|
+
|
|
54
|
+
const addressDomains: string[] = (() => {
|
|
55
|
+
const extra = env('MAIL_ADDRESS_DOMAINS')
|
|
56
|
+
.split(',')
|
|
57
|
+
.map(entry => entry.trim().toLowerCase())
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
const preferred = env('MAIL_DEFAULT_ADDRESS_DOMAIN').toLowerCase() || domain
|
|
60
|
+
return [...new Set([preferred, domain, ...extra])]
|
|
61
|
+
})()
|
|
62
|
+
|
|
63
|
+
export const ADDRESS_DOMAINS = addressDomains
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A mailbox a fresh checkout can sign into, so the package can be run and tried with no
|
|
67
|
+
* configuration at all. Its password is its own address, so it is never seeded into a
|
|
68
|
+
* production deployment.
|
|
69
|
+
*/
|
|
70
|
+
const DEV_SEAT: MailSeat = { email: `test@${domain}`, address: `test@${domain}`, name: 'Test Account', role: 'admin' }
|
|
71
|
+
|
|
72
|
+
export const MAIL_SEATS: MailSeat[] = (() => {
|
|
73
|
+
const configured = json<MailSeat[]>('MAIL_SEATS', [])
|
|
74
|
+
if (process.env.NODE_ENV === 'production') return configured
|
|
75
|
+
return configured.some(seat => seat.email.toLowerCase() === DEV_SEAT.email) ? configured : [...configured, DEV_SEAT]
|
|
76
|
+
})()
|
|
77
|
+
export const ADDRESS_ALIASES: Record<string, string> = json<Record<string, string>>('MAIL_ADDRESS_ALIASES', {})
|
|
78
|
+
|
|
79
|
+
export const brandSlug = BRAND.name.toLowerCase().replace(/[^a-z0-9]+/g, '')
|
|
80
|
+
|
|
81
|
+
/** Inline style for the mark in a signature: a frame in the accent when the tenant asks for one, nothing otherwise. */
|
|
82
|
+
export function signatureMarkStyle(width: number): string {
|
|
83
|
+
const { border, radius } = BRAND.signatureMark
|
|
84
|
+
const colour = border === 'accent' ? BRAND.colors.accent : border
|
|
85
|
+
const frame = !colour || colour === 'none' ? 'border:0;' : `padding:8px;border:1px solid ${colour};border-radius:${radius}px;`
|
|
86
|
+
return `display:block;width:${width}px;height:auto;${frame}`
|
|
87
|
+
}
|
package/lib/d1.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare D1 over the REST API, shaped like the `neon()` tagged-template client so
|
|
3
|
+
* query call sites read the same. The app runs on Vercel, so there is no Worker binding
|
|
4
|
+
* available — every statement is an HTTPS round-trip to Cloudflare.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
type Param = string | number | null
|
|
8
|
+
export type D1Sql = (strings: TemplateStringsArray, ...values: unknown[]) => Promise<Record<string, unknown>[]>
|
|
9
|
+
|
|
10
|
+
const API = 'https://api.cloudflare.com/client/v4'
|
|
11
|
+
|
|
12
|
+
function config() {
|
|
13
|
+
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID
|
|
14
|
+
const databaseId = process.env.D1_DATABASE_ID
|
|
15
|
+
const token = process.env.CLOUDFLARE_D1_TOKEN
|
|
16
|
+
if (!accountId || !databaseId || !token) {
|
|
17
|
+
throw new Error('CLOUDFLARE_ACCOUNT_ID, D1_DATABASE_ID and CLOUDFLARE_D1_TOKEN must be configured')
|
|
18
|
+
}
|
|
19
|
+
return { accountId, databaseId, token }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** SQLite has no boolean or object types — normalise before binding. */
|
|
23
|
+
function toParam(value: unknown): Param {
|
|
24
|
+
if (value === undefined || value === null) return null
|
|
25
|
+
if (typeof value === 'boolean') return value ? 1 : 0
|
|
26
|
+
if (typeof value === 'number') return value
|
|
27
|
+
if (value instanceof Date) return value.toISOString()
|
|
28
|
+
if (typeof value === 'object') return JSON.stringify(value)
|
|
29
|
+
return String(value)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function d1Query(sql: string, params: unknown[] = []): Promise<Record<string, unknown>[]> {
|
|
33
|
+
const { accountId, databaseId, token } = config()
|
|
34
|
+
const response = await fetch(`${API}/accounts/${accountId}/d1/database/${databaseId}/query`, {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
37
|
+
body: JSON.stringify({ sql, params: params.map(toParam) }),
|
|
38
|
+
})
|
|
39
|
+
const payload = (await response.json()) as {
|
|
40
|
+
success?: boolean
|
|
41
|
+
errors?: Array<{ message?: string }>
|
|
42
|
+
result?: Array<{ results?: Record<string, unknown>[]; success?: boolean; error?: string }>
|
|
43
|
+
}
|
|
44
|
+
if (!response.ok || !payload.success) {
|
|
45
|
+
const detail = payload.errors?.map(entry => entry.message).filter(Boolean).join('; ') || `HTTP ${response.status}`
|
|
46
|
+
throw new Error(`D1 query failed: ${detail}`)
|
|
47
|
+
}
|
|
48
|
+
const first = payload.result?.[0]
|
|
49
|
+
if (first?.error) throw new Error(`D1 query failed: ${first.error}`)
|
|
50
|
+
return first?.results ?? []
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Tagged-template entry point: interpolations become bound `?` parameters. */
|
|
54
|
+
export function d1(): D1Sql {
|
|
55
|
+
return (strings, ...values) => d1Query(strings.raw.join('?'), values)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Run several statements in order — used for schema setup. */
|
|
59
|
+
export async function d1Batch(statements: string[]): Promise<void> {
|
|
60
|
+
for (const statement of statements) {
|
|
61
|
+
const trimmed = statement.trim()
|
|
62
|
+
if (trimmed) await d1Query(trimmed)
|
|
63
|
+
}
|
|
64
|
+
}
|