ENH - improve mail experience and interactions

This commit is contained in:
DerEchteAlec
2026-08-06 17:39:24 +02:00
parent 5471040dd8
commit 26c4df9181
14 changed files with 2794 additions and 342 deletions
+33 -1
View File
@@ -4,6 +4,9 @@ import type { MailMessage } from '@/types/mail'
import {
buildForwardDraft,
buildReplyDraft,
filterMailAddressInput,
filterMailRecipientInput,
mailPlainText,
normalizeMailAddress,
parseMailRecipients,
} from '@/utils/mail'
@@ -22,6 +25,23 @@ const message: MailMessage = {
}
describe('mail addresses', () => {
it('filters account fields to the supported email character set', () => {
expect(filterMailAddressInput('Al+ex! ä@ifruit.com<script>')).toBe(
'Alex@ifruit.comscript',
)
expect(filterMailAddressInput('sky.user_name-2@ifruit.com')).toBe(
'sky.user_name-2@ifruit.com',
)
})
it('keeps separators but filters recipient-list special characters', () => {
expect(
filterMailRecipientInput(
'alex@ifruit.com, jamie+tag@ifruit.com; müller@ifruit.com',
),
).toBe('alex@ifruit.com, jamietag@ifruit.com; mller@ifruit.com')
})
it('normalizes local parts and the iFruit domain', () => {
expect(normalizeMailAddress(' Sky.User ')).toBe('sky.user@ifruit.com')
expect(normalizeMailAddress('sky.user@ifruit.com')).toBe(
@@ -48,12 +68,24 @@ describe('mail compose helpers', () => {
expect(
buildReplyDraft(message, 'alex@ifruit.com', true).recipients,
).toEqual(['morgan@ifruit.com', 'jamie@ifruit.com'])
expect(buildReplyDraft(message, 'alex@ifruit.com').body).toContain(
'> Meet at Legion Square.',
)
})
it('builds forward content and avoids duplicate subject prefixes', () => {
const forwarded = buildForwardDraft(message)
expect(forwarded.recipients).toEqual([])
expect(forwarded.subject).toBe('Fwd: Plans')
expect(forwarded.body).toContain('From: morgan@ifruit.com')
expect(forwarded.body).toContain('**From:** morgan@ifruit.com')
})
it('turns formatted markdown into a compact mailbox preview', () => {
expect(
mailPlainText('## Update\n\n**Ready** [details](https://ifruit.com).'),
).toBe('Update Ready details.')
expect(mailPlainText('<script>alert(1)</script> Hello')).toBe(
'alert(1) Hello',
)
})
})
+36 -2
View File
@@ -2,6 +2,21 @@ import type { MailComposeDraft, MailMessage } from '@/types/mail'
export const MAIL_DOMAIN = 'ifruit.com'
export const MAIL_MAX_RECIPIENTS = 10
export const MAIL_ADDRESS_INPUT_MAX_LENGTH = 64
export const MAIL_RECIPIENT_INPUT_MAX_LENGTH =
MAIL_MAX_RECIPIENTS * (MAIL_ADDRESS_INPUT_MAX_LENGTH + 2)
export function filterMailAddressInput(value: string): string {
return value
.replace(/[^a-z0-9@._-]/gi, '')
.slice(0, MAIL_ADDRESS_INPUT_MAX_LENGTH)
}
export function filterMailRecipientInput(value: string): string {
return value
.replace(/[^a-z0-9@._,; -]/gi, '')
.slice(0, MAIL_RECIPIENT_INPUT_MAX_LENGTH)
}
export function normalizeMailAddress(value: string): string | null {
const normalized = value.trim().toLocaleLowerCase('en-US')
@@ -43,6 +58,25 @@ export function parseMailRecipients(value: string): string[] | null {
return recipients
}
export function mailPlainText(value: string): string {
return value
.replace(/<[^>]*>/g, ' ')
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/^\s{0,3}(?:#{1,6}|>|[-+*]|\d+\.)\s+/gm, '')
.replace(/[*_~`]/g, '')
.replace(/\s+/g, ' ')
.trim()
}
function quoteMarkdown(value: string): string {
return value
.trim()
.split('\n')
.map((line) => `> ${line}`)
.join('\n')
}
function prefixedSubject(prefix: 'Re' | 'Fwd', subject: string): string {
const clean = subject.trim()
if (new RegExp(`^${prefix}:`, 'i').test(clean)) return clean
@@ -62,7 +96,7 @@ export function buildReplyDraft(
)
return {
body: `\n--- ${message.sender} ---\n${message.body}`,
body: `\n\n> **${message.sender}**\n>\n${quoteMarkdown(message.body)}`,
recipients,
subject: prefixedSubject('Re', message.subject),
}
@@ -70,7 +104,7 @@ export function buildReplyDraft(
export function buildForwardDraft(message: MailMessage): MailComposeDraft {
return {
body: `\n--- Forwarded message ---\nFrom: ${message.sender}\nTo: ${message.recipients.join(', ')}\n\n${message.body}`,
body: `\n\n---\n\n**Forwarded message**\n\n**From:** ${message.sender}\n\n**To:** ${message.recipients.join(', ')}\n\n${message.body}`,
recipients: [],
subject: prefixedSubject('Fwd', message.subject),
}
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import {
clampMailSwipeOffset,
MAIL_SWIPE_ACTION_WIDTH,
resolveMailSwipeAction,
resolveMailSwipeAxis,
} from '@/utils/mailSwipe'
describe('mail swipe gestures', () => {
it('waits for a clear horizontal or vertical direction', () => {
expect(resolveMailSwipeAxis(5, 2)).toBeNull()
expect(resolveMailSwipeAxis(24, 5)).toBe('horizontal')
expect(resolveMailSwipeAxis(10, 18)).toBe('vertical')
})
it('limits horizontal movement and blocks unavailable directions', () => {
expect(clampMailSwipeOffset(70, true, true)).toBe(70)
expect(clampMailSwipeOffset(70, false, true)).toBe(0)
expect(clampMailSwipeOffset(-70, true, false)).toBe(0)
expect(clampMailSwipeOffset(-240, true, true)).toBeGreaterThan(
-MAIL_SWIPE_ACTION_WIDTH - 19,
)
})
it('only commits an action after crossing its threshold', () => {
expect(resolveMailSwipeAction(63, true, true)).toBeNull()
expect(resolveMailSwipeAction(64, true, true)).toBe('read')
expect(resolveMailSwipeAction(-64, true, true)).toBe('delete')
expect(resolveMailSwipeAction(80, false, true)).toBeNull()
})
})
+48
View File
@@ -0,0 +1,48 @@
export type MailSwipeAction = 'delete' | 'read'
export type MailSwipeAxis = 'horizontal' | 'vertical'
export const MAIL_SWIPE_ACTION_WIDTH = 84
export const MAIL_SWIPE_AXIS_LOCK = 8
export const MAIL_SWIPE_TRIGGER = 64
export function resolveMailSwipeAxis(
deltaX: number,
deltaY: number,
): MailSwipeAxis | null {
const horizontalDistance = Math.abs(deltaX)
const verticalDistance = Math.abs(deltaY)
if (Math.max(horizontalDistance, verticalDistance) < MAIL_SWIPE_AXIS_LOCK) {
return null
}
return horizontalDistance > verticalDistance * 1.15
? 'horizontal'
: 'vertical'
}
export function clampMailSwipeOffset(
deltaX: number,
canRead: boolean,
canDelete: boolean,
): number {
if ((deltaX > 0 && !canRead) || (deltaX < 0 && !canDelete)) return 0
const direction = Math.sign(deltaX)
const distance = Math.abs(deltaX)
const resistedDistance =
distance <= MAIL_SWIPE_ACTION_WIDTH
? distance
: MAIL_SWIPE_ACTION_WIDTH + (distance - MAIL_SWIPE_ACTION_WIDTH) * 0.18
return direction * Math.min(resistedDistance, MAIL_SWIPE_ACTION_WIDTH + 18)
}
export function resolveMailSwipeAction(
offset: number,
canRead: boolean,
canDelete: boolean,
): MailSwipeAction | null {
if (canRead && offset >= MAIL_SWIPE_TRIGGER) return 'read'
if (canDelete && offset <= -MAIL_SWIPE_TRIGGER) return 'delete'
return null
}