mirror of
https://github.com/jhillyerd/inbucket.git
synced 2025-12-18 18:17:03 +00:00
extension: BeforeMessageStored event to rewrite envelope (#417)
* extension: add InboundMessage type * manager: fires BeforeMessageStored event * manager: Reacts to BeforeMessageStored event response * manager: Apply BeforeMessageStored response fields to message Signed-off-by: James Hillyerd <james@hillyerd.com>
This commit is contained in:
@@ -6,7 +6,7 @@ import (
|
|||||||
|
|
||||||
// EventBroker maintains a list of listeners interested in a specific type
|
// EventBroker maintains a list of listeners interested in a specific type
|
||||||
// of event.
|
// of event.
|
||||||
type EventBroker[E any, R comparable] struct {
|
type EventBroker[E any, R interface{}] struct {
|
||||||
sync.RWMutex
|
sync.RWMutex
|
||||||
listenerNames []string // Ordered listener names.
|
listenerNames []string // Ordered listener names.
|
||||||
listenerFuncs []func(E) *R // Ordered listener functions.
|
listenerFuncs []func(E) *R // Ordered listener functions.
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ type AddressParts struct {
|
|||||||
Domain string
|
Domain string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InboundMessage contains the basic header and mailbox data for a message being received.
|
||||||
|
type InboundMessage struct {
|
||||||
|
Mailboxes []string
|
||||||
|
From mail.Address
|
||||||
|
To []mail.Address
|
||||||
|
Subject string
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
|
||||||
// MessageMetadata contains the basic header data for a message event.
|
// MessageMetadata contains the basic header data for a message event.
|
||||||
type MessageMetadata struct {
|
type MessageMetadata struct {
|
||||||
Mailbox string
|
Mailbox string
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type Events struct {
|
|||||||
AfterMessageDeleted AsyncEventBroker[event.MessageMetadata]
|
AfterMessageDeleted AsyncEventBroker[event.MessageMetadata]
|
||||||
AfterMessageStored AsyncEventBroker[event.MessageMetadata]
|
AfterMessageStored AsyncEventBroker[event.MessageMetadata]
|
||||||
BeforeMailAccepted EventBroker[event.AddressParts, bool]
|
BeforeMailAccepted EventBroker[event.AddressParts, bool]
|
||||||
|
BeforeMessageStored EventBroker[event.InboundMessage, event.InboundMessage]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Void indicates the event emitter will ignore any value returned by listeners.
|
// Void indicates the event emitter will ignore any value returned by listeners.
|
||||||
|
|||||||
@@ -69,39 +69,73 @@ func (s *StoreManager) Deliver(
|
|||||||
toaddr[i] = &torecip.Address
|
toaddr[i] = &torecip.Address
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
subject := header.Get("Subject")
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
tstamp := now.UTC().Format(recvdTimeFmt)
|
tstamp := now.UTC().Format(recvdTimeFmt)
|
||||||
|
|
||||||
// Deliver to mailboxes.
|
// Process inbound message through extensions.
|
||||||
for _, recip := range recipients {
|
mailboxes := make([]string, len(recipients))
|
||||||
if recip.ShouldStore() {
|
toAddrs := make([]mail.Address, len(recipients))
|
||||||
// Append recipient and timestamp to generated Recieved header.
|
for i, recip := range recipients {
|
||||||
recvd := fmt.Sprintf("%s for <%s>; %s\r\n", recvdHeader, recip.Address.Address, tstamp)
|
mailboxes[i] = recip.Mailbox
|
||||||
|
toAddrs[i] = recip.Address
|
||||||
|
}
|
||||||
|
|
||||||
// Deliver message.
|
inbound := &event.InboundMessage{
|
||||||
logger.Debug().Str("mailbox", recip.Mailbox).Msg("Delivering message")
|
Mailboxes: mailboxes,
|
||||||
delivery := &Delivery{
|
From: *fromaddr[0],
|
||||||
Meta: event.MessageMetadata{
|
To: toAddrs,
|
||||||
Mailbox: recip.Mailbox,
|
Subject: subject,
|
||||||
From: fromaddr[0],
|
Size: int64(len(source)),
|
||||||
To: toaddr,
|
}
|
||||||
Date: now,
|
|
||||||
Subject: header.Get("Subject"),
|
|
||||||
},
|
|
||||||
Reader: io.MultiReader(strings.NewReader(recvd), bytes.NewReader(source)),
|
|
||||||
}
|
|
||||||
id, err := s.Store.AddMessage(delivery)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error().Str("mailbox", recip.Mailbox).Err(err).Msg("Delivery failed")
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit message stored event.
|
extResult := s.ExtHost.Events.BeforeMessageStored.Emit(inbound)
|
||||||
event := delivery.Meta
|
if extResult == nil {
|
||||||
event.ID = id
|
// Use address policy to determine deliverable mailboxes.
|
||||||
s.ExtHost.Events.AfterMessageStored.Emit(&event)
|
mailboxes = mailboxes[:0]
|
||||||
|
for _, recip := range recipients {
|
||||||
|
if recip.ShouldStore() {
|
||||||
|
mailboxes = append(mailboxes, recip.Mailbox)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
inbound.Mailboxes = mailboxes
|
||||||
|
} else {
|
||||||
|
// Event response overrides destination mailboxes and address policy.
|
||||||
|
inbound = extResult
|
||||||
|
toaddr = make([]*mail.Address, len(inbound.To))
|
||||||
|
for i := range inbound.To {
|
||||||
|
toaddr[i] = &inbound.To[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliver to mailboxes.
|
||||||
|
for _, mb := range inbound.Mailboxes {
|
||||||
|
// Append recipient and timestamp to generated Recieved header.
|
||||||
|
recvd := fmt.Sprintf("%s for <%s>; %s\r\n", recvdHeader, mb, tstamp)
|
||||||
|
|
||||||
|
// Deliver message.
|
||||||
|
logger.Debug().Str("mailbox", mb).Msg("Delivering message")
|
||||||
|
delivery := &Delivery{
|
||||||
|
Meta: event.MessageMetadata{
|
||||||
|
Mailbox: mb,
|
||||||
|
From: &inbound.From,
|
||||||
|
To: toaddr,
|
||||||
|
Date: now,
|
||||||
|
Subject: inbound.Subject,
|
||||||
|
},
|
||||||
|
Reader: io.MultiReader(strings.NewReader(recvd), bytes.NewReader(source)),
|
||||||
|
}
|
||||||
|
id, err := s.Store.AddMessage(delivery)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error().Str("mailbox", mb).Err(err).Msg("Delivery failed")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit message stored event.
|
||||||
|
event := delivery.Meta
|
||||||
|
event.ID = id
|
||||||
|
s.ExtHost.Events.AfterMessageStored.Emit(&event)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package message_test
|
package message_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/mail"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/inbucket/inbucket/v3/pkg/config"
|
"github.com/inbucket/inbucket/v3/pkg/config"
|
||||||
"github.com/inbucket/inbucket/v3/pkg/extension"
|
"github.com/inbucket/inbucket/v3/pkg/extension"
|
||||||
|
"github.com/inbucket/inbucket/v3/pkg/extension/event"
|
||||||
"github.com/inbucket/inbucket/v3/pkg/message"
|
"github.com/inbucket/inbucket/v3/pkg/message"
|
||||||
"github.com/inbucket/inbucket/v3/pkg/policy"
|
"github.com/inbucket/inbucket/v3/pkg/policy"
|
||||||
"github.com/inbucket/inbucket/v3/pkg/test"
|
"github.com/inbucket/inbucket/v3/pkg/test"
|
||||||
@@ -48,16 +50,140 @@ func TestDeliverRespectsRecipientPolicy(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expect empty mailbox for nostore domain.
|
||||||
assertMessageCount(t, sm, "u1@nostore.com", 0)
|
assertMessageCount(t, sm, "u1@nostore.com", 0)
|
||||||
assertMessageCount(t, sm, "u2@example.com", 1)
|
assertMessageCount(t, sm, "u2@example.com", 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeliverEmitsBeforeMessageStoredEvent(t *testing.T) {
|
||||||
|
sm, extHost := testStoreManager()
|
||||||
|
|
||||||
|
// Register function to receive event.
|
||||||
|
var got *event.InboundMessage
|
||||||
|
extHost.Events.BeforeMessageStored.AddListener(
|
||||||
|
"test",
|
||||||
|
func(msg event.InboundMessage) *event.InboundMessage {
|
||||||
|
got = &msg
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// Deliver a message to trigger event.
|
||||||
|
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||||
|
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
|
||||||
|
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
|
||||||
|
if err := sm.Deliver(
|
||||||
|
origin,
|
||||||
|
[]*policy.Recipient{recip1, recip2},
|
||||||
|
"Received: xyz\n",
|
||||||
|
[]byte("From: from@example.com\nSubject: tsub\n\ntest email"),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NotNil(t, got, "BeforeMessageStored listener did not receive InboundMessage")
|
||||||
|
assert.Equal(t, []string{"u1@example.com", "u2@example.com"}, got.Mailboxes, "Mailboxes not equal")
|
||||||
|
assert.Equal(t, mail.Address{Name: "", Address: "from@example.com"}, got.From, "From not equal")
|
||||||
|
assert.Equal(t, []mail.Address{
|
||||||
|
{Name: "", Address: "u1@example.com"},
|
||||||
|
{Name: "", Address: "u2@example.com"},
|
||||||
|
}, got.To, "To not equal")
|
||||||
|
assert.Equal(t, "tsub", got.Subject, "Subject not equal")
|
||||||
|
assert.Equal(t, int64(48), got.Size, "Size not equal")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliverUsesBeforeMessageStoredEventResponseMailboxes(t *testing.T) {
|
||||||
|
sm, extHost := testStoreManager()
|
||||||
|
|
||||||
|
// Register function to receive event.
|
||||||
|
extHost.Events.BeforeMessageStored.AddListener(
|
||||||
|
"test",
|
||||||
|
func(msg event.InboundMessage) *event.InboundMessage {
|
||||||
|
// Listener rewrites destination mailboxes.
|
||||||
|
resp := msg
|
||||||
|
resp.Mailboxes = []string{"new1@example.com", "new2@nostore.com"}
|
||||||
|
return &resp
|
||||||
|
})
|
||||||
|
|
||||||
|
// Deliver a message to trigger event.
|
||||||
|
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||||
|
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
|
||||||
|
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
|
||||||
|
if err := sm.Deliver(
|
||||||
|
origin,
|
||||||
|
[]*policy.Recipient{recip1, recip2},
|
||||||
|
"Received: xyz\r\n",
|
||||||
|
[]byte("From: from@example.com\nSubject: tsub\n\ntest email"),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expect messages in only the mailboxes in the event response, and for the DiscardDomains
|
||||||
|
// policy to be ignored.
|
||||||
|
assertMessageCount(t, sm, "u1@example.com", 0)
|
||||||
|
assertMessageCount(t, sm, "u2@example.com", 0)
|
||||||
|
assertMessageCount(t, sm, "new1@example.com", 1)
|
||||||
|
assertMessageCount(t, sm, "new2@nostore.com", 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeliverUsesBeforeMessageStoredEventResponseFields(t *testing.T) {
|
||||||
|
sm, extHost := testStoreManager()
|
||||||
|
|
||||||
|
// Register function to receive event.
|
||||||
|
extHost.Events.BeforeMessageStored.AddListener(
|
||||||
|
"test",
|
||||||
|
func(msg event.InboundMessage) *event.InboundMessage {
|
||||||
|
// Listener rewrites destination mailboxes.
|
||||||
|
msg.Subject = "event subj"
|
||||||
|
msg.From = mail.Address{Address: "from@event.com", Name: "From Event"}
|
||||||
|
|
||||||
|
// Changing To does not affect destination mailbox(es).
|
||||||
|
msg.To = []mail.Address{
|
||||||
|
{Address: "to@event.com", Name: "To Event"},
|
||||||
|
{Address: "to2@event.com", Name: "To 2 Event"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size is read only, should have no effect.
|
||||||
|
msg.Size = 12345
|
||||||
|
|
||||||
|
return &msg
|
||||||
|
})
|
||||||
|
|
||||||
|
// Deliver a message to trigger event.
|
||||||
|
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||||
|
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
|
||||||
|
if err := sm.Deliver(
|
||||||
|
origin,
|
||||||
|
[]*policy.Recipient{recip1},
|
||||||
|
"Received: xyz\r\n",
|
||||||
|
[]byte("From: from@example.com\nSubject: tsub\n\ntest email"),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify single message stored.
|
||||||
|
metadata, err := sm.GetMetadata("u1@example.com")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, metadata, 1, "mailbox has incorrect # of messages")
|
||||||
|
got := metadata[0]
|
||||||
|
|
||||||
|
// Verify metadata fields were overridden by event response values.
|
||||||
|
assert.Equal(t, "event subj", got.Subject, "Subject didn't match")
|
||||||
|
assert.Equal(t, "from@event.com", got.From.Address, "From Address didn't match")
|
||||||
|
assert.Equal(t, "From Event", got.From.Name, "From Name didn't match")
|
||||||
|
require.Len(t, got.To, 2)
|
||||||
|
assert.Equal(t, "to@event.com", got.To[0].Address, "To Address didn't match")
|
||||||
|
assert.Equal(t, "To Event", got.To[0].Name, "To Name didn't match")
|
||||||
|
assert.Equal(t, "to2@event.com", got.To[1].Address, "To Address didn't match")
|
||||||
|
assert.Equal(t, "To 2 Event", got.To[1].Name, "To Name didn't match")
|
||||||
|
assert.NotEqual(t, 12345, got.Size, "Size is read only")
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeliverEmitsAfterMessageStoredEvent(t *testing.T) {
|
func TestDeliverEmitsAfterMessageStoredEvent(t *testing.T) {
|
||||||
sm, extHost := testStoreManager()
|
sm, extHost := testStoreManager()
|
||||||
|
|
||||||
listener := extHost.Events.AfterMessageStored.AsyncTestListener("manager", 1)
|
listener := extHost.Events.AfterMessageStored.AsyncTestListener("manager", 1)
|
||||||
|
|
||||||
// Attempt to deliver a message to generate event.
|
// Deliver a message to trigger event.
|
||||||
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||||
recip, _ := sm.AddrPolicy.NewRecipient("to@example.com")
|
recip, _ := sm.AddrPolicy.NewRecipient("to@example.com")
|
||||||
if err := sm.Deliver(
|
if err := sm.Deliver(
|
||||||
@@ -75,6 +201,46 @@ func TestDeliverEmitsAfterMessageStoredEvent(t *testing.T) {
|
|||||||
assertMessageCount(t, sm, "to@example.com", 1)
|
assertMessageCount(t, sm, "to@example.com", 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeliverBeforeAndAfterMessageStoredEvents(t *testing.T) {
|
||||||
|
sm, extHost := testStoreManager()
|
||||||
|
|
||||||
|
// Register function to receive Before event.
|
||||||
|
extHost.Events.BeforeMessageStored.AddListener(
|
||||||
|
"test",
|
||||||
|
func(msg event.InboundMessage) *event.InboundMessage {
|
||||||
|
// Listener rewrites destination mailboxes.
|
||||||
|
resp := msg
|
||||||
|
resp.Mailboxes = []string{"new1@example.com", "new2@example.com"}
|
||||||
|
return &resp
|
||||||
|
})
|
||||||
|
|
||||||
|
// After event listener.
|
||||||
|
listener := extHost.Events.AfterMessageStored.AsyncTestListener("manager", 2)
|
||||||
|
|
||||||
|
// Deliver a message to trigger events.
|
||||||
|
origin, _ := sm.AddrPolicy.ParseOrigin("from@example.com")
|
||||||
|
recip1, _ := sm.AddrPolicy.NewRecipient("u1@example.com")
|
||||||
|
recip2, _ := sm.AddrPolicy.NewRecipient("u2@example.com")
|
||||||
|
if err := sm.Deliver(
|
||||||
|
origin,
|
||||||
|
[]*policy.Recipient{recip1, recip2},
|
||||||
|
"Received: xyz\r\n",
|
||||||
|
[]byte("From: from@example.com\nSubject: tsub\n\ntest email"),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm mailbox names overriden by Before were sent to After event. Order is
|
||||||
|
// not guaranteed.
|
||||||
|
got1, err := listener()
|
||||||
|
require.NoError(t, err)
|
||||||
|
got2, err := listener()
|
||||||
|
require.NoError(t, err)
|
||||||
|
got := []string{got1.Mailbox, got2.Mailbox}
|
||||||
|
assert.Contains(t, got, "new1@example.com")
|
||||||
|
assert.Contains(t, got, "new2@example.com")
|
||||||
|
}
|
||||||
|
|
||||||
// Returns an empty StoreManager and extension Host pair, configured for testing.
|
// Returns an empty StoreManager and extension Host pair, configured for testing.
|
||||||
func testStoreManager() (*message.StoreManager, *extension.Host) {
|
func testStoreManager() (*message.StoreManager, *extension.Host) {
|
||||||
extHost := extension.NewHost()
|
extHost := extension.NewHost()
|
||||||
|
|||||||
@@ -589,7 +589,7 @@ func setupSMTPServer(ds storage.Store, extHost *extension.Host) *Server {
|
|||||||
|
|
||||||
// Create a server, don't start it.
|
// Create a server, don't start it.
|
||||||
addrPolicy := &policy.Addressing{Config: cfg}
|
addrPolicy := &policy.Addressing{Config: cfg}
|
||||||
manager := &message.StoreManager{Store: ds}
|
manager := &message.StoreManager{Store: ds, ExtHost: extHost}
|
||||||
|
|
||||||
return NewServer(cfg.SMTP, manager, addrPolicy, extHost)
|
return NewServer(cfg.SMTP, manager, addrPolicy, extHost)
|
||||||
}
|
}
|
||||||
|
|||||||
2
pkg/test/testdata/basic.golden
vendored
2
pkg/test/testdata/basic.golden
vendored
@@ -2,7 +2,7 @@ Mailbox: recipient
|
|||||||
From: <fromuser@inbucket.org>
|
From: <fromuser@inbucket.org>
|
||||||
To: [<recipient@inbucket.org>]
|
To: [<recipient@inbucket.org>]
|
||||||
Subject: basic subject
|
Subject: basic subject
|
||||||
Size: 217
|
Size: 204
|
||||||
|
|
||||||
BODY TEXT:
|
BODY TEXT:
|
||||||
Basic message.
|
Basic message.
|
||||||
|
|||||||
2
pkg/test/testdata/encodedheader.golden
vendored
2
pkg/test/testdata/encodedheader.golden
vendored
@@ -2,7 +2,7 @@ Mailbox: recipient
|
|||||||
From: X-äéß Y-äéß <fromuser@inbucket.org>
|
From: X-äéß Y-äéß <fromuser@inbucket.org>
|
||||||
To: [Test of ȇɲʢȯȡɪɴʛ <recipient@inbucket.org>]
|
To: [Test of ȇɲʢȯȡɪɴʛ <recipient@inbucket.org>]
|
||||||
Subject: Test of ȇɲʢȯȡɪɴʛ
|
Subject: Test of ȇɲʢȯȡɪɴʛ
|
||||||
Size: 351
|
Size: 338
|
||||||
|
|
||||||
BODY TEXT:
|
BODY TEXT:
|
||||||
Basic message.
|
Basic message.
|
||||||
|
|||||||
2
pkg/test/testdata/fullname.golden
vendored
2
pkg/test/testdata/fullname.golden
vendored
@@ -2,7 +2,7 @@ Mailbox: recipient
|
|||||||
From: From User <fromuser@inbucket.org>
|
From: From User <fromuser@inbucket.org>
|
||||||
To: [Rec I. Pient <recipient@inbucket.org>]
|
To: [Rec I. Pient <recipient@inbucket.org>]
|
||||||
Subject: basic subject
|
Subject: basic subject
|
||||||
Size: 246
|
Size: 233
|
||||||
|
|
||||||
BODY TEXT:
|
BODY TEXT:
|
||||||
Basic message.
|
Basic message.
|
||||||
|
|||||||
2
pkg/test/testdata/no-to-ipv4.golden
vendored
2
pkg/test/testdata/no-to-ipv4.golden
vendored
@@ -2,7 +2,7 @@ Mailbox: ip4recipient
|
|||||||
From: <fromuser@inbucket.org>
|
From: <fromuser@inbucket.org>
|
||||||
To: [<ip4recipient@[192.168.123.123]>]
|
To: [<ip4recipient@[192.168.123.123]>]
|
||||||
Subject: basic subject
|
Subject: basic subject
|
||||||
Size: 198
|
Size: 180
|
||||||
|
|
||||||
BODY TEXT:
|
BODY TEXT:
|
||||||
No-To message.
|
No-To message.
|
||||||
|
|||||||
2
pkg/test/testdata/no-to-ipv6.golden
vendored
2
pkg/test/testdata/no-to-ipv6.golden
vendored
@@ -2,7 +2,7 @@ Mailbox: ip6recipient
|
|||||||
From: <fromuser@inbucket.org>
|
From: <fromuser@inbucket.org>
|
||||||
To: [<ip6recipient@[IPv6:2001:0db8:85a3:0000:0000:8a2e:0370:7334]>]
|
To: [<ip6recipient@[IPv6:2001:0db8:85a3:0000:0000:8a2e:0370:7334]>]
|
||||||
Subject: basic subject
|
Subject: basic subject
|
||||||
Size: 227
|
Size: 180
|
||||||
|
|
||||||
BODY TEXT:
|
BODY TEXT:
|
||||||
No-To message.
|
No-To message.
|
||||||
|
|||||||
Reference in New Issue
Block a user