@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,218 @@
1
+ <script lang="ts">
2
+ import { fetchAPI, text } from '@axium/client';
3
+ import { Icon } from '@axium/client/components';
4
+ import { toast } from '@axium/client/toast';
5
+ import { formatBytes } from '@axium/core/format';
6
+ import { splitQuoted, type Email } from '@axium/email/common';
7
+
8
+ let {
9
+ email = $bindable(),
10
+ expanded = $bindable(false),
11
+ onreply,
12
+ onforward,
13
+ }: {
14
+ email: Email;
15
+ expanded?: boolean;
16
+ onreply(email: Email): unknown;
17
+ onforward(email: Email): unknown;
18
+ } = $props();
19
+
20
+ const body = $derived(splitQuoted(email.text ?? ''));
21
+ let showQuoted = $state(false);
22
+
23
+ $effect(() => {
24
+ if (!expanded || email.read) return;
25
+ fetchAPI('PATCH', 'email/:id', { read: true }, email.id).then(
26
+ () => (email.read = true),
27
+ e => toast('error', e)
28
+ );
29
+ });
30
+
31
+ async function toggleStar(e: Event) {
32
+ e.stopPropagation();
33
+ try {
34
+ const result = await fetchAPI('PATCH', 'email/:id', { starred: !email.starred }, email.id);
35
+ email.starred = result.starred;
36
+ } catch (e) {
37
+ toast('error', e);
38
+ }
39
+ }
40
+
41
+ const toNames = $derived(email.to.map(m => (m.name ? `${m.name} <${m.address}>` : m.address)).join(', '));
42
+ </script>
43
+
44
+ <div class={['ThreadMessage', expanded && 'expanded']}>
45
+ {#if !expanded}
46
+ <div class="tm-collapsed" onclick={() => (expanded = true)} role="button" tabindex="0">
47
+ <strong class="tm-from">{email.from.name || email.from.address}</strong>
48
+ <span class="tm-snippet subtle">{email.snippet ?? ''}</span>
49
+ <span class="tm-date subtle">{email.date.toLocaleDateString()}</span>
50
+ </div>
51
+ {:else}
52
+ <div class="tm-header" onclick={() => (expanded = false)} role="button" tabindex="0">
53
+ <div class="tm-headers">
54
+ <div>
55
+ <strong>{email.from.name || email.from.address}</strong>
56
+ {#if email.from.name}
57
+ <span class="subtle">&lt;{email.from.address}&gt;</span>
58
+ {/if}
59
+ </div>
60
+ <span class="subtle">{text('ThreadMessage.to_line', { names: toNames })}</span>
61
+ </div>
62
+ <span class="tm-date subtle">{email.date.toLocaleString()}</span>
63
+ <button class="reset" onclick={toggleStar} title={email.starred ? text('email.unstar') : text('email.star')}>
64
+ <Icon i={email.starred ? 'star' : 'regular/star'} --size="16px" --fill={email.starred ? 'var(--fg-warning)' : undefined} />
65
+ </button>
66
+ </div>
67
+
68
+ <div class="tm-body">
69
+ {#if email.html}
70
+ <iframe sandbox="" srcdoc={email.html} title={email.subject || text('email.no_subject')}></iframe>
71
+ {:else}
72
+ <pre>{body.content}</pre>
73
+ {#if body.quoted}
74
+ <button class="quote-toggle" title={text('ThreadMessage.trimmed')} onclick={() => (showQuoted = !showQuoted)}>
75
+ <Icon i="ellipsis" --size="14px" />
76
+ </button>
77
+ {#if showQuoted}
78
+ <pre class="subtle">{body.quoted}</pre>
79
+ {/if}
80
+ {/if}
81
+ {/if}
82
+ </div>
83
+
84
+ {#if email.attachments.length}
85
+ <div class="tm-attachments">
86
+ {#each email.attachments as attachment}
87
+ <a href="/api/email/{email.id}/attachments/{attachment.id}" download={attachment.filename}>
88
+ <span class="tm-attachment icon-text">
89
+ <Icon i="paperclip" --size="14px" />
90
+ <span>{attachment.filename}</span>
91
+ <span class="subtle">{formatBytes(attachment.size)}</span>
92
+ </span>
93
+ </a>
94
+ {/each}
95
+ </div>
96
+ {/if}
97
+
98
+ <div class="tm-actions">
99
+ <button class="icon-text" onclick={() => onreply(email)}>
100
+ <Icon i="reply" />
101
+ <span>{text('email.reply')}</span>
102
+ </button>
103
+ <button class="icon-text" onclick={() => onforward(email)}>
104
+ <Icon i="share" />
105
+ <span>{text('email.forward')}</span>
106
+ </button>
107
+ </div>
108
+ {/if}
109
+ </div>
110
+
111
+ <style>
112
+ .ThreadMessage {
113
+ display: flex;
114
+ flex-direction: column;
115
+ gap: 0.75em;
116
+ padding: 0.75em 0;
117
+
118
+ &:not(:last-child) {
119
+ border-bottom: var(--border-alt);
120
+ }
121
+ }
122
+
123
+ .tm-collapsed {
124
+ display: flex;
125
+ align-items: center;
126
+ gap: 0.75em;
127
+ cursor: pointer;
128
+ min-width: 0;
129
+
130
+ .tm-from {
131
+ flex: 0 0 12em;
132
+ overflow: hidden;
133
+ text-overflow: ellipsis;
134
+ white-space: nowrap;
135
+ }
136
+
137
+ .tm-snippet {
138
+ flex: 1;
139
+ min-width: 0;
140
+ overflow: hidden;
141
+ text-overflow: ellipsis;
142
+ white-space: nowrap;
143
+ }
144
+ }
145
+
146
+ .tm-header {
147
+ display: flex;
148
+ align-items: flex-start;
149
+ gap: 0.75em;
150
+ cursor: pointer;
151
+
152
+ .tm-headers {
153
+ flex: 1;
154
+ min-width: 0;
155
+ display: flex;
156
+ flex-direction: column;
157
+ gap: 0.25em;
158
+ }
159
+
160
+ button {
161
+ display: inline-flex;
162
+ }
163
+ }
164
+
165
+ .tm-date {
166
+ flex-shrink: 0;
167
+ font-size: 0.85em;
168
+ }
169
+
170
+ .tm-body {
171
+ pre {
172
+ background: none;
173
+ padding: 0;
174
+ white-space: pre-wrap;
175
+ word-break: break-word;
176
+ font-family: inherit;
177
+ margin: 0;
178
+ }
179
+
180
+ iframe {
181
+ width: 100%;
182
+ height: 50vh;
183
+ border: none;
184
+ background-color: white;
185
+ border-radius: 0.5em;
186
+ }
187
+ }
188
+
189
+ .quote-toggle {
190
+ display: inline-flex;
191
+ margin: 0.5em 0;
192
+ padding: 0 0.5em;
193
+ border-radius: 1em;
194
+ background-color: var(--bg-accent);
195
+ }
196
+
197
+ .tm-attachments {
198
+ display: flex;
199
+ flex-wrap: wrap;
200
+ gap: 0.5em;
201
+
202
+ .tm-attachment {
203
+ gap: 0.5em;
204
+ padding: 0.4em 0.8em;
205
+ border: var(--border-accent);
206
+ border-radius: 0.5em;
207
+
208
+ &:hover {
209
+ background-color: var(--bg-accent);
210
+ }
211
+ }
212
+ }
213
+
214
+ .tm-actions {
215
+ display: flex;
216
+ gap: 1em;
217
+ }
218
+ </style>
package/lib/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { default as AddressChips } from './AddressChips.svelte';
2
+ export { default as Composer } from './Composer.svelte';
3
+ export { default as MessageRow } from './MessageRow.svelte';
4
+ export { default as ThreadMessage } from './ThreadMessage.svelte';
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": ["../tsconfig.json", "../.svelte-kit/tsconfig.json"],
3
+ "compilerOptions": {
4
+ "rootDir": "..",
5
+ "noEmit": true,
6
+ "module": "preserve",
7
+ "moduleResolution": "Bundler",
8
+ "types": ["@sveltejs/kit"]
9
+ },
10
+ "include": ["**/*.svelte", "**/*.ts"],
11
+ "exclude": [],
12
+ "references": [{ "path": ".." }]
13
+ }
@@ -0,0 +1,57 @@
1
+ {
2
+ "app_name": {
3
+ "email": "Email"
4
+ },
5
+ "email": {
6
+ "archive": "Archive",
7
+ "back": "Back",
8
+ "compose": "Compose",
9
+ "delete_forever": "Delete forever",
10
+ "drafts": "Drafts",
11
+ "empty_folder": "Nothing here",
12
+ "forward": "Forward",
13
+ "inbox": "Inbox",
14
+ "mark_read": "Mark as read",
15
+ "mark_spam": "Mark as spam",
16
+ "mark_unread": "Mark as unread",
17
+ "move_to_inbox": "Move to inbox",
18
+ "no_subject": "(no subject)",
19
+ "no_username": "You need a username to send and receive email.",
20
+ "no_username_link": "Pick one in your account settings.",
21
+ "not_spam": "Not spam",
22
+ "refresh": "Refresh",
23
+ "reply": "Reply",
24
+ "sent": "Sent",
25
+ "spam": "Spam",
26
+ "star": "Star",
27
+ "starred": "Starred",
28
+ "toast_deleted": "Email deleted",
29
+ "toast_draft_saved": "Draft saved",
30
+ "toast_received": "New email from {name}",
31
+ "toast_sent": "Email sent",
32
+ "trash": "Trash",
33
+ "unstar": "Unstar"
34
+ },
35
+ "AddressChips": {
36
+ "invalid": "Invalid address: {address}",
37
+ "placeholder": "Add recipients..."
38
+ },
39
+ "Composer": {
40
+ "bcc": "Bcc",
41
+ "body_placeholder": "Write your message...",
42
+ "cc": "Cc",
43
+ "discard": "Discard",
44
+ "forwarded_message": "Forwarded message",
45
+ "no_recipients": "Add at least one recipient",
46
+ "on_wrote": "On {date}, {name} wrote:",
47
+ "save_draft": "Save draft",
48
+ "send": "Send",
49
+ "subject": "Subject",
50
+ "title": "New message",
51
+ "to": "To"
52
+ },
53
+ "ThreadMessage": {
54
+ "to_line": "to {names}",
55
+ "trimmed": "Show trimmed content"
56
+ }
57
+ }
package/package.json ADDED
@@ -0,0 +1,83 @@
1
+ {
2
+ "name": "@axium/email",
3
+ "version": "0.0.1",
4
+ "author": "James Prevett <axium@jamespre.dev>",
5
+ "description": "Email for Axium",
6
+ "funding": {
7
+ "type": "individual",
8
+ "url": "https://github.com/sponsors/james-pre"
9
+ },
10
+ "license": "LGPL-3.0-or-later",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/james-pre/axium.git"
14
+ },
15
+ "homepage": "https://github.com/james-pre/axium#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/james-pre/axium/issues"
18
+ },
19
+ "type": "module",
20
+ "main": "dist/index.js",
21
+ "types": "dist/index.d.ts",
22
+ "exports": {
23
+ ".": "./dist/index.js",
24
+ "./client": "./dist/client/index.js",
25
+ "./server": "./dist/server/index.js",
26
+ "./*": "./dist/*.js",
27
+ "./components": "./lib/index.js",
28
+ "./components/*": "./lib/*.svelte"
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "lib",
33
+ "routes",
34
+ "locales",
35
+ "db.json"
36
+ ],
37
+ "scripts": {
38
+ "build": "tsc"
39
+ },
40
+ "peerDependencies": {
41
+ "@axium/client": ">=0.33.0",
42
+ "@axium/core": ">=0.36.0",
43
+ "@axium/server": ">=0.51.0",
44
+ "@sveltejs/kit": "^2.27.3",
45
+ "commander": "^15.0.0",
46
+ "ioium": "^1.6.0",
47
+ "kysely": "^0.29.0",
48
+ "utilium": "^3.0.0"
49
+ },
50
+ "dependencies": {
51
+ "mailauth": "^4.13.3",
52
+ "postal-mime": "^2.7.5",
53
+ "smtp-server": "^3.19.2",
54
+ "zod": "^4.0.5"
55
+ },
56
+ "devDependencies": {
57
+ "@types/smtp-server": "^3.5.13"
58
+ },
59
+ "axium": {
60
+ "server": {
61
+ "routes": "routes",
62
+ "hooks": "./dist/server/hooks.js",
63
+ "cli": "./dist/server/cli.js",
64
+ "db": "./db.json",
65
+ "web_client_hooks": "./dist/client/web_hook.js"
66
+ },
67
+ "apps": [
68
+ {
69
+ "id": "email",
70
+ "name": "Email",
71
+ "icon": "envelope"
72
+ }
73
+ ],
74
+ "update_checks": true,
75
+ "config": {
76
+ "inbound": {
77
+ "enabled": true,
78
+ "port": 25,
79
+ "max_size": 25000
80
+ }
81
+ }
82
+ }
83
+ }
@@ -0,0 +1,59 @@
1
+ <script lang="ts">
2
+ import { afterNavigate, invalidateAll } from '$app/navigation';
3
+ import { page } from '$app/state';
4
+ import { text } from '@axium/client';
5
+ import { Icon } from '@axium/client/components';
6
+ import { Composer } from '@axium/email/components';
7
+ import { setContext } from 'svelte';
8
+ import { emailApp, type EmailApp } from './context.js';
9
+ import './email.css';
10
+
11
+ const { data, children } = $props();
12
+
13
+ let composer = $state<ReturnType<typeof Composer>>();
14
+ let sidebar = $state<HTMLDivElement>();
15
+
16
+ setContext<EmailApp>(emailApp, {
17
+ composer: () => composer,
18
+ toggleSidebar: () => sidebar?.classList.toggle('active'),
19
+ });
20
+
21
+ afterNavigate(() => sidebar?.classList.remove('active'));
22
+
23
+ const folders = [
24
+ ['inbox', 'inbox'],
25
+ ['starred', 'star'],
26
+ ['sent', 'paper-plane'],
27
+ ['drafts', 'file-lines'],
28
+ ['spam', 'triangle-exclamation'],
29
+ ['trash', 'trash'],
30
+ ['archive', 'box-archive'],
31
+ ] as const;
32
+
33
+ const current = $derived(page.params.folder ?? 'inbox');
34
+ </script>
35
+
36
+ <div id="email-app">
37
+ <div id="email-sidebar" bind:this={sidebar}>
38
+ <button class="compose icon-text" onclick={() => composer?.compose()}>
39
+ <Icon i="pen" />
40
+ <span>{text('email.compose')}</span>
41
+ </button>
42
+ {#each folders as [name, icon]}
43
+ <a href="/email/{name}" class={['email-sidebar-item', 'icon-text', current == name && 'active']}>
44
+ <Icon i={icon} --size="16px" />
45
+ <span>{text(`email.${name}`)}</span>
46
+ </a>
47
+ {/each}
48
+ </div>
49
+
50
+ {@render children()}
51
+
52
+ {#if data.session}
53
+ <button class="mobile-only mobile-float-right mobile-button icon-text" onclick={() => composer?.compose()}>
54
+ <Icon i="pen" />
55
+ <span>{text('email.compose')}</span>
56
+ </button>
57
+ <Composer bind:this={composer} userId={data.session.userId} onSent={() => invalidateAll()} />
58
+ {/if}
59
+ </div>
@@ -0,0 +1,17 @@
1
+ import { invalidateAll } from '$app/navigation';
2
+ import { addListener } from '@axium/client/socket';
3
+ import { getCurrentSession } from '@axium/client/user';
4
+ import type { Session, UserPublic } from '@axium/core';
5
+ import '@axium/email/common';
6
+
7
+ export const ssr = false;
8
+
9
+ addListener('email.received', () => void invalidateAll());
10
+
11
+ export async function load({ parent }) {
12
+ let { session }: { session?: (Session & { user: UserPublic }) | null } = await parent();
13
+
14
+ session ||= await getCurrentSession().catch(() => null);
15
+
16
+ return { session };
17
+ }
@@ -0,0 +1,5 @@
1
+ import { redirect } from '@sveltejs/kit';
2
+
3
+ export function load(): never {
4
+ redirect(307, '/email/inbox');
5
+ }
@@ -0,0 +1,53 @@
1
+ <script lang="ts">
2
+ import { goto, invalidateAll } from '$app/navigation';
3
+ import { text } from '@axium/client';
4
+ import { Icon } from '@axium/client/components';
5
+ import type { Email } from '@axium/email/common';
6
+ import { MessageRow } from '@axium/email/components';
7
+ import { getContext } from 'svelte';
8
+ import { emailApp, type EmailApp } from '../context.js';
9
+
10
+ const { data } = $props();
11
+
12
+ let emails = $state(data.emails);
13
+ $effect(() => {
14
+ emails = data.emails;
15
+ });
16
+
17
+ const app = getContext<EmailApp>(emailApp);
18
+ const { user } = data.session;
19
+
20
+ function open(email: Email) {
21
+ if (email.folder == 'drafts') app.composer()?.editDraft(email);
22
+ else void goto(`/email/${data.folder}/${email.id}`);
23
+ }
24
+ </script>
25
+
26
+ <svelte:head>
27
+ <title>{text(`email.${data.folder}`)} — {text('app_name.email')}</title>
28
+ </svelte:head>
29
+
30
+ <div id="email-main">
31
+ {#if !user.username}
32
+ <div class="warning">
33
+ {text('email.no_username')}
34
+ <a href="/account"><u>{text('email.no_username_link')}</u></a>
35
+ </div>
36
+ {/if}
37
+ <div class="bar">
38
+ <button class="reset mobile-only" onclick={() => app.toggleSidebar()}>
39
+ <Icon i="bars" />
40
+ </button>
41
+ <span class="label">{text(`email.${data.folder}`)}</span>
42
+ <button class="reset" onclick={() => invalidateAll()} title={text('email.refresh')}>
43
+ <Icon i="rotate-right" --size="16px" />
44
+ </button>
45
+ </div>
46
+ <div class="email-list">
47
+ {#each emails as email, i (email.id)}
48
+ <MessageRow bind:email={emails[i]} onopen={open} />
49
+ {:else}
50
+ <p class="subtle empty">{text('email.empty_folder')}</p>
51
+ {/each}
52
+ </div>
53
+ </div>
@@ -0,0 +1,16 @@
1
+ import { fetchAPI } from '@axium/client/requests';
2
+ import { emailFolders, type EmailFolder } from '@axium/email/common';
3
+ import { redirect } from '@sveltejs/kit';
4
+
5
+ export async function load({ parent, params }) {
6
+ const { session } = await parent();
7
+
8
+ if (!session) redirect(307, `/login?after=/email/${params.folder}`);
9
+
10
+ const folder = params.folder as EmailFolder | 'starred';
11
+ if (folder != 'starred' && !emailFolders.includes(folder)) redirect(307, '/email/inbox');
12
+
13
+ const emails = await fetchAPI('GET', 'users/:id/email', folder == 'starred' ? { starred: true } : { folder }, session.userId);
14
+
15
+ return { emails, folder, session };
16
+ }
@@ -0,0 +1,133 @@
1
+ <script lang="ts">
2
+ import { goto } from '$app/navigation';
3
+ import { fetchAPI, text } from '@axium/client';
4
+ import { Icon } from '@axium/client/components';
5
+ import { toast, toastStatus } from '@axium/client/toast';
6
+ import type { Email, EmailChangeable } from '@axium/email/common';
7
+ import { ThreadMessage } from '@axium/email/components';
8
+ import { getContext, untrack } from 'svelte';
9
+ import { emailApp, type EmailApp } from '../../context.js';
10
+
11
+ const { data } = $props();
12
+
13
+ const app = getContext<EmailApp>(emailApp);
14
+
15
+ /** Expand the opened and unread messages by default */
16
+ function initExpanded(): Record<string, boolean> {
17
+ const init: Record<string, boolean> = {};
18
+ for (const email of data.thread) init[email.id] = email.id == data.id || !email.read;
19
+ return init;
20
+ }
21
+
22
+ let thread = $state(data.thread);
23
+ let expanded = $state(initExpanded());
24
+
25
+ $effect(() => {
26
+ thread = data.thread;
27
+ // keep the user's manual expansion when the thread refreshes (e.g. new mail arrives)
28
+ expanded = { ...initExpanded(), ...untrack(() => expanded) };
29
+ });
30
+
31
+ const focused = $derived(thread.find(email => email.id == data.id) ?? thread.at(-1)!);
32
+ const back = $derived(`/email/${data.folder}`);
33
+
34
+ async function patch(changes: EmailChangeable) {
35
+ try {
36
+ const result = await fetchAPI('PATCH', 'email/:id', changes, focused.id);
37
+ Object.assign(focused, result, { attachments: focused.attachments });
38
+ if ('folder' in changes) await goto(back);
39
+ } catch (e) {
40
+ toast('error', e);
41
+ }
42
+ }
43
+
44
+ function remove() {
45
+ toastStatus(
46
+ fetchAPI('DELETE', 'email/:id', {}, focused.id).then(() => goto(back)),
47
+ text('email.toast_deleted')
48
+ );
49
+ }
50
+
51
+ function reply(email: Email) {
52
+ app.composer()?.reply(email);
53
+ }
54
+
55
+ function forward(email: Email) {
56
+ app.composer()?.forward(email);
57
+ }
58
+ </script>
59
+
60
+ <svelte:head>
61
+ <title>{focused.subject || text('email.no_subject')} — {text('app_name.email')}</title>
62
+ </svelte:head>
63
+
64
+ <div id="email-view-container">
65
+ <div class="view-actions">
66
+ <a href={back} title={text('email.back')}>
67
+ <button class="reset"><Icon i="arrow-left" /></button>
68
+ </a>
69
+ <button
70
+ class="reset"
71
+ onclick={() => patch({ starred: !focused.starred })}
72
+ title={focused.starred ? text('email.unstar') : text('email.star')}
73
+ >
74
+ <Icon i={focused.starred ? 'star' : 'regular/star'} --fill={focused.starred ? 'var(--fg-warning)' : undefined} />
75
+ </button>
76
+ {#if focused.folder != 'archive' && focused.folder != 'sent' && focused.folder != 'drafts'}
77
+ <button class="reset" onclick={() => patch({ folder: 'archive' })} title={text('email.archive')}>
78
+ <Icon i="box-archive" />
79
+ </button>
80
+ {/if}
81
+ {#if focused.folder == 'spam' || focused.folder == 'trash' || focused.folder == 'archive'}
82
+ <button class="reset" onclick={() => patch({ folder: 'inbox' })} title={text('email.move_to_inbox')}>
83
+ <Icon i="inbox" />
84
+ </button>
85
+ {:else if focused.folder == 'inbox'}
86
+ <button class="reset" onclick={() => patch({ folder: 'spam' })} title={text('email.mark_spam')}>
87
+ <Icon i="triangle-exclamation" />
88
+ </button>
89
+ {/if}
90
+ <button class="reset" onclick={() => patch({ read: false }).then(() => goto(back))} title={text('email.mark_unread')}>
91
+ <Icon i="envelope" />
92
+ </button>
93
+ <button
94
+ class="reset danger"
95
+ onclick={remove}
96
+ title={focused.folder == 'trash' || focused.folder == 'drafts' ? text('email.delete_forever') : text('generic.delete')}
97
+ >
98
+ <Icon i="trash" />
99
+ </button>
100
+ </div>
101
+
102
+ <h2 class="subject">{focused.subject || text('email.no_subject')}</h2>
103
+
104
+ <div class="thread">
105
+ {#each thread as email, i (email.id)}
106
+ <ThreadMessage bind:email={thread[i]} bind:expanded={expanded[email.id]} onreply={reply} onforward={forward} />
107
+ {/each}
108
+ </div>
109
+ </div>
110
+
111
+ <style>
112
+ .view-actions {
113
+ display: flex;
114
+ align-items: center;
115
+ gap: 1.25em;
116
+
117
+ button {
118
+ display: inline-flex;
119
+ }
120
+ }
121
+
122
+ .subject {
123
+ margin: 0;
124
+ }
125
+
126
+ .thread {
127
+ flex: 1;
128
+ min-height: 0;
129
+ overflow-y: auto;
130
+ display: flex;
131
+ flex-direction: column;
132
+ }
133
+ </style>
@@ -0,0 +1,12 @@
1
+ import { fetchAPI } from '@axium/client/requests';
2
+ import { redirect } from '@sveltejs/kit';
3
+
4
+ export async function load({ parent, params }) {
5
+ const { session } = await parent();
6
+
7
+ if (!session) redirect(307, `/login?after=/email/${params.folder}/${params.id}`);
8
+
9
+ const thread = await fetchAPI('GET', 'email/:id/thread', {}, params.id);
10
+
11
+ return { thread, id: params.id, folder: params.folder, session };
12
+ }
@@ -0,0 +1,10 @@
1
+ import type { Composer } from '@axium/email/components';
2
+
3
+ /** Shared by the email layout with its pages */
4
+ export interface EmailApp {
5
+ /** The layout's Composer instance */
6
+ composer(): ReturnType<typeof Composer> | undefined;
7
+ toggleSidebar(): void;
8
+ }
9
+
10
+ export const emailApp = Symbol('email-app');