@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.
- package/LICENSE.md +157 -0
- package/README.md +40 -0
- package/db.json +83 -0
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/client/web_hook.d.ts +8 -0
- package/dist/client/web_hook.js +11 -0
- package/dist/common.d.ts +454 -0
- package/dist/common.js +127 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/server/api.d.ts +1 -0
- package/dist/server/api.js +138 -0
- package/dist/server/cli.d.ts +1 -0
- package/dist/server/cli.js +69 -0
- package/dist/server/db.d.ts +17 -0
- package/dist/server/db.js +2 -0
- package/dist/server/hooks.d.ts +4 -0
- package/dist/server/hooks.js +13 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.js +4 -0
- package/dist/server/send.d.ts +45 -0
- package/dist/server/send.js +221 -0
- package/dist/server/smtp.d.ts +2 -0
- package/dist/server/smtp.js +128 -0
- package/lib/AddressChips.svelte +107 -0
- package/lib/Composer.svelte +245 -0
- package/lib/MessageRow.svelte +160 -0
- package/lib/ThreadMessage.svelte +218 -0
- package/lib/index.ts +4 -0
- package/lib/tsconfig.json +13 -0
- package/locales/en.json +57 -0
- package/package.json +83 -0
- package/routes/email/+layout.svelte +59 -0
- package/routes/email/+layout.ts +17 -0
- package/routes/email/+page.ts +5 -0
- package/routes/email/[folder]/+page.svelte +53 -0
- package/routes/email/[folder]/+page.ts +16 -0
- package/routes/email/[folder]/[id]/+page.svelte +133 -0
- package/routes/email/[folder]/[id]/+page.ts +12 -0
- package/routes/email/context.ts +10 -0
- package/routes/email/email.css +143 -0
- 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"><{email.from.address}></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,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
|
+
}
|
package/locales/en.json
ADDED
|
@@ -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,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');
|