mirror of
https://github.com/jhillyerd/inbucket.git
synced 2025-12-17 17:47:03 +00:00
feat: Monitor tab dynamically updates when messages are deleted (#337)
* WIP: msghub handles deletes, UI does not yet display them Signed-off-by: James Hillyerd <james@hillyerd.com> * socket and UI support message deletes Signed-off-by: James Hillyerd <james@hillyerd.com> * use Delete naming for consistency Signed-off-by: James Hillyerd <james@hillyerd.com> --------- Signed-off-by: James Hillyerd <james@hillyerd.com>
This commit is contained in:
@@ -15,6 +15,7 @@ const opChanLen = 100
|
|||||||
// Listener receives the contents of the history buffer, followed by new messages
|
// Listener receives the contents of the history buffer, followed by new messages
|
||||||
type Listener interface {
|
type Listener interface {
|
||||||
Receive(msg event.MessageMetadata) error
|
Receive(msg event.MessageMetadata) error
|
||||||
|
Delete(mailbox string, id string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hub relays messages on to its listeners
|
// Hub relays messages on to its listeners
|
||||||
@@ -42,6 +43,12 @@ func New(historyLen int, extHost *extension.Host) *Hub {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
extHost.Events.AfterMessageDeleted.AddListener("msghub",
|
||||||
|
func(msg event.MessageMetadata) *extension.Void {
|
||||||
|
hub.Delete(msg.Mailbox, msg.ID)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
return hub
|
return hub
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +75,7 @@ func (hub *Hub) Dispatch(msg event.MessageMetadata) {
|
|||||||
h.history.Value = msg
|
h.history.Value = msg
|
||||||
h.history = h.history.Next()
|
h.history = h.history.Next()
|
||||||
|
|
||||||
// Deliver message to all listeners, removing listeners if they return an error
|
// Relay event to all listeners, removing listeners if they return an error.
|
||||||
for l := range h.listeners {
|
for l := range h.listeners {
|
||||||
if err := l.Receive(msg); err != nil {
|
if err := l.Receive(msg); err != nil {
|
||||||
delete(h.listeners, l)
|
delete(h.listeners, l)
|
||||||
@@ -78,6 +85,37 @@ func (hub *Hub) Dispatch(msg event.MessageMetadata) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete removes the message from the history buffer and instructs listeners to do the same.
|
||||||
|
func (hub *Hub) Delete(mailbox string, id string) {
|
||||||
|
hub.opChan <- func(h *Hub) {
|
||||||
|
if h.history == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locate and remove history entry.
|
||||||
|
p := h.history
|
||||||
|
end := p
|
||||||
|
for {
|
||||||
|
if next, ok := p.Next().Value.(event.MessageMetadata); ok {
|
||||||
|
if mailbox == next.Mailbox && id == next.ID {
|
||||||
|
p.Unlink(1) // Remove next node.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p = p.Next(); p == end {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relay event to all listeners, removing listeners if they return an error.
|
||||||
|
for l := range h.listeners {
|
||||||
|
if err := l.Delete(mailbox, id); err != nil {
|
||||||
|
delete(h.listeners, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// AddListener registers a listener to receive broadcasted messages.
|
// AddListener registers a listener to receive broadcasted messages.
|
||||||
func (hub *Hub) AddListener(l Listener) {
|
func (hub *Hub) AddListener(l Listener) {
|
||||||
hub.opChan <- func(h *Hub) {
|
hub.opChan <- func(h *Hub) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package msghub
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -13,8 +14,10 @@ import (
|
|||||||
// testListener implements the Listener interface, mock for unit tests
|
// testListener implements the Listener interface, mock for unit tests
|
||||||
type testListener struct {
|
type testListener struct {
|
||||||
messages []*event.MessageMetadata // received messages
|
messages []*event.MessageMetadata // received messages
|
||||||
wantMessages int // how many messages this listener wants to receive
|
deletes []string // received deletes
|
||||||
errorAfter int // when != 0, messages until Receive() begins returning error
|
wantEvents int // how many events this listener wants to receive
|
||||||
|
errorAfter int // when != 0, event count until Receive() begins returning error
|
||||||
|
gotEvents int
|
||||||
|
|
||||||
done chan struct{} // closed once we have received wantMessages
|
done chan struct{} // closed once we have received wantMessages
|
||||||
overflow chan struct{} // closed if we receive wantMessages+1
|
overflow chan struct{} // closed if we receive wantMessages+1
|
||||||
@@ -23,7 +26,8 @@ type testListener struct {
|
|||||||
func newTestListener(want int) *testListener {
|
func newTestListener(want int) *testListener {
|
||||||
l := &testListener{
|
l := &testListener{
|
||||||
messages: make([]*event.MessageMetadata, 0, want*2),
|
messages: make([]*event.MessageMetadata, 0, want*2),
|
||||||
wantMessages: want,
|
deletes: make([]string, 0, want*2),
|
||||||
|
wantEvents: want,
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
overflow: make(chan struct{}),
|
overflow: make(chan struct{}),
|
||||||
}
|
}
|
||||||
@@ -36,22 +40,29 @@ func newTestListener(want int) *testListener {
|
|||||||
// Receive a Message, store it in the messages slice, close applicable channels, and return an error
|
// Receive a Message, store it in the messages slice, close applicable channels, and return an error
|
||||||
// if instructed
|
// if instructed
|
||||||
func (l *testListener) Receive(msg event.MessageMetadata) error {
|
func (l *testListener) Receive(msg event.MessageMetadata) error {
|
||||||
|
l.gotEvents++
|
||||||
l.messages = append(l.messages, &msg)
|
l.messages = append(l.messages, &msg)
|
||||||
if len(l.messages) == l.wantMessages {
|
if l.gotEvents == l.wantEvents {
|
||||||
close(l.done)
|
close(l.done)
|
||||||
}
|
}
|
||||||
if len(l.messages) == l.wantMessages+1 {
|
if l.gotEvents == l.wantEvents+1 {
|
||||||
close(l.overflow)
|
close(l.overflow)
|
||||||
}
|
}
|
||||||
if l.errorAfter > 0 && len(l.messages) > l.errorAfter {
|
if l.errorAfter > 0 && l.gotEvents > l.errorAfter {
|
||||||
return fmt.Errorf("Too many messages")
|
return fmt.Errorf("Too many messages")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *testListener) Delete(mailbox string, id string) error {
|
||||||
|
l.gotEvents++
|
||||||
|
l.deletes = append(l.deletes, mailbox+"/"+id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// String formats the got vs wanted message counts
|
// String formats the got vs wanted message counts
|
||||||
func (l *testListener) String() string {
|
func (l *testListener) String() string {
|
||||||
return fmt.Sprintf("got %v messages, wanted %v", len(l.messages), l.wantMessages)
|
return fmt.Sprintf("got %v messages, wanted %v", len(l.messages), l.wantEvents)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHubNew(t *testing.T) {
|
func TestHubNew(t *testing.T) {
|
||||||
@@ -198,6 +209,55 @@ func TestHubHistoryReplay(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHubHistoryDelete(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
hub := New(100, extension.NewHost())
|
||||||
|
go hub.Start(ctx)
|
||||||
|
l1 := newTestListener(3)
|
||||||
|
hub.AddListener(l1)
|
||||||
|
|
||||||
|
// Broadcast 3 messages with no listeners
|
||||||
|
msgs := make([]event.MessageMetadata, 3)
|
||||||
|
for i := 0; i < len(msgs); i++ {
|
||||||
|
msgs[i] = event.MessageMetadata{
|
||||||
|
Mailbox: "hub",
|
||||||
|
ID: strconv.Itoa(i),
|
||||||
|
Subject: fmt.Sprintf("subj %v", i),
|
||||||
|
}
|
||||||
|
hub.Dispatch(msgs[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for messages (live)
|
||||||
|
select {
|
||||||
|
case <-l1.done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Timeout:", l1)
|
||||||
|
}
|
||||||
|
|
||||||
|
hub.Delete("hub", "1") // Delete a message
|
||||||
|
hub.Delete("zzz", "0") // Attempt to delete non-existent mailbox message
|
||||||
|
|
||||||
|
// Add a new listener, waits for 2 messages
|
||||||
|
l2 := newTestListener(2)
|
||||||
|
hub.AddListener(l2)
|
||||||
|
|
||||||
|
// Wait for messages (history)
|
||||||
|
select {
|
||||||
|
case <-l2.done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("Timeout:", l2)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := []string{"subj 0", "subj 2"}
|
||||||
|
for i := 0; i < len(want); i++ {
|
||||||
|
got := l2.messages[i].Subject
|
||||||
|
if got != want[i] {
|
||||||
|
t.Errorf("msg[%v].Subject == %q, want %q", i, got, want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHubHistoryReplayWrap(t *testing.T) {
|
func TestHubHistoryReplayWrap(t *testing.T) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// JSONMessageHeaderV1 contains the basic header data for a message
|
// JSONMessageHeaderV1 contains the basic header data for a message.
|
||||||
type JSONMessageHeaderV1 struct {
|
type JSONMessageHeaderV1 struct {
|
||||||
Mailbox string `json:"mailbox"`
|
Mailbox string `json:"mailbox"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -17,7 +17,7 @@ type JSONMessageHeaderV1 struct {
|
|||||||
Seen bool `json:"seen"`
|
Seen bool `json:"seen"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSONMessageV1 contains the same data as the header plus a JSONMessageBody
|
// JSONMessageV1 contains the same data as the header plus a JSONMessageBody.
|
||||||
type JSONMessageV1 struct {
|
type JSONMessageV1 struct {
|
||||||
Mailbox string `json:"mailbox"`
|
Mailbox string `json:"mailbox"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -33,7 +33,7 @@ type JSONMessageV1 struct {
|
|||||||
Attachments []*JSONMessageAttachmentV1 `json:"attachments"`
|
Attachments []*JSONMessageAttachmentV1 `json:"attachments"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSONMessageAttachmentV1 contains information about a MIME attachment
|
// JSONMessageAttachmentV1 contains information about a MIME attachment.
|
||||||
type JSONMessageAttachmentV1 struct {
|
type JSONMessageAttachmentV1 struct {
|
||||||
FileName string `json:"filename"`
|
FileName string `json:"filename"`
|
||||||
ContentType string `json:"content-type"`
|
ContentType string `json:"content-type"`
|
||||||
@@ -42,8 +42,15 @@ type JSONMessageAttachmentV1 struct {
|
|||||||
MD5 string `json:"md5"`
|
MD5 string `json:"md5"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSONMessageBodyV1 contains the Text and HTML versions of the message body
|
// JSONMessageBodyV1 contains the Text and HTML versions of the message body.
|
||||||
type JSONMessageBodyV1 struct {
|
type JSONMessageBodyV1 struct {
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
HTML string `json:"html"`
|
HTML string `json:"html"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JSONMonitorEventV1 contains events for the Inbucket mailbox and monitor tabs.
|
||||||
|
type JSONMonitorEventV1 struct {
|
||||||
|
// Event variant: `message-deleted`, `message-stored`.
|
||||||
|
Variant string `json:"variant"`
|
||||||
|
Header *JSONMessageHeaderV1 `json:"header"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ var upgrader = websocket.Upgrader{
|
|||||||
|
|
||||||
// msgListener handles messages from the msghub
|
// msgListener handles messages from the msghub
|
||||||
type msgListener struct {
|
type msgListener struct {
|
||||||
hub *msghub.Hub // Global message hub
|
hub *msghub.Hub // Global message hub.
|
||||||
c chan event.MessageMetadata // Queue of messages from Receive()
|
c chan *model.JSONMonitorEventV1 // Queue of incoming events.
|
||||||
mailbox string // Name of mailbox to monitor, "" == all mailboxes
|
mailbox string // Name of mailbox to monitor, "" == all mailboxes.
|
||||||
}
|
}
|
||||||
|
|
||||||
// newMsgListener creates a listener and registers it. Optional mailbox parameter will restrict
|
// newMsgListener creates a listener and registers it. Optional mailbox parameter will restrict
|
||||||
@@ -45,20 +45,45 @@ type msgListener struct {
|
|||||||
func newMsgListener(hub *msghub.Hub, mailbox string) *msgListener {
|
func newMsgListener(hub *msghub.Hub, mailbox string) *msgListener {
|
||||||
ml := &msgListener{
|
ml := &msgListener{
|
||||||
hub: hub,
|
hub: hub,
|
||||||
c: make(chan event.MessageMetadata, 100),
|
c: make(chan *model.JSONMonitorEventV1, 100),
|
||||||
mailbox: mailbox,
|
mailbox: mailbox,
|
||||||
}
|
}
|
||||||
hub.AddListener(ml)
|
hub.AddListener(ml)
|
||||||
return ml
|
return ml
|
||||||
}
|
}
|
||||||
|
|
||||||
// Receive handles an incoming message
|
// Receive handles an incoming message.
|
||||||
func (ml *msgListener) Receive(msg event.MessageMetadata) error {
|
func (ml *msgListener) Receive(msg event.MessageMetadata) error {
|
||||||
if ml.mailbox != "" && ml.mailbox != msg.Mailbox {
|
if ml.mailbox != "" && ml.mailbox != msg.Mailbox {
|
||||||
// Did not match mailbox name
|
// Did not match the watched mailbox name.
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
ml.c <- msg
|
|
||||||
|
// Enqueue for websocket.
|
||||||
|
ml.c <- &model.JSONMonitorEventV1{
|
||||||
|
Variant: "message-stored",
|
||||||
|
Header: metadataToHeader(&msg),
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete handles a deleted message.
|
||||||
|
func (ml *msgListener) Delete(mailbox string, id string) error {
|
||||||
|
if ml.mailbox != "" && ml.mailbox != mailbox {
|
||||||
|
// Did not match watched mailbox name.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue for websocket.
|
||||||
|
ml.c <- &model.JSONMonitorEventV1{
|
||||||
|
Variant: "message-deleted",
|
||||||
|
Header: &model.JSONMessageHeaderV1{
|
||||||
|
Mailbox: mailbox,
|
||||||
|
ID: id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,24 +129,14 @@ func (ml *msgListener) WSWriter(conn *websocket.Conn) {
|
|||||||
// Handle messages from hub until msgListener is closed
|
// Handle messages from hub until msgListener is closed
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case msg, ok := <-ml.c:
|
case event, ok := <-ml.c:
|
||||||
conn.SetWriteDeadline(time.Now().Add(writeWait))
|
conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||||
if !ok {
|
if !ok {
|
||||||
// msgListener closed, exit
|
// msgListener closed, exit
|
||||||
conn.WriteMessage(websocket.CloseMessage, []byte{})
|
conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
header := &model.JSONMessageHeaderV1{
|
if conn.WriteJSON(event) != nil {
|
||||||
Mailbox: msg.Mailbox,
|
|
||||||
ID: msg.ID,
|
|
||||||
From: stringutil.StringAddress(msg.From),
|
|
||||||
To: stringutil.StringAddressList(msg.To),
|
|
||||||
Subject: msg.Subject,
|
|
||||||
Date: msg.Date,
|
|
||||||
PosixMillis: msg.Date.UnixNano() / 1000000,
|
|
||||||
Size: msg.Size,
|
|
||||||
}
|
|
||||||
if conn.WriteJSON(header) != nil {
|
|
||||||
// Write failed
|
// Write failed
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -198,3 +213,16 @@ func MonitorMailboxMessagesV1(
|
|||||||
ml.WSReader(conn)
|
ml.WSReader(conn)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func metadataToHeader(msg *event.MessageMetadata) *model.JSONMessageHeaderV1 {
|
||||||
|
return &model.JSONMessageHeaderV1{
|
||||||
|
Mailbox: msg.Mailbox,
|
||||||
|
ID: msg.ID,
|
||||||
|
From: stringutil.StringAddress(msg.From),
|
||||||
|
To: stringutil.StringAddressList(msg.To),
|
||||||
|
Subject: msg.Subject,
|
||||||
|
Date: msg.Date,
|
||||||
|
PosixMillis: msg.Date.UnixNano() / 1000000,
|
||||||
|
Size: msg.Size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
44
ui/src/Data/MonitorEvent.elm
Normal file
44
ui/src/Data/MonitorEvent.elm
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
module Data.MonitorEvent exposing (MessageID, MonitorEvent(..), decoder)
|
||||||
|
|
||||||
|
import Data.MessageHeader as MessageHeader exposing (MessageHeader)
|
||||||
|
import Json.Decode exposing (Decoder, andThen, fail, field, string, succeed)
|
||||||
|
import Json.Decode.Pipeline exposing (required)
|
||||||
|
|
||||||
|
|
||||||
|
type alias MessageID =
|
||||||
|
{ mailbox : String
|
||||||
|
, id : String
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type MonitorEvent
|
||||||
|
= MessageStored MessageHeader
|
||||||
|
| MessageDeleted MessageID
|
||||||
|
|
||||||
|
|
||||||
|
decoder : Decoder MonitorEvent
|
||||||
|
decoder =
|
||||||
|
field "variant" string
|
||||||
|
|> andThen variantDecoder
|
||||||
|
|
||||||
|
|
||||||
|
variantDecoder : String -> Decoder MonitorEvent
|
||||||
|
variantDecoder variant =
|
||||||
|
case variant of
|
||||||
|
"message-deleted" ->
|
||||||
|
succeed MessageDeleted
|
||||||
|
|> required "header" messageIdDecoder
|
||||||
|
|
||||||
|
"message-stored" ->
|
||||||
|
succeed MessageStored
|
||||||
|
|> required "header" MessageHeader.decoder
|
||||||
|
|
||||||
|
unknown ->
|
||||||
|
fail <| "Unknown variant: " ++ unknown
|
||||||
|
|
||||||
|
|
||||||
|
messageIdDecoder : Decoder MessageID
|
||||||
|
messageIdDecoder =
|
||||||
|
succeed MessageID
|
||||||
|
|> required "mailbox" string
|
||||||
|
|> required "id" string
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
module Page.Monitor exposing (Model, Msg, init, update, view)
|
module Page.Monitor exposing (Model, Msg, init, update, view)
|
||||||
|
|
||||||
import Api
|
import Api
|
||||||
import Data.MessageHeader as MessageHeader exposing (MessageHeader)
|
import Data.MessageHeader exposing (MessageHeader)
|
||||||
|
import Data.MonitorEvent as MonitorEvent
|
||||||
import Data.Session exposing (Session)
|
import Data.Session exposing (Session)
|
||||||
import DateFormat as DF
|
import DateFormat as DF
|
||||||
import Effect exposing (Effect)
|
import Effect exposing (Effect)
|
||||||
@@ -68,8 +69,20 @@ update msg model =
|
|||||||
( { model | connected = False }, Effect.none )
|
( { model | connected = False }, Effect.none )
|
||||||
|
|
||||||
MessageReceived value ->
|
MessageReceived value ->
|
||||||
case D.decodeValue (MessageHeader.decoder |> D.at [ "detail" ]) value of
|
case D.decodeValue (MonitorEvent.decoder |> D.at [ "detail" ]) value of
|
||||||
Ok header ->
|
Ok event ->
|
||||||
|
case event of
|
||||||
|
MonitorEvent.MessageDeleted deleted ->
|
||||||
|
( { model
|
||||||
|
| messages =
|
||||||
|
List.filter
|
||||||
|
(\x -> x.mailbox /= deleted.mailbox || x.id /= deleted.id)
|
||||||
|
model.messages
|
||||||
|
}
|
||||||
|
, Effect.none
|
||||||
|
)
|
||||||
|
|
||||||
|
MonitorEvent.MessageStored header ->
|
||||||
( { model | messages = header :: List.take 500 model.messages }
|
( { model | messages = header :: List.take 500 model.messages }
|
||||||
, Effect.none
|
, Effect.none
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user