Implement JavaScript UI for attachments
This one is a bit of a doozy. A summary of the changes: - Session has grown storage for attachments which have been uploaded but not yet sent. - The list of attachments on a message is refcounted so that we can clean up the temporary files only after it's done with - i.e. after copying to Sent and after all of the SMTP attempts are done. - Abandoned attachments are cleared out on process shutdown. Future work: - Add a limit to the maximum number of pending attachments the user can have in the session. - Periodically clean out abandoned attachments?
This commit is contained in:
parent
4904207269
commit
a393429f01
8 changed files with 429 additions and 32 deletions
|
@ -101,4 +101,6 @@ func main() {
|
|||
e.Logger.Print("Waiting for work queues to finish...")
|
||||
s.Queue.Shutdown()
|
||||
e.Logger.Print("Shut down.")
|
||||
|
||||
s.Close()
|
||||
}
|
||||
|
|
|
@ -46,6 +46,8 @@ func registerRoutes(p *alps.GoPlugin) {
|
|||
p.GET("/compose", handleComposeNew)
|
||||
p.POST("/compose", handleComposeNew)
|
||||
|
||||
p.POST("/compose/attachment", handleComposeAttachment)
|
||||
|
||||
p.GET("/message/:mbox/:uid/reply", handleReply)
|
||||
p.POST("/message/:mbox/:uid/reply", handleReply)
|
||||
|
||||
|
@ -414,11 +416,19 @@ type composeOptions struct {
|
|||
// Send message, append it to the Sent mailbox, mark the original message as
|
||||
// answered
|
||||
func submitCompose(ctx *alps.Context, msg *OutgoingMessage, options *composeOptions) error {
|
||||
msg.Ref()
|
||||
msg.Ref()
|
||||
task := work.NewTask(func(_ context.Context) error {
|
||||
return ctx.Session.DoSMTP(func (c *smtp.Client) error {
|
||||
err := ctx.Session.DoSMTP(func (c *smtp.Client) error {
|
||||
return sendMessage(c, msg)
|
||||
})
|
||||
}).Retries(5)
|
||||
if err != nil {
|
||||
ctx.Logger().Printf("Error sending email: %v\n", err)
|
||||
}
|
||||
return err
|
||||
}).Retries(5).After(func(_ context.Context, task *work.Task) {
|
||||
msg.Unref()
|
||||
})
|
||||
err := ctx.Server.Queue.Enqueue(task)
|
||||
if err != nil {
|
||||
if _, ok := err.(alps.AuthError); ok {
|
||||
|
@ -440,6 +450,7 @@ func submitCompose(ctx *alps.Context, msg *OutgoingMessage, options *composeOpti
|
|||
if _, err := appendMessage(c, msg, mailboxSent); err != nil {
|
||||
return err
|
||||
}
|
||||
msg.Unref()
|
||||
if draft := options.Draft; draft != nil {
|
||||
if err := deleteMessage(c, draft.Mailbox, draft.Uid); err != nil {
|
||||
return err
|
||||
|
@ -533,6 +544,19 @@ func handleCompose(ctx *alps.Context, msg *OutgoingMessage, options *composeOpti
|
|||
msg.Attachments = append(msg.Attachments, &formAttachment{fh})
|
||||
}
|
||||
|
||||
uuids := ctx.FormValue("attachment-uuids")
|
||||
for _, uuid := range strings.Split(uuids, ",") {
|
||||
attachment := ctx.Session.PopAttachment(uuid)
|
||||
if attachment == nil {
|
||||
return fmt.Errorf("Unable to retrieve message attachments from session")
|
||||
}
|
||||
msg.Attachments = append(msg.Attachments, &refcountedAttachment{
|
||||
attachment.File,
|
||||
attachment.Form,
|
||||
0,
|
||||
})
|
||||
}
|
||||
|
||||
if saveAsDraft {
|
||||
err = ctx.Session.DoIMAP(func(c *imapclient.Client) error {
|
||||
copied, err := appendMessage(c, msg, mailboxDrafts)
|
||||
|
@ -575,6 +599,28 @@ func handleComposeNew(ctx *alps.Context) error {
|
|||
}, &composeOptions{})
|
||||
}
|
||||
|
||||
func handleComposeAttachment(ctx *alps.Context) error {
|
||||
reader, err := ctx.Request().MultipartReader()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get multipart form: %v", err)
|
||||
}
|
||||
form, err := reader.ReadForm(32 << 20) // 32 MB
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode multipart form: %v", err)
|
||||
}
|
||||
|
||||
var uuids []string
|
||||
for _, fh := range form.File["attachments"] {
|
||||
uuid, err := ctx.Session.PutAttachment(fh, form)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uuids = append(uuids, uuid)
|
||||
}
|
||||
|
||||
return ctx.JSON(http.StatusOK, &uuids)
|
||||
}
|
||||
|
||||
func unwrapIMAPAddressList(addrs []*imap.Address) []string {
|
||||
l := make([]string, len(addrs))
|
||||
for i, addr := range addrs {
|
||||
|
|
|
@ -53,6 +53,37 @@ func (att *formAttachment) Filename() string {
|
|||
return att.FileHeader.Filename
|
||||
}
|
||||
|
||||
type refcountedAttachment struct {
|
||||
*multipart.FileHeader
|
||||
*multipart.Form
|
||||
refs int
|
||||
}
|
||||
|
||||
func (att *refcountedAttachment) Open() (io.ReadCloser, error) {
|
||||
return att.FileHeader.Open()
|
||||
}
|
||||
|
||||
func (att *refcountedAttachment) MIMEType() string {
|
||||
// TODO: retain params, e.g. "charset"?
|
||||
t, _, _ := mime.ParseMediaType(att.FileHeader.Header.Get("Content-Type"))
|
||||
return t
|
||||
}
|
||||
|
||||
func (att *refcountedAttachment) Filename() string {
|
||||
return att.FileHeader.Filename
|
||||
}
|
||||
|
||||
func (att *refcountedAttachment) Ref() {
|
||||
att.refs += 1
|
||||
}
|
||||
|
||||
func (att *refcountedAttachment) Unref() {
|
||||
att.refs -= 1
|
||||
if att.refs == 0 {
|
||||
att.Form.RemoveAll()
|
||||
}
|
||||
}
|
||||
|
||||
type imapAttachment struct {
|
||||
Mailbox string
|
||||
Uid uint32
|
||||
|
@ -176,6 +207,22 @@ func (msg *OutgoingMessage) WriteTo(w io.Writer) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (msg *OutgoingMessage) Ref() {
|
||||
for _, a := range msg.Attachments {
|
||||
if a, ok := a.(*refcountedAttachment); ok {
|
||||
a.Ref()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (msg *OutgoingMessage) Unref() {
|
||||
for _, a := range msg.Attachments {
|
||||
if a, ok := a.(*refcountedAttachment); ok {
|
||||
a.Unref()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendMessage(c *smtp.Client, msg *OutgoingMessage) error {
|
||||
if err := c.Mail(msg.From, nil); err != nil {
|
||||
return fmt.Errorf("MAIL FROM failed: %v", err)
|
||||
|
|
|
@ -73,6 +73,10 @@ func newServer(e *echo.Echo, options *Options) (*Server, error) {
|
|||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) Close() {
|
||||
s.Sessions.Close()
|
||||
}
|
||||
|
||||
func parseUpstream(s string) (*url.URL, error) {
|
||||
if !strings.ContainsAny(s, ":/") {
|
||||
// This is a raw domain name, make it an URL with an empty scheme
|
||||
|
|
73
session.go
73
session.go
|
@ -5,6 +5,7 @@ import (
|
|||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
|
@ -13,6 +14,7 @@ import (
|
|||
imapclient "github.com/emersion/go-imap/client"
|
||||
"github.com/emersion/go-sasl"
|
||||
"github.com/emersion/go-smtp"
|
||||
"github.com/google/uuid"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
|
@ -54,6 +56,14 @@ type Session struct {
|
|||
|
||||
imapLocker sync.Mutex
|
||||
imapConn *imapclient.Client // protected by locker, can be nil
|
||||
|
||||
attachmentsLocker sync.Mutex
|
||||
attachments map[string]*Attachment // protected by attachmentsLocker
|
||||
}
|
||||
|
||||
type Attachment struct {
|
||||
File *multipart.FileHeader
|
||||
Form *multipart.Form
|
||||
}
|
||||
|
||||
func (s *Session) ping() {
|
||||
|
@ -117,6 +127,13 @@ func (s *Session) SetHTTPBasicAuth(req *http.Request) {
|
|||
|
||||
// Close destroys the session. This can be used to log the user out.
|
||||
func (s *Session) Close() {
|
||||
s.attachmentsLocker.Lock()
|
||||
defer s.attachmentsLocker.Unlock()
|
||||
|
||||
for _, f := range s.attachments {
|
||||
f.Form.RemoveAll()
|
||||
}
|
||||
|
||||
select {
|
||||
case <-s.closed:
|
||||
// This space is intentionally left blank
|
||||
|
@ -125,6 +142,41 @@ func (s *Session) Close() {
|
|||
}
|
||||
}
|
||||
|
||||
// Puts an attachment and returns a generated UUID
|
||||
func (s *Session) PutAttachment(in *multipart.FileHeader,
|
||||
form *multipart.Form) (string, error) {
|
||||
// TODO: Prevent users from uploading too many attachments, or too large
|
||||
//
|
||||
// Probably just set a cap on the maximum combined size of all files in the
|
||||
// user's session
|
||||
//
|
||||
// TODO: Figure out what to do if the user abandons the compose window
|
||||
// after adding some attachments
|
||||
id := uuid.New()
|
||||
s.attachmentsLocker.Lock()
|
||||
s.attachments[id.String()] = &Attachment{
|
||||
File: in,
|
||||
Form: form,
|
||||
}
|
||||
s.attachmentsLocker.Unlock()
|
||||
return id.String(), nil
|
||||
}
|
||||
|
||||
// Removes an attachment from the session. Returns nil if there was no such
|
||||
// attachment.
|
||||
func (s *Session) PopAttachment(uuid string) *Attachment {
|
||||
s.attachmentsLocker.Lock()
|
||||
defer s.attachmentsLocker.Unlock()
|
||||
|
||||
a, ok := s.attachments[uuid]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
delete(s.attachments, uuid)
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
// Store returns a store suitable for storing persistent user data.
|
||||
func (s *Session) Store() Store {
|
||||
return s.store
|
||||
|
@ -159,6 +211,12 @@ func newSessionManager(dialIMAP DialIMAPFunc, dialSMTP DialSMTPFunc, logger echo
|
|||
}
|
||||
}
|
||||
|
||||
func (sm *SessionManager) Close() {
|
||||
for _, s := range sm.sessions {
|
||||
s.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (sm *SessionManager) connectIMAP(username, password string) (*imapclient.Client, error) {
|
||||
c, err := sm.dialIMAP()
|
||||
if err != nil {
|
||||
|
@ -213,13 +271,14 @@ func (sm *SessionManager) Put(username, password string) (*Session, error) {
|
|||
}
|
||||
|
||||
s := &Session{
|
||||
manager: sm,
|
||||
closed: make(chan struct{}),
|
||||
pings: make(chan struct{}, 5),
|
||||
imapConn: c,
|
||||
username: username,
|
||||
password: password,
|
||||
token: token,
|
||||
manager: sm,
|
||||
closed: make(chan struct{}),
|
||||
pings: make(chan struct{}, 5),
|
||||
imapConn: c,
|
||||
username: username,
|
||||
password: password,
|
||||
token: token,
|
||||
attachments: make(map[string]*Attachment),
|
||||
}
|
||||
|
||||
s.store, err = newStore(s, sm.logger)
|
||||
|
|
146
themes/alps/assets/attachments.js
Normal file
146
themes/alps/assets/attachments.js
Normal file
|
@ -0,0 +1,146 @@
|
|||
let attachments = [];
|
||||
|
||||
const headers = document.querySelector(".create-update .headers");
|
||||
headers.classList.remove("no-js");
|
||||
|
||||
const attachmentsNode = document.getElementById("attachment-list");
|
||||
attachmentsNode.style.display = '';
|
||||
const helpNode = attachmentsNode.querySelector(".help");
|
||||
|
||||
const attachmentsInput = headers.querySelector("input[type='file']");
|
||||
attachmentsInput.removeAttribute("name");
|
||||
attachmentsInput.addEventListener("input", ev => {
|
||||
const files = attachmentsInput.files;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
attachFile(files[i]);
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener("drop", ev => {
|
||||
ev.preventDefault();
|
||||
const files = ev.dataTransfer.files;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
attachFile(files[i]);
|
||||
}
|
||||
});
|
||||
|
||||
const sendButton = document.getElementById("send-button"),
|
||||
saveButton = document.getElementById("save-button");
|
||||
|
||||
const XHR_UNSENT = 0,
|
||||
XHR_OPENED = 1,
|
||||
XHR_HEADERS_RECEIVED = 2,
|
||||
XHR_LOADING = 3,
|
||||
XHR_DONE = 4;
|
||||
|
||||
const attachmentUUIDsNode = document.getElementById("attachment-uuids");
|
||||
function updateState() {
|
||||
let complete = true;
|
||||
for (let i = 0; i < attachments.length; i++) {
|
||||
const a = attachments[i];
|
||||
const progress = a.node.querySelector(".progress");
|
||||
progress.style.width = `${Math.floor(a.progress * 100)}%`;
|
||||
complete &= a.progress === 1.0;
|
||||
if (a.progress === 1.0) {
|
||||
progress.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
if (complete) {
|
||||
sendButton.removeAttribute("disabled");
|
||||
saveButton.removeAttribute("disabled");
|
||||
} else {
|
||||
sendButton.setAttribute("disabled", "disabled");
|
||||
saveButton.setAttribute("disabled", "disabled");
|
||||
}
|
||||
|
||||
attachmentUUIDsNode.value = attachments.
|
||||
filter(a => a.progress === 1.0).
|
||||
map(a => a.uuid).
|
||||
join(",");
|
||||
}
|
||||
|
||||
function attachFile(file) {
|
||||
helpNode.remove();
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
const node = attachmentNodeFor(file);
|
||||
const attachment = {
|
||||
node: node,
|
||||
progress: 0,
|
||||
xhr: xhr,
|
||||
};
|
||||
attachments.push(attachment);
|
||||
attachmentsNode.appendChild(node);
|
||||
|
||||
let formData = new FormData();
|
||||
formData.append("attachments", file);
|
||||
|
||||
xhr.open("POST", "/compose/attachment");
|
||||
xhr.upload.addEventListener("progress", ev => {
|
||||
attachment.progress = ev.loaded / ev.total;
|
||||
updateState();
|
||||
});
|
||||
xhr.addEventListener("load", () => {
|
||||
// TODO: Handle errors
|
||||
const resp = JSON.parse(xhr.responseText);
|
||||
attachment.uuid = resp[0];
|
||||
updateState();
|
||||
});
|
||||
xhr.send(formData);
|
||||
|
||||
updateState();
|
||||
}
|
||||
|
||||
function attachmentNodeFor(file) {
|
||||
const node = document.createElement("div"),
|
||||
progress = document.createElement("span"),
|
||||
filename = document.createElement("span"),
|
||||
size = document.createElement("span"),
|
||||
button = document.createElement("button");
|
||||
node.classList.add("upload");
|
||||
|
||||
progress.classList.add("progress");
|
||||
node.appendChild(progress);
|
||||
|
||||
filename.classList.add("filename");
|
||||
filename.innerText = file.name;
|
||||
node.appendChild(filename);
|
||||
|
||||
size.classList.add("size");
|
||||
size.innerText = formatSI(file.size) + "B";
|
||||
node.appendChild(size);
|
||||
|
||||
button.innerHTML = "×";
|
||||
node.appendChild(button);
|
||||
return node;
|
||||
}
|
||||
|
||||
// via https://github.com/ThomWright/format-si-prefix; MIT license
|
||||
// Copyright (c) 2015 Thom Wright
|
||||
const PREFIXES = {
|
||||
'24': 'Y', '21': 'Z', '18': 'E', '15': 'P', '12': 'T', '9': 'G', '6': 'M',
|
||||
'3': 'k', '0': '', '-3': 'm', '-6': 'µ', '-9': 'n', '-12': 'p', '-15': 'f',
|
||||
'-18': 'a', '-21': 'z', '-24': 'y'
|
||||
};
|
||||
|
||||
function formatSI(num) {
|
||||
if (num === 0) {
|
||||
return '0';
|
||||
}
|
||||
let sig = Math.abs(num); // significand
|
||||
let exponent = 0;
|
||||
while (sig >= 1000 && exponent < 24) {
|
||||
sig /= 1000;
|
||||
exponent += 3;
|
||||
}
|
||||
while (sig < 1 && exponent > -24) {
|
||||
sig *= 1000;
|
||||
exponent -= 3;
|
||||
}
|
||||
const signPrefix = num < 0 ? '-' : '';
|
||||
if (sig > 1000) {
|
||||
return signPrefix + sig.toFixed(0) + PREFIXES[exponent];
|
||||
}
|
||||
return signPrefix + parseFloat(sig.toPrecision(3)) + PREFIXES[exponent];
|
||||
}
|
|
@ -166,19 +166,89 @@ main.create-update {
|
|||
}
|
||||
|
||||
main.create-update { flex: 1 auto; padding: 1rem; }
|
||||
main.create-update form { flex: 1 auto; display: flex; flex-direction: column; }
|
||||
main.create-update form label { margin-top: 5px; }
|
||||
|
||||
/* TODO: CSS grid this */
|
||||
main.create-update form label span {
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
min-width: 150px;
|
||||
main.create-update form {
|
||||
flex: 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main.create-update form input { width: 80%; }
|
||||
main.create-update form textarea { flex: 1 auto; resize: none; margin-top: 1rem; }
|
||||
main.create-update h1 { margin: 0; }
|
||||
main.create-update .headers {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-rows: auto auto auto auto;
|
||||
grid-gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
main.create-update .headers.no-js {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
main.create-update .headers label {
|
||||
grid-column-start: 1;
|
||||
}
|
||||
|
||||
main.create-update .headers input {
|
||||
grid-column-start: 2;
|
||||
grid-column-end: 3;
|
||||
}
|
||||
|
||||
main.create-update #attachment-list {
|
||||
grid-column-start: 3;
|
||||
grid-row-start: 1;
|
||||
grid-row-end: 5;
|
||||
|
||||
width: 25rem;
|
||||
height: 100%;
|
||||
background: #eee;
|
||||
overflow-y: scroll;
|
||||
border: 1px solid #eee;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main.create-update #attachment-list .help {
|
||||
text-align: center;
|
||||
color: #555;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
main.create-update #attachment-list .upload {
|
||||
width: calc(100% - 1rem);
|
||||
position: relative;
|
||||
display: flex;
|
||||
margin: 0.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: white;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
main.create-update #attachment-list *:not(:last-child) {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
main.create-update #attachment-list .upload .filename {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
main.create-update #attachment-list .upload button {
|
||||
padding: inherit;
|
||||
}
|
||||
|
||||
main.create-update #attachment-list .upload .progress {
|
||||
position: absolute;
|
||||
height: 5px;
|
||||
background: #50C878;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
main.create-update textarea {
|
||||
flex: 1 auto;
|
||||
resize: none;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
main table { border-collapse: collapse; width: 100%; border: 1px solid #eee; }
|
||||
main table td {
|
||||
|
@ -683,6 +753,12 @@ button:hover,
|
|||
text-decoration: none;
|
||||
}
|
||||
|
||||
button[disabled], button[disabled]:hover {
|
||||
color: #555;
|
||||
background-color: #c5c5c5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.button:active,
|
||||
button:active,
|
||||
.button-link:active {
|
||||
|
|
|
@ -8,15 +8,14 @@
|
|||
<div class="container">
|
||||
<main class="create-update">
|
||||
|
||||
<form method="post" action="" enctype="multipart/form-data">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="in_reply_to" value="{{.Message.InReplyTo}}">
|
||||
|
||||
<label>
|
||||
<span>From</span>
|
||||
<div class="headers no-js">
|
||||
<label>From</label>
|
||||
<input type="email" name="from" id="from" value="{{.Message.From}}" />
|
||||
</label>
|
||||
<label>
|
||||
<span>To</span>
|
||||
|
||||
<label>To</label>
|
||||
<input
|
||||
type="email"
|
||||
name="to"
|
||||
|
@ -26,9 +25,27 @@
|
|||
list="emails"
|
||||
{{ if not .Message.To }} autofocus{{ end }}
|
||||
/>
|
||||
</label>
|
||||
<label><span>Subject</span><input type="text" name="subject" id="subject" value="{{.Message.Subject}}" {{ if .Message.To }} autofocus{{ end }}/></label>
|
||||
<label><span>Attachments</span><input type="file" name="attachments" id="attachments" multiple></label>
|
||||
|
||||
<label>Subject</label>
|
||||
<input type="text" name="subject" id="subject" value="{{.Message.Subject}}" {{ if .Message.To }} autofocus{{ end }}/>
|
||||
|
||||
<label>Attachments</label>
|
||||
<input type="file" name="attachments" id="attachments" multiple>
|
||||
|
||||
<div id="attachment-list" style="display: none;">
|
||||
<div class="help">Drag and drop attachments here</div>
|
||||
<!--
|
||||
<div class="upload">
|
||||
<span class="progress"></span>
|
||||
<span class="filename">foobar.pdf</span>
|
||||
<span class="size">1234 KiB</span>
|
||||
<button>×</button>
|
||||
</div>
|
||||
-->
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="attachment-uuids" name="attachment-uuids" value="" />
|
||||
</div>
|
||||
<!-- TODO: list of previous attachments (needs design) -->
|
||||
|
||||
<textarea name="text" class="body">{{.Message.Text}}</textarea>
|
||||
|
@ -40,8 +57,8 @@
|
|||
</datalist>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit">Send Message</button>
|
||||
<button type="submit" name="save_as_draft">Save as draft</button>
|
||||
<button id="send-button" type="submit">Send Message</button>
|
||||
<button id="save-button" type="submit" name="save_as_draft">Save as draft</button>
|
||||
<a class="button-link" href="/mailbox/INBOX">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -49,6 +66,6 @@
|
|||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/themes/alps/assets/attachments.js"></script>
|
||||
|
||||
{{template "foot.html"}}
|
||||
|
|
Loading…
Reference in a new issue