@axium/email 0.0.1

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.
Files changed (43) hide show
  1. package/LICENSE.md +157 -0
  2. package/README.md +40 -0
  3. package/db.json +83 -0
  4. package/dist/client/index.d.ts +1 -0
  5. package/dist/client/index.js +1 -0
  6. package/dist/client/web_hook.d.ts +8 -0
  7. package/dist/client/web_hook.js +11 -0
  8. package/dist/common.d.ts +454 -0
  9. package/dist/common.js +127 -0
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.js +1 -0
  12. package/dist/server/api.d.ts +1 -0
  13. package/dist/server/api.js +138 -0
  14. package/dist/server/cli.d.ts +1 -0
  15. package/dist/server/cli.js +69 -0
  16. package/dist/server/db.d.ts +17 -0
  17. package/dist/server/db.js +2 -0
  18. package/dist/server/hooks.d.ts +4 -0
  19. package/dist/server/hooks.js +13 -0
  20. package/dist/server/index.d.ts +4 -0
  21. package/dist/server/index.js +4 -0
  22. package/dist/server/send.d.ts +45 -0
  23. package/dist/server/send.js +221 -0
  24. package/dist/server/smtp.d.ts +2 -0
  25. package/dist/server/smtp.js +128 -0
  26. package/lib/AddressChips.svelte +107 -0
  27. package/lib/Composer.svelte +245 -0
  28. package/lib/MessageRow.svelte +160 -0
  29. package/lib/ThreadMessage.svelte +218 -0
  30. package/lib/index.ts +4 -0
  31. package/lib/tsconfig.json +13 -0
  32. package/locales/en.json +57 -0
  33. package/package.json +83 -0
  34. package/routes/email/+layout.svelte +59 -0
  35. package/routes/email/+layout.ts +17 -0
  36. package/routes/email/+page.ts +5 -0
  37. package/routes/email/[folder]/+page.svelte +53 -0
  38. package/routes/email/[folder]/+page.ts +16 -0
  39. package/routes/email/[folder]/[id]/+page.svelte +133 -0
  40. package/routes/email/[folder]/[id]/+page.ts +12 -0
  41. package/routes/email/context.ts +10 -0
  42. package/routes/email/email.css +143 -0
  43. package/routes/tsconfig.json +13 -0
@@ -0,0 +1,107 @@
1
+ <script lang="ts">
2
+ import { text } from '@axium/client';
3
+ import { Icon } from '@axium/client/components';
4
+ import { Mailbox } from '@axium/email/common';
5
+
6
+ let {
7
+ value = $bindable(),
8
+ id,
9
+ placeholder,
10
+ }: {
11
+ value: Mailbox[];
12
+ id?: string;
13
+ placeholder?: string;
14
+ } = $props();
15
+
16
+ let input = $state('');
17
+ let error = $state<string>();
18
+
19
+ function addChip() {
20
+ const raw = input.trim().replace(/,$/, '');
21
+ if (!raw) return;
22
+
23
+ // allow `Name <user@example.com>` or a bare address
24
+ const match = raw.match(/^(?:"?([^"<]*)"?\s*)?<([^>]+)>$/);
25
+ const parsed = Mailbox.safeParse(match ? { name: match[1]?.trim() || null, address: match[2] } : { address: raw });
26
+
27
+ if (!parsed.success) {
28
+ error = text('AddressChips.invalid', { address: raw });
29
+ return;
30
+ }
31
+
32
+ error = undefined;
33
+ if (!value.some(v => v.address == parsed.data.address)) value.push(parsed.data);
34
+ input = '';
35
+ }
36
+
37
+ function onkeydown(e: KeyboardEvent) {
38
+ if (e.key == 'Enter' || e.key == ',') {
39
+ e.preventDefault();
40
+ addChip();
41
+ } else if (e.key == 'Backspace' && !input && value.length) {
42
+ value.pop();
43
+ }
44
+ }
45
+ </script>
46
+
47
+ <div class="AddressChips">
48
+ {#each value as mailbox, i}
49
+ <span class="chip">
50
+ {#if mailbox.name}
51
+ <span>{mailbox.name}</span>
52
+ <span class="subtle">&lt;{mailbox.address}&gt;</span>
53
+ {:else}
54
+ <span>{mailbox.address}</span>
55
+ {/if}
56
+ <button type="button" class="reset" onclick={() => value.splice(i, 1)}>
57
+ <Icon i="xmark" --size="12px" />
58
+ </button>
59
+ </span>
60
+ {/each}
61
+ <input
62
+ {id}
63
+ type="text"
64
+ bind:value={input}
65
+ {onkeydown}
66
+ onblur={addChip}
67
+ placeholder={value.length ? '' : placeholder || text('AddressChips.placeholder')}
68
+ class={[error && 'error']}
69
+ />
70
+ {#if error}
71
+ <span class="error">{error}</span>
72
+ {/if}
73
+ </div>
74
+
75
+ <style>
76
+ .AddressChips {
77
+ display: flex;
78
+ flex-wrap: wrap;
79
+ align-items: center;
80
+ gap: 0.25em;
81
+ flex: 1;
82
+ min-width: 0;
83
+
84
+ input {
85
+ border: none;
86
+ flex: 1;
87
+ min-width: 8em;
88
+ padding: 0.25em 0;
89
+ }
90
+ }
91
+
92
+ .chip {
93
+ display: inline-flex;
94
+ align-items: center;
95
+ gap: 0.5em;
96
+ padding: 0.2em 0.6em;
97
+ border-radius: 1em;
98
+ border: var(--border-accent);
99
+ background-color: var(--bg-accent);
100
+ white-space: nowrap;
101
+ }
102
+
103
+ span.error {
104
+ width: 100%;
105
+ font-size: 0.85em;
106
+ }
107
+ </style>
@@ -0,0 +1,245 @@
1
+ <script lang="ts">
2
+ import { fetchAPI, text } from '@axium/client';
3
+ import { closeOnBackGesture } from '@axium/client/attachments';
4
+ import { Icon } from '@axium/client/components';
5
+ import { toast, toastStatus } from '@axium/client/toast';
6
+ import type { Email, Mailbox } from '@axium/email/common';
7
+ import AddressChips from './AddressChips.svelte';
8
+
9
+ let {
10
+ dialog = $bindable(),
11
+ userId,
12
+ onSent,
13
+ }: {
14
+ dialog?: HTMLDialogElement;
15
+ userId: string;
16
+ onSent?(email: Email): unknown;
17
+ } = $props();
18
+
19
+ let to = $state<Mailbox[]>([]),
20
+ cc = $state<Mailbox[]>([]),
21
+ bcc = $state<Mailbox[]>([]),
22
+ subject = $state(''),
23
+ body = $state(''),
24
+ inReplyTo = $state<string | null>(null),
25
+ draftId = $state<string | null>(null),
26
+ showCc = $state(false),
27
+ showBcc = $state(false),
28
+ sending = $state(false);
29
+
30
+ export function compose() {
31
+ reset();
32
+ dialog?.showModal();
33
+ }
34
+
35
+ export function editDraft(draft: Email) {
36
+ reset();
37
+ to = [...draft.to];
38
+ cc = [...draft.cc];
39
+ bcc = [...draft.bcc];
40
+ showCc = !!draft.cc.length;
41
+ showBcc = !!draft.bcc.length;
42
+ subject = draft.subject;
43
+ body = draft.text ?? '';
44
+ inReplyTo = draft.inReplyTo ?? null;
45
+ draftId = draft.id;
46
+ dialog?.showModal();
47
+ }
48
+
49
+ export function reply(email: Email) {
50
+ reset();
51
+ to = [email.from];
52
+ subject = /^re:/i.test(email.subject) ? email.subject : `Re: ${email.subject}`;
53
+ inReplyTo = email.messageId;
54
+ const quoted = (email.text ?? '').replaceAll(/^/gm, '> ');
55
+ body = `\n\n${text('Composer.on_wrote', { date: email.date.toLocaleString(), name: email.from.name || email.from.address })}\n${quoted}`;
56
+ dialog?.showModal();
57
+ }
58
+
59
+ export function forward(email: Email) {
60
+ reset();
61
+ subject = /^fwd:/i.test(email.subject) ? email.subject : `Fwd: ${email.subject}`;
62
+ body = [
63
+ '',
64
+ '',
65
+ `---------- ${text('Composer.forwarded_message')} ----------`,
66
+ `From: ${email.from.name || ''} <${email.from.address}>`,
67
+ `Date: ${email.date.toLocaleString()}`,
68
+ `Subject: ${email.subject}`,
69
+ `To: ${email.to.map(m => m.address).join(', ')}`,
70
+ '',
71
+ email.text ?? '',
72
+ ].join('\n');
73
+ dialog?.showModal();
74
+ }
75
+
76
+ function reset() {
77
+ to = [];
78
+ cc = [];
79
+ bcc = [];
80
+ subject = '';
81
+ body = '';
82
+ inReplyTo = null;
83
+ draftId = null;
84
+ showCc = false;
85
+ showBcc = false;
86
+ }
87
+
88
+ async function send() {
89
+ if (!to.length && !cc.length && !bcc.length) {
90
+ toast('warning', text('Composer.no_recipients'));
91
+ return;
92
+ }
93
+ sending = true;
94
+ try {
95
+ const email = await fetchAPI('PUT', 'users/:id/email', { to, cc, bcc, subject, text: body, inReplyTo, draftId }, userId);
96
+ toast('success', text('email.toast_sent'));
97
+ dialog?.close();
98
+ reset();
99
+ onSent?.(email);
100
+ } catch (e) {
101
+ toast('error', e);
102
+ } finally {
103
+ sending = false;
104
+ }
105
+ }
106
+
107
+ async function saveDraft() {
108
+ const promise = fetchAPI(
109
+ 'PUT',
110
+ 'users/:id/email',
111
+ { to, cc, bcc, subject, text: body, inReplyTo, draftId, isDraft: true },
112
+ userId
113
+ ).then(draft => {
114
+ draftId = draft.id;
115
+ });
116
+ await toastStatus(promise, text('email.toast_draft_saved'));
117
+ }
118
+ </script>
119
+
120
+ <dialog bind:this={dialog} class="Composer" {@attach closeOnBackGesture}>
121
+ <div class="composer-header">
122
+ <strong>{text('Composer.title')}</strong>
123
+ <button type="button" class="reset" onclick={() => dialog?.close()}>
124
+ <Icon i="xmark" />
125
+ </button>
126
+ </div>
127
+ <div class="field">
128
+ <label for="composer-to">{text('Composer.to')}</label>
129
+ <AddressChips id="composer-to" bind:value={to} />
130
+ <span class="cc-toggles subtle">
131
+ {#if !showCc}<button type="button" class="reset" onclick={() => (showCc = true)}>{text('Composer.cc')}</button>{/if}
132
+ {#if !showBcc}<button type="button" class="reset" onclick={() => (showBcc = true)}>{text('Composer.bcc')}</button>{/if}
133
+ </span>
134
+ </div>
135
+ {#if showCc}
136
+ <div class="field">
137
+ <label for="composer-cc">{text('Composer.cc')}</label>
138
+ <AddressChips id="composer-cc" bind:value={cc} />
139
+ </div>
140
+ {/if}
141
+ {#if showBcc}
142
+ <div class="field">
143
+ <label for="composer-bcc">{text('Composer.bcc')}</label>
144
+ <AddressChips id="composer-bcc" bind:value={bcc} />
145
+ </div>
146
+ {/if}
147
+ <div class="field">
148
+ <label for="composer-subject">{text('Composer.subject')}</label>
149
+ <input id="composer-subject" type="text" bind:value={subject} maxlength="998" />
150
+ </div>
151
+ <textarea bind:value={body} placeholder={text('Composer.body_placeholder')}></textarea>
152
+ <div class="composer-actions">
153
+ <button type="button" class="icon-text send" onclick={send} disabled={sending}>
154
+ <Icon i="paper-plane" />
155
+ <span>{text('Composer.send')}</span>
156
+ </button>
157
+ <button type="button" onclick={saveDraft} disabled={sending}>{text('Composer.save_draft')}</button>
158
+ <button
159
+ type="button"
160
+ class="reset discard"
161
+ onclick={() => {
162
+ dialog?.close();
163
+ reset();
164
+ }}
165
+ >
166
+ <Icon i="trash" />
167
+ <span class="mobile-hide">{text('Composer.discard')}</span>
168
+ </button>
169
+ </div>
170
+ </dialog>
171
+
172
+ <style>
173
+ dialog.Composer {
174
+ display: none;
175
+ flex-direction: column;
176
+ gap: 0.5em;
177
+ width: min(40em, calc(100% - 2em));
178
+ height: min(35em, 90dvh);
179
+
180
+ &:modal {
181
+ display: flex;
182
+ }
183
+ }
184
+
185
+ .composer-header {
186
+ display: flex;
187
+ justify-content: space-between;
188
+ align-items: center;
189
+ }
190
+
191
+ .field {
192
+ display: flex;
193
+ align-items: center;
194
+ gap: 0.5em;
195
+ border-bottom: var(--border-alt);
196
+ padding-bottom: 0.25em;
197
+
198
+ label {
199
+ color: var(--fg-disabled);
200
+ flex-shrink: 0;
201
+ }
202
+
203
+ input {
204
+ border: none;
205
+ border-radius: 0;
206
+ flex: 1;
207
+ padding: 0.25em 0;
208
+ }
209
+ }
210
+
211
+ .cc-toggles {
212
+ display: flex;
213
+ gap: 0.75em;
214
+ flex-shrink: 0;
215
+
216
+ button:hover {
217
+ text-decoration: underline;
218
+ }
219
+ }
220
+
221
+ textarea {
222
+ flex: 1;
223
+ resize: none;
224
+ border: none;
225
+ padding: 0.5em 0;
226
+ font-size: 16px;
227
+ }
228
+
229
+ .composer-actions {
230
+ display: flex;
231
+ align-items: center;
232
+ gap: 1em;
233
+
234
+ .send {
235
+ font-weight: bold;
236
+ }
237
+
238
+ .discard {
239
+ margin-left: auto;
240
+ display: inline-flex;
241
+ align-items: center;
242
+ gap: 0.5em;
243
+ }
244
+ }
245
+ </style>
@@ -0,0 +1,160 @@
1
+ <script lang="ts">
2
+ import { invalidateAll } from '$app/navigation';
3
+ import { fetchAPI, text } from '@axium/client';
4
+ import { contextMenu } from '@axium/client/attachments';
5
+ import { Icon } from '@axium/client/components';
6
+ import { toast, toastStatus } from '@axium/client/toast';
7
+ import type { Email, EmailChangeable } from '@axium/email/common';
8
+
9
+ let { email = $bindable(), onopen }: { email: Email; onopen(email: Email): unknown } = $props();
10
+
11
+ function shortDate(date: Date): string {
12
+ const now = new Date();
13
+ if (date.toDateString() == now.toDateString()) return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
14
+ if (date.getFullYear() == now.getFullYear()) return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
15
+ return date.toLocaleDateString();
16
+ }
17
+
18
+ async function patch(changes: EmailChangeable) {
19
+ try {
20
+ const result = await fetchAPI('PATCH', 'email/:id', changes, email.id);
21
+ Object.assign(email, result, { attachments: email.attachments });
22
+ if ('folder' in changes) await invalidateAll();
23
+ } catch (e) {
24
+ toast('error', e);
25
+ }
26
+ }
27
+
28
+ const correspondents = $derived(
29
+ email.folder == 'sent' || email.folder == 'drafts'
30
+ ? email.to.map(m => m.name || m.address).join(', ')
31
+ : email.from.name || email.from.address
32
+ );
33
+ </script>
34
+
35
+ <div
36
+ class={['MessageRow', !email.read && 'unread']}
37
+ onclick={() => onopen(email)}
38
+ role="row"
39
+ tabindex="0"
40
+ {@attach contextMenu(() => [
41
+ {
42
+ i: email.read ? 'envelope' : 'envelope-open',
43
+ text: email.read ? text('email.mark_unread') : text('email.mark_read'),
44
+ action: () => patch({ read: !email.read }),
45
+ },
46
+ {
47
+ i: email.starred ? 'star' : 'regular/star',
48
+ text: email.starred ? text('email.unstar') : text('email.star'),
49
+ action: () => patch({ starred: !email.starred }),
50
+ },
51
+ email.folder != 'archive' &&
52
+ email.folder != 'sent' &&
53
+ email.folder != 'drafts' && { i: 'box-archive', text: text('email.archive'), action: () => patch({ folder: 'archive' }) },
54
+ email.folder == 'inbox' && { i: 'triangle-exclamation', text: text('email.mark_spam'), action: () => patch({ folder: 'spam' }) },
55
+ email.folder == 'spam' && { i: 'inbox', text: text('email.not_spam'), action: () => patch({ folder: 'inbox' }) },
56
+ (email.folder == 'trash' || email.folder == 'archive') && {
57
+ i: 'inbox',
58
+ text: text('email.move_to_inbox'),
59
+ action: () => patch({ folder: 'inbox' }),
60
+ },
61
+ {
62
+ i: 'trash',
63
+ danger: true,
64
+ text: email.folder == 'trash' || email.folder == 'drafts' ? text('email.delete_forever') : text('generic.delete'),
65
+ action: () => toastStatus(fetchAPI('DELETE', 'email/:id', {}, email.id).then(invalidateAll), text('email.toast_deleted')),
66
+ },
67
+ ])}
68
+ >
69
+ <button
70
+ class="reset star"
71
+ onclick={e => {
72
+ e.stopPropagation();
73
+ void patch({ starred: !email.starred });
74
+ }}
75
+ title={email.starred ? text('email.unstar') : text('email.star')}
76
+ >
77
+ <Icon i={email.starred ? 'star' : 'regular/star'} --size="16px" --fill={email.starred ? 'var(--fg-warning)' : undefined} />
78
+ </button>
79
+ <span class="correspondents">{correspondents}</span>
80
+ <span class="MessageRow-content">
81
+ <span class="subject">{email.subject || text('email.no_subject')}</span>
82
+ {#if email.snippet}
83
+ <span class="snippet subtle mobile-hide">&nbsp;&mdash;&nbsp;{email.snippet}</span>
84
+ {/if}
85
+ </span>
86
+ {#if email.attachments.length}
87
+ <Icon i="paperclip" --size="14px" />
88
+ {/if}
89
+ <span class="date">{shortDate(email.date)}</span>
90
+ </div>
91
+
92
+ <style>
93
+ .MessageRow {
94
+ display: flex;
95
+ align-items: center;
96
+ gap: 0.75em;
97
+ padding: 0.6em 1em;
98
+ border-bottom: var(--border-alt);
99
+ cursor: pointer;
100
+ min-width: 0;
101
+
102
+ &:hover {
103
+ background-color: var(--bg-alt);
104
+ }
105
+
106
+ &.unread {
107
+ font-weight: bold;
108
+ background-color: var(--bg-menu);
109
+
110
+ &:hover {
111
+ background-color: var(--bg-alt);
112
+ }
113
+ }
114
+ }
115
+
116
+ .star {
117
+ flex-shrink: 0;
118
+ display: inline-flex;
119
+ }
120
+
121
+ .correspondents {
122
+ flex: 0 0 12em;
123
+ overflow: hidden;
124
+ text-overflow: ellipsis;
125
+ white-space: nowrap;
126
+ }
127
+
128
+ .MessageRow-content {
129
+ flex: 1;
130
+ min-width: 0;
131
+ overflow: hidden;
132
+ text-overflow: ellipsis;
133
+ white-space: nowrap;
134
+
135
+ .snippet {
136
+ font-weight: normal;
137
+ }
138
+ }
139
+
140
+ .date {
141
+ flex-shrink: 0;
142
+ font-size: 0.85em;
143
+ }
144
+
145
+ @media (width < 700px) {
146
+ .MessageRow {
147
+ flex-wrap: wrap;
148
+ row-gap: 0.1em;
149
+ }
150
+
151
+ .correspondents {
152
+ flex: 1;
153
+ }
154
+
155
+ .MessageRow-content {
156
+ flex-basis: 100%;
157
+ order: 1;
158
+ }
159
+ }
160
+ </style>