@novacraft-engineering/mailbox 0.4.26 → 0.4.27
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/app/api/mail/send/route.ts +3 -0
- package/app/mail/page.module.css +29 -9
- package/app/mail/page.tsx +26 -4
- package/app/mail/scale.test.ts +17 -0
- package/app/mail/scale.ts +15 -0
- package/lib/mail-provider.ts +1 -1
- package/lib/scheduled.ts +4 -3
- package/package.json +1 -1
|
@@ -203,6 +203,9 @@ export async function POST(req: Request) {
|
|
|
203
203
|
text: body.text,
|
|
204
204
|
attachments,
|
|
205
205
|
...(inReplyToId ? { headers: { 'In-Reply-To': inReplyToId, References: inReplyToId } } : {}),
|
|
206
|
+
// A person's own message is already in their Sent folder; the archive is for mail the
|
|
207
|
+
// app sends on its own behalf, which no one would otherwise see.
|
|
208
|
+
skipArchive: true,
|
|
206
209
|
})
|
|
207
210
|
} catch (sendError) {
|
|
208
211
|
return NextResponse.json({ ok: false, error: (sendError as Error).message }, { status: 502 })
|
package/app/mail/page.module.css
CHANGED
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
--thread-pad: 14px 16px;
|
|
93
93
|
--thread-inset: 12px;
|
|
94
94
|
grid-template-columns: var(--rail-w) var(--list-w) 1fr;
|
|
95
|
-
grid-template-rows: 100dvh;
|
|
95
|
+
grid-template-rows: calc(100dvh / var(--ui-zoom, 1));
|
|
96
96
|
/* This element carries the theme's tokens, so it has to carry the theme's text and
|
|
97
97
|
surface too. Without them anything that does not name a colour of its own inherits
|
|
98
98
|
the document default — black, which only looks deliberate on the light theme. */
|
|
@@ -117,7 +117,11 @@
|
|
|
117
117
|
min-height: 0;
|
|
118
118
|
height: 100%;
|
|
119
119
|
overflow-y: auto;
|
|
120
|
+
scrollbar-width: thin;
|
|
121
|
+
scrollbar-color: color-mix(in srgb, var(--nc-violet-500) 55%, transparent) transparent;
|
|
120
122
|
}
|
|
123
|
+
.rail::-webkit-scrollbar { width: 6px; }
|
|
124
|
+
.rail::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--nc-violet-500) 45%, transparent); border-radius: 999px; }
|
|
121
125
|
.brand {
|
|
122
126
|
display: flex;
|
|
123
127
|
align-items: center;
|
|
@@ -335,6 +339,11 @@
|
|
|
335
339
|
opacity: 0;
|
|
336
340
|
cursor: pointer;
|
|
337
341
|
}
|
|
342
|
+
.scaleNote {
|
|
343
|
+
margin: 8px 0 0;
|
|
344
|
+
color: var(--nc-fg-3);
|
|
345
|
+
font: 400 12px/1.45 var(--nc-font-sans);
|
|
346
|
+
}
|
|
338
347
|
.scaleValue {
|
|
339
348
|
min-width: 48px;
|
|
340
349
|
font-family: var(--nc-font-mono);
|
|
@@ -351,7 +360,8 @@
|
|
|
351
360
|
position: relative;
|
|
352
361
|
width: 64px;
|
|
353
362
|
padding: 20px 10px;
|
|
354
|
-
overflow: hidden;
|
|
363
|
+
overflow-x: hidden;
|
|
364
|
+
overflow-y: auto;
|
|
355
365
|
z-index: 420;
|
|
356
366
|
transition: width var(--dur-base) var(--nc-ease-out), box-shadow var(--dur-base) var(--nc-ease-out);
|
|
357
367
|
}
|
|
@@ -1777,6 +1787,7 @@
|
|
|
1777
1787
|
}
|
|
1778
1788
|
.rteGroup {
|
|
1779
1789
|
display: flex;
|
|
1790
|
+
flex-wrap: wrap;
|
|
1780
1791
|
align-items: center;
|
|
1781
1792
|
gap: 2px;
|
|
1782
1793
|
padding-right: 6px;
|
|
@@ -2084,8 +2095,8 @@
|
|
|
2084
2095
|
position: fixed;
|
|
2085
2096
|
right: 24px;
|
|
2086
2097
|
bottom: 24px;
|
|
2087
|
-
width: min(700px, calc(100vw - 48px));
|
|
2088
|
-
max-height: calc(100dvh - 48px);
|
|
2098
|
+
width: min(700px, calc(100vw / var(--ui-zoom, 1) - 48px));
|
|
2099
|
+
max-height: calc(100dvh / var(--ui-zoom, 1) - 48px);
|
|
2089
2100
|
display: flex;
|
|
2090
2101
|
flex-direction: column;
|
|
2091
2102
|
background: var(--nc-ink-1);
|
|
@@ -2131,7 +2142,16 @@
|
|
|
2131
2142
|
font-size: 11px;
|
|
2132
2143
|
color: var(--nc-fg-3);
|
|
2133
2144
|
}
|
|
2145
|
+
.composeTitle { flex: none; white-space: nowrap; }
|
|
2146
|
+
.composeHead .composeHint {
|
|
2147
|
+
flex: 0 1 auto;
|
|
2148
|
+
min-width: 0;
|
|
2149
|
+
overflow: hidden;
|
|
2150
|
+
text-overflow: ellipsis;
|
|
2151
|
+
white-space: nowrap;
|
|
2152
|
+
}
|
|
2134
2153
|
.composeHeadActions {
|
|
2154
|
+
flex: none;
|
|
2135
2155
|
margin-left: auto;
|
|
2136
2156
|
display: flex;
|
|
2137
2157
|
align-items: center;
|
|
@@ -3068,7 +3088,7 @@
|
|
|
3068
3088
|
.lbScrim { padding: 0; }
|
|
3069
3089
|
.lbShell {
|
|
3070
3090
|
width: 100%;
|
|
3071
|
-
height: 100dvh;
|
|
3091
|
+
height: calc(100dvh / var(--ui-zoom, 1));
|
|
3072
3092
|
border: none;
|
|
3073
3093
|
border-radius: 0;
|
|
3074
3094
|
}
|
|
@@ -4091,7 +4111,7 @@
|
|
|
4091
4111
|
|
|
4092
4112
|
.settingsCard {
|
|
4093
4113
|
width: min(760px, 100%);
|
|
4094
|
-
max-height: calc(100dvh - 48px);
|
|
4114
|
+
max-height: calc(100dvh / var(--ui-zoom, 1) - 48px);
|
|
4095
4115
|
overflow-y: auto;
|
|
4096
4116
|
background: var(--nc-ink-1);
|
|
4097
4117
|
border: 1px solid var(--nc-line-2);
|
|
@@ -4265,7 +4285,7 @@
|
|
|
4265
4285
|
--rail-w: 0px;
|
|
4266
4286
|
--list-w: 0px;
|
|
4267
4287
|
grid-template-columns: 1fr;
|
|
4268
|
-
grid-template-rows: 100dvh;
|
|
4288
|
+
grid-template-rows: calc(100dvh / var(--ui-zoom, 1));
|
|
4269
4289
|
}
|
|
4270
4290
|
.rail {
|
|
4271
4291
|
position: fixed;
|
|
@@ -4297,7 +4317,7 @@
|
|
|
4297
4317
|
.menuBtn { display: inline-flex; }
|
|
4298
4318
|
.fab { display: inline-flex; animation: ncPop .5s var(--nc-ease-spring) .1s backwards; }
|
|
4299
4319
|
.list { padding-bottom: 88px; }
|
|
4300
|
-
.compose { right: 0; bottom: 0; width: 100vw; max-height: 100dvh; border-radius: 0; }
|
|
4320
|
+
.compose { right: 0; bottom: 0; width: calc(100vw / var(--ui-zoom, 1)); max-height: calc(100dvh / var(--ui-zoom, 1)); border-radius: 0; }
|
|
4301
4321
|
}
|
|
4302
4322
|
|
|
4303
4323
|
/* ── Motion ──────────────────────────────────────────────── */
|
|
@@ -5726,7 +5746,7 @@
|
|
|
5726
5746
|
top: 60px;
|
|
5727
5747
|
right: 14px;
|
|
5728
5748
|
z-index: 75;
|
|
5729
|
-
width: min(360px, calc(100vw - 28px));
|
|
5749
|
+
width: min(360px, calc(100vw / var(--ui-zoom, 1) - 28px));
|
|
5730
5750
|
padding: 16px 18px 14px;
|
|
5731
5751
|
border: 1px solid var(--nc-line-1);
|
|
5732
5752
|
border-left: 3px solid var(--nc-violet-500);
|
package/app/mail/page.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import { inlineEmailStyles, htmlToPlainText, dropUnreachableImages, outlookSafeI
|
|
|
13
13
|
import { BUILTIN_FONTS, DEFAULT_LINE_SPACING, EMPTY_FONT, FONT_SIZES, LINE_SPACINGS, fontFaceCss, fontStack, lineSpacingOf, type BaseFont, type CustomFont, paragraphGap } from '@/lib/fonts'
|
|
14
14
|
import MailSelect, { GLYPH } from './MailSelect'
|
|
15
15
|
import { handleComposeTab } from './tabKey'
|
|
16
|
+
import { fittedScale } from './scale'
|
|
16
17
|
import { writingSettingsFrom, type WritingSettings } from './writing/settings'
|
|
17
18
|
import { sendIssues, type SendIssue } from './writing/checks'
|
|
18
19
|
import { firstNameFromAddress, type MailTemplate } from './writing/templates'
|
|
@@ -1847,15 +1848,30 @@ export default function DevMailPage() {
|
|
|
1847
1848
|
return () => window.clearTimeout(timer)
|
|
1848
1849
|
}, [isLoggedIn, prefsLoaded, themePref, accent, themeCustom, readerLayout, apiHeaders])
|
|
1849
1850
|
|
|
1851
|
+
const [windowSize, setWindowSize] = useState({ width: 0, height: 0 })
|
|
1852
|
+
useEffect(() => {
|
|
1853
|
+
const measure = () => setWindowSize({ width: window.innerWidth, height: window.innerHeight })
|
|
1854
|
+
measure()
|
|
1855
|
+
window.addEventListener('resize', measure)
|
|
1856
|
+
return () => window.removeEventListener('resize', measure)
|
|
1857
|
+
}, [])
|
|
1858
|
+
const chosenScale = Math.min(SCALE_MAX, Math.max(SCALE_MIN, Math.round(settings.uiScale || 100)))
|
|
1859
|
+
const appliedScale = fittedScale(chosenScale, windowSize.width, windowSize.height)
|
|
1860
|
+
|
|
1850
1861
|
useEffect(() => {
|
|
1851
|
-
const scale = Math.min(SCALE_MAX, Math.max(SCALE_MIN, Math.round(settings.uiScale || 100)))
|
|
1852
1862
|
// zoom rather than a font size: every measurement in this interface is in pixels, and
|
|
1853
1863
|
// scaling only the type would leave the type in boxes that no longer fit it.
|
|
1854
|
-
document.documentElement.style.zoom =
|
|
1864
|
+
document.documentElement.style.zoom = appliedScale === 100 ? '' : `${appliedScale}%`
|
|
1865
|
+
// A zoomed page still measures the window in unzoomed units, so every full-window
|
|
1866
|
+
// height or width is divided back down by this, or it overshoots the screen by the scale.
|
|
1867
|
+
document.documentElement.style.setProperty('--ui-zoom', String(appliedScale / 100))
|
|
1868
|
+
}, [appliedScale])
|
|
1869
|
+
|
|
1870
|
+
useEffect(() => {
|
|
1855
1871
|
try {
|
|
1856
|
-
localStorage.setItem(LS_SCALE_KEY, String(
|
|
1872
|
+
localStorage.setItem(LS_SCALE_KEY, String(chosenScale))
|
|
1857
1873
|
} catch {}
|
|
1858
|
-
}, [
|
|
1874
|
+
}, [chosenScale])
|
|
1859
1875
|
|
|
1860
1876
|
const chooseLayout = useCallback((layout: 'list' | 'bubbles') => {
|
|
1861
1877
|
setReaderLayout(layout)
|
|
@@ -8713,6 +8729,12 @@ export default function DevMailPage() {
|
|
|
8713
8729
|
/>
|
|
8714
8730
|
<span className={styles.scaleValue}>{settings.uiScale}%</span>
|
|
8715
8731
|
</div>
|
|
8732
|
+
{appliedScale < chosenScale && (
|
|
8733
|
+
<p className={styles.scaleNote} role="status">
|
|
8734
|
+
This window fits up to {appliedScale}%, so that is what it shows. Make the window larger, or pick a
|
|
8735
|
+
smaller size, to use {chosenScale}%.
|
|
8736
|
+
</p>
|
|
8737
|
+
)}
|
|
8716
8738
|
</div>
|
|
8717
8739
|
<div className={styles.settingsField}>
|
|
8718
8740
|
<span>Density</span>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import { fittedScale } from './scale.ts'
|
|
4
|
+
|
|
5
|
+
test('a larger size is held to what the window can fit', () => {
|
|
6
|
+
assert.equal(fittedScale(110, 1440, 790), 110, 'a MacBook Air browser window takes Large')
|
|
7
|
+
assert.equal(fittedScale(125, 1440, 790), 125)
|
|
8
|
+
assert.equal(fittedScale(140, 1440, 790), 131, 'but not 140%: it would leave under 600px of height')
|
|
9
|
+
assert.equal(fittedScale(110, 1366, 650), 108, 'a 1366x768 laptop is held just short of Large')
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('smaller sizes and 100% are always allowed', () => {
|
|
13
|
+
assert.equal(fittedScale(90, 800, 500), 90)
|
|
14
|
+
assert.equal(fittedScale(100, 1024, 600), 100)
|
|
15
|
+
assert.equal(fittedScale(125, 390, 844), 100, 'a phone keeps its own layout at 100%')
|
|
16
|
+
assert.equal(fittedScale(110, 0, 0), 110, 'before the window is known')
|
|
17
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** The smallest room, in the interface's own pixels, the desktop layout still works in. */
|
|
2
|
+
export const SCALE_FIT = { width: 1024, height: 600 }
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The interface size a window can actually take. The page is zoomed rather than reflowed,
|
|
6
|
+
* so the layout switches between desktop and phone on the real window width: zoomed past
|
|
7
|
+
* what the window holds, the desktop layout stays up and its edges fall off the screen.
|
|
8
|
+
* A larger choice is held back to what fits; a smaller one, or 100%, is always allowed,
|
|
9
|
+
* since below that the phone layout takes over.
|
|
10
|
+
*/
|
|
11
|
+
export function fittedScale(chosen: number, width: number, height: number): number {
|
|
12
|
+
if (!width || !height) return chosen
|
|
13
|
+
const fit = Math.floor(100 * Math.min(width / SCALE_FIT.width, height / SCALE_FIT.height))
|
|
14
|
+
return Math.max(Math.min(chosen, fit), Math.min(chosen, 100))
|
|
15
|
+
}
|
package/lib/mail-provider.ts
CHANGED
|
@@ -174,7 +174,7 @@ async function sendViaSes(payload: SendPayload): Promise<SendResult> {
|
|
|
174
174
|
* A mailbox this deployment owns that keeps a copy of everything the app sends on its own
|
|
175
175
|
* behalf. Product mail is invisible otherwise: it leaves for the recipient and no account
|
|
176
176
|
* here ever sees it. Skipped when the archive is already a recipient, so a message never
|
|
177
|
-
* arrives twice.
|
|
177
|
+
* arrives twice, and skipped for anything a person wrote — their Sent folder already has it.
|
|
178
178
|
*/
|
|
179
179
|
function withArchive(payload: SendPayload): SendPayload {
|
|
180
180
|
const archive = (process.env.MAIL_ARCHIVE_BCC ?? '').trim().toLowerCase()
|
package/lib/scheduled.ts
CHANGED
|
@@ -145,9 +145,10 @@ async function dispatchRow(row: Record<string, unknown>): Promise<'sent' | 'fail
|
|
|
145
145
|
try {
|
|
146
146
|
// scheduledAt is deliberately dropped: the wait already happened here.
|
|
147
147
|
const { scheduledAt: _ignored, ...payload } = send.payload
|
|
148
|
-
const result = await sendMail(
|
|
149
|
-
payload.html ? { ...payload, html: absoluteUrls(payload.html, BRAND.publicUrl) } : payload,
|
|
150
|
-
|
|
148
|
+
const result = await sendMail({
|
|
149
|
+
...(payload.html ? { ...payload, html: absoluteUrls(payload.html, BRAND.publicUrl) } : payload),
|
|
150
|
+
skipArchive: true,
|
|
151
|
+
})
|
|
151
152
|
await recordSend(send, result?.id ?? null)
|
|
152
153
|
await sql`
|
|
153
154
|
UPDATE mail_scheduled SET status = 'sent', sent_id = ${result?.id ?? null}, last_error = NULL WHERE id = ${id}`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novacraft-engineering/mailbox",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.27",
|
|
4
4
|
"description": "A shared webmail app: one codebase, one deployment per mailbox. Threads, a rich composer with signatures, attachments on S3-compatible storage, sharing links, full-text search, web push and PWA install.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"webmail",
|