@novacraft-engineering/mailbox 0.4.25 → 0.4.26

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.
@@ -0,0 +1,259 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import styles from '../page.module.css'
5
+ import MailSelect from '../MailSelect'
6
+ import type { WritingSettings } from './settings'
7
+ import { normaliseShortcut, type MailTemplate } from './templates'
8
+
9
+ type Tab = 'writing' | 'checks' | 'templates' | 'dictionary' | 'shortcuts'
10
+
11
+ const SHORTCUTS: Array<[string, string]> = [
12
+ ['Tab', 'Indent, or nest a list item; jumps to the next {field} in a template'],
13
+ ['Tab Tab (quickly)', 'Leave the message for the next control'],
14
+ ['Shift Tab', 'Move a list item back out'],
15
+ ['⌘/Ctrl B · I · U', 'Bold · italic · underline'],
16
+ ['⌘/Ctrl K', 'Add or edit a link'],
17
+ ['⌘/Ctrl Shift 7 · 8', 'Numbered list · bullet list'],
18
+ ['⌘/Ctrl Shift V', 'Paste without formatting'],
19
+ ['⌘/Ctrl F', 'Find and replace in this message'],
20
+ ['⌘/Ctrl Z · Shift Z', 'Undo · redo'],
21
+ ['⌘/Ctrl Enter', 'Send'],
22
+ ['Backspace', 'Straight after an automatic change, puts back what you typed'],
23
+ [';shortcut then Space', 'Insert a saved template'],
24
+ ['- · 1. · > then Space', 'Start a bullet list · numbered list · quote'],
25
+ ]
26
+
27
+ function Toggle({ label, hint, checked, onChange }: { label: string; hint?: string; checked: boolean; onChange: (next: boolean) => void }) {
28
+ return (
29
+ <label className={styles.wpRow}>
30
+ <span className={styles.wpText}>
31
+ <span className={styles.wpLabel}>{label}</span>
32
+ {hint && <span className={styles.wpHint}>{hint}</span>}
33
+ </span>
34
+ <input type="checkbox" className={styles.wpSwitch} checked={checked} onChange={event => onChange(event.target.checked)} />
35
+ </label>
36
+ )
37
+ }
38
+
39
+ export default function WritingPanel({
40
+ settings,
41
+ onChange,
42
+ templates,
43
+ onSaveTemplate,
44
+ onDeleteTemplate,
45
+ onInsertTemplate,
46
+ currentHtml,
47
+ companyWords,
48
+ onCompanyWords,
49
+ isAdmin,
50
+ grammarAvailable,
51
+ onClose,
52
+ }: {
53
+ settings: WritingSettings
54
+ onChange: (next: WritingSettings) => void
55
+ templates: MailTemplate[]
56
+ onSaveTemplate?: (template: MailTemplate) => void
57
+ onDeleteTemplate?: (id: string) => void
58
+ onInsertTemplate: (template: MailTemplate) => void
59
+ currentHtml: string
60
+ companyWords: string[]
61
+ onCompanyWords?: (words: string[]) => void
62
+ isAdmin: boolean
63
+ grammarAvailable: boolean
64
+ onClose: () => void
65
+ }) {
66
+ const [tab, setTab] = useState<Tab>('writing')
67
+ const [templateName, setTemplateName] = useState('')
68
+ const [templateShortcut, setTemplateShortcut] = useState('')
69
+ const [companyDraft, setCompanyDraft] = useState('')
70
+ const set = <K extends keyof WritingSettings>(key: K, value: WritingSettings[K]) => onChange({ ...settings, [key]: value })
71
+
72
+ return (
73
+ <div className={styles.wpPanel} role="dialog" aria-label="Writing tools">
74
+ <div className={styles.wpHead}>
75
+ <span className={styles.wpTitle}>Writing tools</span>
76
+ <button type="button" className={styles.wpClose} onClick={onClose} aria-label="Close">×</button>
77
+ </div>
78
+ <div className={styles.wpTabs} role="tablist">
79
+ {(['writing', 'checks', 'templates', 'dictionary', 'shortcuts'] as Tab[]).map(name => (
80
+ <button key={name} type="button" role="tab" aria-selected={tab === name} className={`${styles.wpTab} ${tab === name ? styles.wpTabOn : ''}`} onClick={() => setTab(name)}>
81
+ {name === 'checks' ? 'Before sending' : name[0].toUpperCase() + name.slice(1)}
82
+ </button>
83
+ ))}
84
+ </div>
85
+
86
+ <div className={styles.wpBody}>
87
+ {tab === 'writing' && (
88
+ <>
89
+ <Toggle label="Capitalise new sentences" hint="After . ? ! and at the start of a paragraph" checked={settings.autoCapitalize} onChange={value => set('autoCapitalize', value)} />
90
+ {settings.autoCapitalize && (
91
+ <div className={styles.wpIndent}>
92
+ <Toggle label="Add a missed space" hint="end.Next → end. Next, as you type" checked={settings.spaceAfterStop} onChange={value => set('spaceAfterStop', value)} />
93
+ </div>
94
+ )}
95
+ <Toggle label='Capital "I"' checked={settings.capitalizeI} onChange={value => set('capitalizeI', value)} />
96
+ <Toggle label="Fix common typos" hint="teh → the, dont → don't" checked={settings.autocorrect} onChange={value => set('autocorrect', value)} />
97
+ <Toggle label="Check spelling" hint="On for new messages; the ✓Aa button turns it off for one" checked={settings.spellcheck} onChange={value => set('spellcheck', value)} />
98
+ {settings.spellcheck && (
99
+ <div className={styles.wpIndent}>
100
+ <label className={styles.wpRow}>
101
+ <span className={styles.wpLabel}>Dictionary</span>
102
+ <MailSelect
103
+ ariaLabel="Dictionary"
104
+ buttonClassName={styles.wpSelect}
105
+ value={settings.spellLanguage}
106
+ options={[
107
+ { value: 'en-GB', label: 'British English' },
108
+ { value: 'en-US', label: 'American English' },
109
+ ]}
110
+ onChange={value => set('spellLanguage', value === 'en-US' ? 'en-US' : 'en-GB')}
111
+ />
112
+ </label>
113
+ <Toggle label="Skip words in CAPITALS" hint="Acronyms and company names" checked={settings.ignoreCapitals} onChange={value => set('ignoreCapitals', value)} />
114
+ <Toggle label="Skip words with numbers" hint="Policy and vehicle numbers" checked={settings.ignoreWithNumbers} onChange={value => set('ignoreWithNumbers', value)} />
115
+ <Toggle label="Flag repeated words" hint='"the the"' checked={settings.repeatedWords} onChange={value => set('repeatedWords', value)} />
116
+ </div>
117
+ )}
118
+ <Toggle
119
+ label="Check grammar"
120
+ hint={grammarAvailable ? 'Checked on this company’s own server' : 'Not set up on this mailbox yet'}
121
+ checked={settings.grammar && grammarAvailable}
122
+ onChange={value => set('grammar', value)}
123
+ />
124
+ <Toggle label="Tab indents" hint="A quick second Tab leaves the message" checked={settings.tabIndent} onChange={value => set('tabIndent', value)} />
125
+ {settings.tabIndent && (
126
+ <label className={`${styles.wpRow} ${styles.wpIndent}`}>
127
+ <span className={styles.wpText}>
128
+ <span className={styles.wpLabel}>Double-Tab speed</span>
129
+ <span className={styles.wpHint}>{settings.doubleTabMs} ms between presses</span>
130
+ </span>
131
+ <input type="range" min={200} max={1000} step={50} value={settings.doubleTabMs} onChange={event => set('doubleTabMs', Number(event.target.value))} />
132
+ </label>
133
+ )}
134
+ <Toggle label="Template shortcuts" hint="Type ;name then Space" checked={settings.templateShortcuts} onChange={value => set('templateShortcuts', value)} />
135
+ <Toggle label="Clean pasted formatting" hint="Strips Word and Excel styling, keeps tables" checked={settings.cleanPaste} onChange={value => set('cleanPaste', value)} />
136
+ <Toggle label="Format amounts" hint="$2500 → $2,500 · USD 1500000 → USD 1,500,000 · ₦, £, €, GHS, KES…" checked={settings.amountFormat} onChange={value => set('amountFormat', value)} />
137
+ {settings.amountFormat && (
138
+ <div className={styles.wpIndent}>
139
+ <Toggle label="N means naira" hint="N1500000 → ₦1,500,000" checked={settings.nairaLetter} onChange={value => set('nairaLetter', value)} />
140
+ <Toggle label="Plain numbers too" hint="1500000 → 1,500,000 · years, phone and account numbers are left alone" checked={settings.numberFormat} onChange={value => set('numberFormat', value)} />
141
+ </div>
142
+ )}
143
+ <Toggle label="Word count" checked={settings.wordCount} onChange={value => set('wordCount', value)} />
144
+ </>
145
+ )}
146
+
147
+ {tab === 'checks' && (
148
+ <>
149
+ <p className={styles.wpNote}>Asked when you press Send. You can always send anyway.</p>
150
+ <Toggle label="Missing attachment" hint='"attached" or "enclosed" with nothing attached' checked={settings.checkAttachment} onChange={value => set('checkAttachment', value)} />
151
+ <Toggle label="Nothing written" hint="Only the signature or the quoted message" checked={settings.checkEmptyBody} onChange={value => set('checkEmptyBody', value)} />
152
+ <Toggle label="Unfilled placeholders" hint="[Name], {policy_no}, XXX" checked={settings.checkPlaceholders} onChange={value => set('checkPlaceholders', value)} />
153
+ <Toggle label="Greeting names someone else" hint='"Dear Mr Okafor" to a different person' checked={settings.checkGreeting} onChange={value => set('checkGreeting', value)} />
154
+ <Toggle label="Many outside recipients" checked={settings.checkExternal} onChange={value => set('checkExternal', value)} />
155
+ {settings.checkExternal && (
156
+ <label className={`${styles.wpRow} ${styles.wpIndent}`}>
157
+ <span className={styles.wpLabel}>Warn from</span>
158
+ <span>
159
+ <input type="number" min={1} max={100} className={styles.wpNumber} value={settings.externalThreshold} onChange={event => set('externalThreshold', Math.max(1, Math.min(100, Number(event.target.value) || 1)))} /> people
160
+ </span>
161
+ </label>
162
+ )}
163
+ </>
164
+ )}
165
+
166
+ {tab === 'templates' && (
167
+ <>
168
+ {templates.length === 0 && <p className={styles.wpNote}>No templates yet. Write one below, then save it.</p>}
169
+ {templates.map(template => (
170
+ <div key={template.id} className={styles.wpTemplate}>
171
+ <button type="button" className={styles.wpTemplateName} onClick={() => onInsertTemplate(template)} title="Insert into this message">
172
+ {template.name}
173
+ {template.shortcut && <span className={styles.wpShortcut}>;{normaliseShortcut(template.shortcut)}</span>}
174
+ </button>
175
+ {onDeleteTemplate && (
176
+ <button type="button" className={styles.wpRemove} onClick={() => onDeleteTemplate(template.id)} aria-label={`Delete ${template.name}`}>×</button>
177
+ )}
178
+ </div>
179
+ ))}
180
+ {onSaveTemplate && (
181
+ <div className={styles.wpSave}>
182
+ <span className={styles.wpLabel}>Save this message as a template</span>
183
+ <span className={styles.wpHint}>Write {'{first_name}'}, {'{date}'} or any {'{field}'}: known ones fill in, Tab jumps to the rest</span>
184
+ <input className={styles.wpInput} placeholder="Name, e.g. Renewal notice" value={templateName} onChange={event => setTemplateName(event.target.value)} />
185
+ <input className={styles.wpInput} placeholder="Shortcut, e.g. renewal" value={templateShortcut} onChange={event => setTemplateShortcut(event.target.value)} />
186
+ <button
187
+ type="button"
188
+ className={styles.wpButton}
189
+ disabled={!templateName.trim() || !currentHtml.replace(/<[^>]+>/g, '').trim()}
190
+ onClick={() => {
191
+ onSaveTemplate({ id: `t${Date.now().toString(36)}`, name: templateName.trim(), shortcut: normaliseShortcut(templateShortcut), html: currentHtml })
192
+ setTemplateName('')
193
+ setTemplateShortcut('')
194
+ }}
195
+ >
196
+ Save template
197
+ </button>
198
+ </div>
199
+ )}
200
+ </>
201
+ )}
202
+
203
+ {tab === 'dictionary' && (
204
+ <>
205
+ <span className={styles.wpLabel}>Your words</span>
206
+ <span className={styles.wpHint}>Added from a red underline with “Add to dictionary”</span>
207
+ <div className={styles.wpChips}>
208
+ {settings.personalWords.length === 0 && <span className={styles.wpHint}>None yet</span>}
209
+ {settings.personalWords.map(word => (
210
+ <span key={word} className={styles.wpChip}>
211
+ {word}
212
+ <button type="button" onClick={() => set('personalWords', settings.personalWords.filter(entry => entry !== word))} aria-label={`Remove ${word}`}>×</button>
213
+ </span>
214
+ ))}
215
+ </div>
216
+ <span className={styles.wpLabel}>Company words</span>
217
+ <span className={styles.wpHint}>{isAdmin ? 'Everyone in the company gets these' : 'Kept by an administrator'}</span>
218
+ <div className={styles.wpChips}>
219
+ {companyWords.length === 0 && <span className={styles.wpHint}>None yet</span>}
220
+ {companyWords.map(word => (
221
+ <span key={word} className={styles.wpChip}>
222
+ {word}
223
+ {isAdmin && onCompanyWords && (
224
+ <button type="button" onClick={() => onCompanyWords(companyWords.filter(entry => entry !== word))} aria-label={`Remove ${word}`}>×</button>
225
+ )}
226
+ </span>
227
+ ))}
228
+ </div>
229
+ {isAdmin && onCompanyWords && (
230
+ <form
231
+ className={styles.wpSave}
232
+ onSubmit={event => {
233
+ event.preventDefault()
234
+ const words = companyDraft.split(/[,\s]+/).map(word => word.trim()).filter(Boolean)
235
+ if (words.length) onCompanyWords([...new Set([...companyWords, ...words])])
236
+ setCompanyDraft('')
237
+ }}
238
+ >
239
+ <input className={styles.wpInput} placeholder="Add words, separated by commas" value={companyDraft} onChange={event => setCompanyDraft(event.target.value)} />
240
+ <button type="submit" className={styles.wpButton}>Add</button>
241
+ </form>
242
+ )}
243
+ </>
244
+ )}
245
+
246
+ {tab === 'shortcuts' && (
247
+ <dl className={styles.wpKeys}>
248
+ {SHORTCUTS.map(([keys, what]) => (
249
+ <div key={keys} className={styles.wpKeyRow}>
250
+ <dt><kbd>{keys}</kbd></dt>
251
+ <dd>{what}</dd>
252
+ </div>
253
+ ))}
254
+ </dl>
255
+ )}
256
+ </div>
257
+ </div>
258
+ )
259
+ }
@@ -0,0 +1,139 @@
1
+ import { Extension } from '@tiptap/react'
2
+ import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'
3
+ import type { EditorView } from '@tiptap/pm/view'
4
+ import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
5
+ import { correctWord, formatAmount, startsSentence } from './text'
6
+ import { TEMPLATE_FIELD, fillTemplate, normaliseShortcut } from './templates'
7
+ import { live } from './live'
8
+
9
+ type Revert = { doc: ProseMirrorNode; from: number; to: number; original: string }
10
+
11
+ /** What a word after a full stop turns out to be when the stop belonged to an address or a file name. */
12
+ const ADDRESS_ENDINGS = new Set([
13
+ 'com', 'ng', 'org', 'net', 'io', 'co', 'uk', 'africa', 'info', 'gov', 'edu', 'biz', 'me', 'app', 'dev', 'us', 'za', 'gh', 'ke',
14
+ 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'ppt', 'pptx', 'jpg', 'jpeg', 'png', 'gif', 'zip', 'txt', 'html', 'eml', 'msg',
15
+ ])
16
+
17
+ const BOUNDARY = /^[\s.,!?;:)\]"'”’]$/
18
+
19
+ function inCode(view: EditorView, pos: number): boolean {
20
+ const $pos = view.state.doc.resolve(pos)
21
+ if ($pos.parent.type.name === 'codeBlock') return true
22
+ return $pos.marks().some(mark => mark.type.name === 'code')
23
+ }
24
+
25
+ /** The next {field} after the caret, or the first one in the message. */
26
+ export function nextField(doc: ProseMirrorNode, after: number): { from: number; to: number } | null {
27
+ let first: { from: number; to: number } | null = null
28
+ let next: { from: number; to: number } | null = null
29
+ doc.descendants((node, pos) => {
30
+ if (next || !node.isText || !node.text) return !next
31
+ for (const match of node.text.matchAll(TEMPLATE_FIELD)) {
32
+ const range = { from: pos + (match.index ?? 0), to: pos + (match.index ?? 0) + match[0].length }
33
+ if (!first) first = range
34
+ if (range.from >= after && !next) next = range
35
+ }
36
+ return !next
37
+ })
38
+ return next ?? first
39
+ }
40
+
41
+ /**
42
+ * The small corrections made while typing: a capital to start a sentence, "I" on its own,
43
+ * common typos, naira amounts, and template shortcuts. Each is one Backspace from undone.
44
+ */
45
+ export const WritingAssist = Extension.create<Record<string, never>, { revert: Revert | null; spaced: number | null }>({
46
+ name: 'writingAssist',
47
+ addStorage() {
48
+ return { revert: null, spaced: null }
49
+ },
50
+ addKeyboardShortcuts() {
51
+ return {
52
+ Backspace: () => {
53
+ const revert = this.storage.revert
54
+ this.storage.revert = null
55
+ if (!revert || revert.doc !== this.editor.state.doc) return false
56
+ this.editor.view.dispatch(this.editor.state.tr.insertText(revert.original, revert.from, revert.to))
57
+ return true
58
+ },
59
+ }
60
+ },
61
+ addProseMirrorPlugins() {
62
+ const storage = this.storage
63
+ const editor = this.editor
64
+ const remember = (view: EditorView, from: number, to: number, original: string) => {
65
+ storage.revert = { doc: view.state.doc, from, to, original }
66
+ }
67
+ return [
68
+ new Plugin({
69
+ key: new PluginKey('writingAssist'),
70
+ props: {
71
+ handleTextInput: (view, from, to, text) => {
72
+ storage.revert = null
73
+ if (from !== to || text.length !== 1 || inCode(view, from)) return false
74
+ const state = live(editor)
75
+ if (!state.tools) return false
76
+ const settings = state.settings
77
+ const $from = view.state.doc.resolve(from)
78
+ const before = $from.parent.textBetween(0, $from.parentOffset, undefined, '')
79
+
80
+ // A letter straight after the stop: the space was missed, so it goes in with the capital.
81
+ if (settings.autoCapitalize && settings.spaceAfterStop && /[A-Za-z]/.test(text) && /[A-Za-z]{2}[.!?]$/.test(before) && startsSentence(`${before} `)) {
82
+ view.dispatch(view.state.tr.insertText(` ${text.toUpperCase()}`, from, to))
83
+ remember(view, from, from + 2, text)
84
+ storage.spaced = before.endsWith('.') ? from : null
85
+ return true
86
+ }
87
+
88
+ if (settings.autoCapitalize && /[a-z]/.test(text) && startsSentence(before)) {
89
+ view.dispatch(view.state.tr.insertText(text.toUpperCase(), from, to))
90
+ remember(view, from, from + 1, text)
91
+ return true
92
+ }
93
+
94
+ if (!BOUNDARY.test(text)) return false
95
+ const word = before.match(/([^\s]+)$/)?.[1]
96
+ const spaced = storage.spaced
97
+ storage.spaced = null
98
+ if (!word) return false
99
+ const start = from - word.length
100
+
101
+ // "metroperil. Com" was an address after all: take the space and the capital back out.
102
+ if (spaced !== null && start === spaced + 1 && ADDRESS_ENDINGS.has(word.toLowerCase())) {
103
+ view.dispatch(view.state.tr.insertText(word.toLowerCase() + text, spaced, to))
104
+ return true
105
+ }
106
+
107
+ if (settings.templateShortcuts && word.startsWith(';') && text === ' ') {
108
+ const shortcut = normaliseShortcut(word)
109
+ const template = state.templates.find(entry => normaliseShortcut(entry.shortcut) === shortcut)
110
+ if (template) {
111
+ editor.chain().focus().deleteRange({ from: start, to }).insertContent(fillTemplate(template.html, state.context)).run()
112
+ const field = nextField(editor.state.doc, start)
113
+ if (field) editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, field.from, field.to)))
114
+ return true
115
+ }
116
+ }
117
+
118
+ const bare = word.replace(/^["'“‘(\[]+/, '')
119
+ const offset = word.length - bare.length
120
+ let replacement: string | null = null
121
+ if (settings.capitalizeI && bare === 'i') replacement = 'I'
122
+ else if (settings.autocorrect) replacement = correctWord(bare)
123
+ if (!replacement && settings.amountFormat) {
124
+ const previous = before.slice(0, start).match(/(\S+)\s+$/)?.[1] ?? ''
125
+ replacement = formatAmount(bare, { nairaLetter: settings.nairaLetter, plainNumbers: settings.numberFormat }, previous)
126
+ }
127
+ if (!replacement) return false
128
+
129
+ const wordFrom = start + offset
130
+ const tr = view.state.tr.insertText(replacement + text, wordFrom, to)
131
+ view.dispatch(tr)
132
+ remember(view, wordFrom, wordFrom + replacement.length, bare)
133
+ return true
134
+ },
135
+ },
136
+ }),
137
+ ]
138
+ },
139
+ })
@@ -0,0 +1,48 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import { sendIssues } from './checks.ts'
4
+ import { WRITING_DEFAULTS } from './settings.ts'
5
+
6
+ const base: { recipients: Array<{ email: string; name?: string }>; attachmentCount: number; ownDomains: string[] } = {
7
+ recipients: [{ email: 'eric.samuel@coronation.ng', name: 'Eric Samuel' }],
8
+ attachmentCount: 0,
9
+ ownDomains: ['metroperilinsbrokers.com'],
10
+ }
11
+ const kinds = (body: string, extra: Partial<typeof base> = {}, settings = WRITING_DEFAULTS) =>
12
+ sendIssues({ ...base, ...extra, body }, settings).map(issue => issue.kind)
13
+
14
+ test('a promised attachment with none attached is caught', () => {
15
+ assert.deepEqual(kinds('Dear Eric,\nPlease find attached the schedule.'), ['attachment'])
16
+ assert.deepEqual(kinds('Dear Eric,\nPlease find attached the schedule.', { attachmentCount: 1 }), [])
17
+ assert.deepEqual(kinds('Dear Eric,\nThe documents are enclosed.'), ['attachment'])
18
+ assert.deepEqual(kinds('Dear Eric,\nPlease find below the details.'), [])
19
+ })
20
+
21
+ test('empty body and leftover placeholders', () => {
22
+ assert.deepEqual(kinds(' '), ['empty'])
23
+ assert.deepEqual(kinds('Dear {first_name}, your renewal is due.'), ['placeholder'])
24
+ assert.deepEqual(kinds('Dear Eric, premium is N[amount].'), ['placeholder'])
25
+ assert.deepEqual(kinds('Dear Eric, see [cid:image001.png] below.'), [], 'an inline image marker is not a placeholder')
26
+ })
27
+
28
+ test('the greeting has to match a recipient', () => {
29
+ assert.deepEqual(kinds('Dear Mr. Okafor,\nThanks.'), ['greeting'])
30
+ assert.deepEqual(kinds('Dear Mr. Samuel,\nThanks.'), [])
31
+ assert.deepEqual(kinds('Hi Eric,\nThanks.'), [])
32
+ assert.deepEqual(kinds('Dear Sir,\nThanks.'), [])
33
+ assert.deepEqual(kinds('Good morning all,\nThanks.'), [])
34
+ assert.deepEqual(kinds('Hello, the schedule is below.'), [], 'an ordinary word after the greeting is not a name')
35
+ assert.deepEqual(kinds('Hi there, thanks.'), [])
36
+ })
37
+
38
+ test('many outside recipients are flagged, colleagues are not counted', () => {
39
+ const outsiders = Array.from({ length: 5 }, (_, index) => ({ email: `p${index}@insurer.com` }))
40
+ assert.deepEqual(kinds('Hello all, thanks.', { recipients: outsiders }), ['external'])
41
+ const colleagues = outsiders.map(entry => ({ email: entry.email.replace('insurer.com', 'metroperilinsbrokers.com') }))
42
+ assert.deepEqual(kinds('Hello all, thanks.', { recipients: colleagues }), [])
43
+ })
44
+
45
+ test('every check can be switched off', () => {
46
+ const off = { ...WRITING_DEFAULTS, checkAttachment: false, checkEmptyBody: false, checkPlaceholders: false, checkGreeting: false, checkExternal: false }
47
+ assert.deepEqual(kinds('Dear {x} Okafor, attached', {}, off), [])
48
+ })
@@ -0,0 +1,58 @@
1
+ import type { WritingSettings } from './settings'
2
+
3
+ export type SendCheckInput = {
4
+ /** What the sender wrote: no signature, no quoted message. */
5
+ body: string
6
+ recipients: Array<{ email: string; name?: string | null }>
7
+ attachmentCount: number
8
+ ownDomains: string[]
9
+ }
10
+
11
+ export type SendIssue = { kind: 'attachment' | 'empty' | 'placeholder' | 'greeting' | 'external'; message: string }
12
+
13
+ const PROMISES_ATTACHMENT =
14
+ /\b(attached|attachments?|enclosed|enclosing|herewith|I(?:'ve| have) (?:also )?(?:included|added) the (?:file|document|schedule|form|quote|policy|invoice))\b/i
15
+ const PLACEHOLDER = /\[(?![Cc]id:)[^\]\n]{1,40}\]|\{[A-Za-z_ ]{1,30}\}|<<[^>\n]{1,40}>>|\bX{3,}\b|\bTBD\b|\bTODO\b/
16
+ const GREETING = /^\s*(?:dear|hi|hello|hey|good (?:morning|afternoon|evening))[ ,]+(?:(?:mr|mrs|ms|miss|dr|prof|engr|chief|sir|madam|barr|alh(?:aji)?|hajia)\.?\s+)?([A-Za-z][A-Za-z'-]{1,30})/i
17
+ const GENERIC = new Set(['sir', 'madam', 'sirs', 'all', 'team', 'everyone', 'colleague', 'colleagues', 'gentlemen', 'there', 'friend', 'client', 'customer', 'valued', 'concerned', 'whom', 'both', 'you'])
18
+
19
+ const domainOf = (email: string) => email.trim().toLowerCase().split('@')[1] ?? ''
20
+
21
+ /** What looks wrong with a message before it goes, each on its own switch. */
22
+ export function sendIssues(input: SendCheckInput, settings: WritingSettings): SendIssue[] {
23
+ const issues: SendIssue[] = []
24
+ const body = input.body.trim()
25
+
26
+ if (settings.checkEmptyBody && !body) {
27
+ issues.push({ kind: 'empty', message: 'The message has no text of your own — only the signature or the quoted message.' })
28
+ }
29
+ if (settings.checkAttachment && input.attachmentCount === 0) {
30
+ const promise = body.match(PROMISES_ATTACHMENT)
31
+ if (promise) issues.push({ kind: 'attachment', message: `It says “${promise[0]}”, but nothing is attached.` })
32
+ }
33
+ if (settings.checkPlaceholders) {
34
+ const left = body.match(PLACEHOLDER)
35
+ if (left) issues.push({ kind: 'placeholder', message: `“${left[0]}” looks like a placeholder that was never filled in.` })
36
+ }
37
+ if (settings.checkGreeting) {
38
+ const greeted = body.split('\n').find(line => line.trim())?.match(GREETING)?.[1]
39
+ if (greeted && /^[A-Z]/.test(greeted) && !GENERIC.has(greeted.toLowerCase())) {
40
+ const target = greeted.toLowerCase()
41
+ const matches = input.recipients.some(recipient => {
42
+ const haystack = `${recipient.name ?? ''} ${recipient.email.split('@')[0]}`.toLowerCase()
43
+ return haystack.includes(target) || (target.length >= 4 && haystack.includes(target.slice(0, 4)))
44
+ })
45
+ if (!matches && input.recipients.length) {
46
+ issues.push({ kind: 'greeting', message: `The greeting names “${greeted}”, but no recipient looks like ${greeted}.` })
47
+ }
48
+ }
49
+ }
50
+ if (settings.checkExternal) {
51
+ const own = new Set(input.ownDomains.map(domain => domain.toLowerCase()))
52
+ const outside = input.recipients.filter(recipient => !own.has(domainOf(recipient.email)))
53
+ if (outside.length >= settings.externalThreshold) {
54
+ issues.push({ kind: 'external', message: `It goes to ${outside.length} people outside the company.` })
55
+ }
56
+ }
57
+ return issues
58
+ }
@@ -0,0 +1,52 @@
1
+ import { Extension } from '@tiptap/react'
2
+ import { Plugin, PluginKey, type EditorState } from '@tiptap/pm/state'
3
+ import { Decoration, DecorationSet } from '@tiptap/pm/view'
4
+ import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
5
+
6
+ export type FindState = { query: string; index: number }
7
+ export const findKey = new PluginKey<FindState>('find')
8
+
9
+ export function findMatches(doc: ProseMirrorNode, query: string): Array<{ from: number; to: number }> {
10
+ const needle = query.toLowerCase()
11
+ if (!needle) return []
12
+ const out: Array<{ from: number; to: number }> = []
13
+ doc.descendants((node, pos) => {
14
+ if (!node.isText || !node.text) return true
15
+ const haystack = node.text.toLowerCase()
16
+ for (let at = haystack.indexOf(needle); at !== -1; at = haystack.indexOf(needle, at + needle.length)) {
17
+ out.push({ from: pos + at, to: pos + at + needle.length })
18
+ }
19
+ return false
20
+ })
21
+ return out
22
+ }
23
+
24
+ export const findState = (state: EditorState): FindState => findKey.getState(state) ?? { query: '', index: 0 }
25
+
26
+ /** Highlights every match of the find bar's query, the current one more strongly. */
27
+ export const FindReplace = Extension.create({
28
+ name: 'findReplace',
29
+ addProseMirrorPlugins() {
30
+ return [
31
+ new Plugin<FindState>({
32
+ key: findKey,
33
+ state: {
34
+ init: () => ({ query: '', index: 0 }),
35
+ apply: (tr, value) => (tr.getMeta(findKey) as FindState | undefined) ?? value,
36
+ },
37
+ props: {
38
+ decorations: state => {
39
+ const { query, index } = findState(state)
40
+ const matches = findMatches(state.doc, query)
41
+ if (!matches.length) return DecorationSet.empty
42
+ const current = ((index % matches.length) + matches.length) % matches.length
43
+ return DecorationSet.create(
44
+ state.doc,
45
+ matches.map((match, position) => Decoration.inline(match.from, match.to, { class: position === current ? 'rteFindCurrent' : 'rteFind' })),
46
+ )
47
+ },
48
+ },
49
+ }),
50
+ ]
51
+ },
52
+ })
@@ -0,0 +1,100 @@
1
+ import { Extension } from '@tiptap/react'
2
+ import { Plugin, PluginKey } from '@tiptap/pm/state'
3
+ import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view'
4
+ import { live } from './live'
5
+
6
+ type GrammarState = { set: DecorationSet; version: number }
7
+ export const grammarKey = new PluginKey<GrammarState>('grammar')
8
+
9
+ type Match = { offset: number; length: number; message: string; replacements: string[] }
10
+
11
+ /** Blue underlines from the company's own grammar server, fetched when typing pauses. */
12
+ export const grammarIgnoreKey = (text: string, message: string) => `${text.toLowerCase()}|${message}`
13
+
14
+ export const Grammar = Extension.create<Record<string, never>, { ignored: Set<string> }>({
15
+ name: 'grammar',
16
+ addStorage() {
17
+ return { ignored: new Set<string>() }
18
+ },
19
+ addProseMirrorPlugins() {
20
+ const editor = this.editor
21
+ const ignored = this.storage.ignored
22
+ let timer: ReturnType<typeof setTimeout> | null = null
23
+ let asked = 0
24
+
25
+ const run = async (view: EditorView) => {
26
+ const current = live(editor)
27
+ if (!current.tools || !current.grammarOn) {
28
+ view.dispatch(view.state.tr.setMeta(grammarKey, DecorationSet.empty))
29
+ return
30
+ }
31
+ const blocks: Array<{ start: number; text: string; at: number }> = []
32
+ let text = ''
33
+ view.state.doc.descendants((node, pos) => {
34
+ if (!node.isTextblock) return true
35
+ if (node.type.name === 'codeBlock') return false
36
+ const content = node.textBetween(0, node.content.size, undefined, '')
37
+ if (content.trim()) {
38
+ blocks.push({ start: pos + 1, text: content, at: text.length })
39
+ text += `${content}\n\n`
40
+ }
41
+ return false
42
+ })
43
+ if (!text.trim()) return view.dispatch(view.state.tr.setMeta(grammarKey, DecorationSet.empty))
44
+ const ticket = ++asked
45
+ const doc = view.state.doc
46
+ const response = await fetch('/api/mail/grammar', {
47
+ method: 'POST',
48
+ headers: { ...Object.fromEntries(new Headers(current.headers()).entries()), 'Content-Type': 'application/json' },
49
+ body: JSON.stringify({ text: text.slice(0, 20000), language: current.settings.spellLanguage }),
50
+ }).catch(() => null)
51
+ const data = response?.ok ? ((await response.json().catch(() => null)) as { matches?: Match[] } | null) : null
52
+ if (ticket !== asked || view.isDestroyed || !view.state.doc.eq(doc)) return
53
+ const decorations = (data?.matches ?? []).flatMap(match => {
54
+ const block = blocks.find(entry => match.offset >= entry.at && match.offset + match.length <= entry.at + entry.text.length)
55
+ if (!block) return []
56
+ const from = block.start + (match.offset - block.at)
57
+ if (ignored.has(grammarIgnoreKey(view.state.doc.textBetween(from, from + match.length), match.message))) return []
58
+ return [
59
+ Decoration.inline(from, from + match.length, {
60
+ class: 'rteGrammar',
61
+ 'data-kind': 'grammar',
62
+ 'data-message': match.message,
63
+ 'data-replacements': JSON.stringify(match.replacements.slice(0, 5)),
64
+ }),
65
+ ]
66
+ })
67
+ view.dispatch(view.state.tr.setMeta(grammarKey, DecorationSet.create(view.state.doc, decorations)))
68
+ }
69
+
70
+ return [
71
+ new Plugin<GrammarState>({
72
+ key: grammarKey,
73
+ state: {
74
+ init: () => ({ set: DecorationSet.empty, version: 0 }),
75
+ apply(tr, value) {
76
+ const meta = tr.getMeta(grammarKey) as DecorationSet | 'refresh' | undefined
77
+ if (meta instanceof DecorationSet) return { set: meta, version: value.version }
78
+ if (meta === 'refresh') return { set: value.set, version: value.version + 1 }
79
+ return { set: value.set.map(tr.mapping, tr.doc), version: value.version }
80
+ },
81
+ },
82
+ props: { decorations: state => grammarKey.getState(state)?.set },
83
+ view: view => {
84
+ timer = setTimeout(() => void run(view), 800)
85
+ return {
86
+ update: (next, previous) => {
87
+ const refreshed = grammarKey.getState(next.state)?.version !== grammarKey.getState(previous)?.version
88
+ if (!refreshed && next.state.doc.eq(previous.doc)) return
89
+ if (timer) clearTimeout(timer)
90
+ timer = setTimeout(() => void run(next), refreshed ? 0 : 1500)
91
+ },
92
+ destroy: () => {
93
+ if (timer) clearTimeout(timer)
94
+ },
95
+ }
96
+ },
97
+ }),
98
+ ]
99
+ },
100
+ })