diff --git a/examples/gno.land/r/demo/mail/DAPP_VS_SYSTEM.txt b/examples/gno.land/r/demo/mail/DAPP_VS_SYSTEM.txt new file mode 100644 index 00000000000..dfee5fce22b --- /dev/null +++ b/examples/gno.land/r/demo/mail/DAPP_VS_SYSTEM.txt @@ -0,0 +1,27 @@ +This is a very specific app. + +But the app could also be envisionned to sit on a system instead. +If mail is a particular type of message, and a mail dapp is a user +of a more universal messaging thing, then it brings some advantages. + +This mail app would then take just a few lines. +And it would change the way we do things. + +For example, orders in p/demo/releases, DAO orders and so on could be other kind of +messages. This way, it would be possible to search and have generic tools that +operate on all point-to-point messages without reimplementing everything all +the time. + +You could search inside the release notes emitted by a certain user, +during a certain year. Or searching at the orders emitted by a DAO that contain +a certain text. You could watch for a certain filter on a certain type of +message coming from some organization. (While this can end-up being costly, we +can imagine ways it can be made profitable, or growth-sustainable, instead) + +Therefore it would be possible to realize more interoperable systems. + +But not all point-to-point system have the same requirements. +Some are producer-consumer, some are archive systems with a certain retention. +Meaning a certain amount of configuration is required for the dapps. + +So it's a complex but interesting question. diff --git a/examples/gno.land/r/demo/mail/DESIGN.md b/examples/gno.land/r/demo/mail/DESIGN.md new file mode 100644 index 00000000000..9ab24b648c2 --- /dev/null +++ b/examples/gno.land/r/demo/mail/DESIGN.md @@ -0,0 +1,26 @@ +# Design choices + +At this point + +* mailbox automatically and freely created on both sides when a mail is sent. +* sender needs to pay stamps, they would go to the realm which will be managed by a DAO to finance new tool +* mails are unencrypted + +I assume people would actually not mind paying for stamps if price is reasonnable. +for example it was atom, at 1atom=12usd, I would be okay to pay 0.25$=0.02atom per +mail sent (The time spent redacting a mail costs more than the stamps; and +it would feel less of a waste when I click the button if it costed money). + +Do we still need an admin? Yes. Maybe see p/acl +Is it a public service? I think it can be a DAO. So semi-public? + +Encryption is a huge topic for GNO and not possible at the moment. + +## Various possible ideas + +Things like: + +TUI - there is nothing in a Render() now +Respond to mails +Mail attachment + diff --git a/examples/gno.land/r/demo/mail/README.txt b/examples/gno.land/r/demo/mail/README.txt new file mode 100644 index 00000000000..7b0a6efa9fd --- /dev/null +++ b/examples/gno.land/r/demo/mail/README.txt @@ -0,0 +1,12 @@ +GNO mail +a demo + +📪 📪 📪 📪 📪 📪 📪 📪 📪 📪 📪 📪 📪 📪 📪 📪 📪 📪 📪 + +A mailbox connects you to the GNO mail system. +One day maybe GNO mail v.419 can be +used all over the Cosmos. + +📪 📪 📪 📪 📪 📪 📪 📪 📪 📪 📪 📪 📪 📪 📪 📪 📪 📪 📪 + + diff --git a/examples/gno.land/r/demo/mail/container.gno b/examples/gno.land/r/demo/mail/container.gno new file mode 100644 index 00000000000..ef8b5ac2ffa --- /dev/null +++ b/examples/gno.land/r/demo/mail/container.gno @@ -0,0 +1,27 @@ +package mail + +// A private structure to hold mails +// current impl. is an array +// RFC Would a ringbuffer with a certain capacity, and a "keep" feature +// be better, I wonder. + +type container struct { + mails []*Mail +} + +func newContainer() *container { return &container{[]*Mail{}} } +func newContainerWith(a []*Mail) *container { return &container{a} } + +func (ctr *container) push(mail *Mail) { + ctr.mails = append(ctr.mails, mail) +} + +func (ctr *container) filter(preds ...Predicate) []*Mail { + a := []*Mail{} + for _, mail := range ctr.mails { + if mail.Satisfies(preds) { + a = append(a, mail) + } + } + return a +} diff --git a/examples/gno.land/r/demo/mail/files/mail_filetest.gno b/examples/gno.land/r/demo/mail/files/mail_filetest.gno new file mode 100644 index 00000000000..caee747495e --- /dev/null +++ b/examples/gno.land/r/demo/mail/files/mail_filetest.gno @@ -0,0 +1,83 @@ +// PKGPATH: gno.land/r/demo/mail_test +package mail_test + +import ( + "std" + "strconv" + "strings" + "time" + + "gno.land/p/demo/avl" + "gno.land/p/demo/testutils" + "gno.land/r/demo/mail" +) + +// Send mail, a great movie - SYNOPSIS +// Starring "dude" (the OrigCaller) and "sanders" +// ------------------------------------------------------------------ +// Let there be some dude who wants to send mail: +// .dude gets some money, so he can pay for stamps +// .dude sent a mail to sanders +// .now he has a mailbox, so does sanders +// .mail got received by sanders +// .mail also gets saved in the outbox +// .the realm got money from the stamps +// .sanders still has 0gno + +// TODO send more emails. +// TODO mail.Id is not checked + +func init() {} + +func main() { + var ( + dude = testutils.TestAddress("dude") + sanders = testutils.TestAddress("sanders") + banker = std.GetBanker(std.BankerTypeReadonly) + ) + std.TestSetOrigCaller(dude) + std.TestSetOrigSend(std.Coins{{mail.Fee.Denom, mail.Fee.Amount}}, nil) + coinsBefore := banker.GetCoins(std.GetOrigPkgAddr()).AmountOf(mail.Fee.Denom) + coins := std.Coins{{mail.Fee.Denom, 10 * mail.Fee.Amount}} + std.TestIssueCoins(dude, coins) + if banker.GetCoins(dude).AmountOf(mail.Fee.Denom) == 0 { + panic(".dude luckily gets some money, so he can pay for stamps") + } + // ------------------------------------------ + mail.SendMail(sanders, "hi", "this is the dude") + // ------------------------------------------ + if !mail.HasAddressMailbox(dude) { + panic(".now he has a mailbox") + } + if !mail.HasAddressMailbox(sanders) { + panic(".and sanders also has a mailbox") + } + dudebox := mail.MustMailbox(dude) + sandersbox := mail.MustMailbox(sanders) + if len(sandersbox.Find(mail.RecipientIs{sanders})) < 1 { + panic(".mail got received by sanders") + } + if len(sandersbox.Find(mail.RecipientIs{sanders}, mail.SenderIs{dude})) < 1 { + panic(".mail got received by sanders") + } + if len(dudebox.Find(mail.SenderIs{dude}, mail.RecipientIs{sanders})) < 1 { + panic(".mail to sanders is in large outbox, by date") + } + if len(sandersbox.ReceivedFrom(dude)) < 1 { + panic(".mail got received by sanders") + } + if len(dudebox.SentTo(sanders)) < 1 { + panic(".mail also saved in outbox") + } + coinsAfter := banker.GetCoins(std.GetOrigPkgAddr()).AmountOf(mail.Fee.Denom) + if coinsAfter-coinsBefore != mail.Fee.Amount { + panic(".realm received the expected amount from the stamps: recvd=" + strconv.Itoa(int(coinsAfter-coinsBefore))) + } + if banker.GetCoins(sanders).AmountOf(mail.Fee.Denom) != 0 { + panic(".sanders still has 0gno") + } +} + +// Output: + +// Error: diff --git a/examples/gno.land/r/demo/mail/mail.gno b/examples/gno.land/r/demo/mail/mail.gno new file mode 100644 index 00000000000..333daeb9205 --- /dev/null +++ b/examples/gno.land/r/demo/mail/mail.gno @@ -0,0 +1,41 @@ +package mail + +import ( + "std" + "strings" + "time" + + "gno.land/p/demo/ufmt" +) + +type Mail struct { + id int + topic string + body string + time time.Time + sender std.Address + recipient std.Address +} + +func newMail(topic, body string, sender std.Address, recipient std.Address) *Mail { + return &Mail{counter + 1, topic, body, time.Now(), sender, recipient} +} + +func (mail *Mail) Satisfies(preds []Predicate) bool { + for _, pred := range preds { + if !pred.Satisfy(mail) { + return false + } + } + return true +} + +func (mail *Mail) Contains(s string) bool { + s = strings.ToLower(s) + return strings.Contains(strings.ToLower(mail.topic), s) || strings.Contains(strings.ToLower(mail.body), s) +} + +// time key for avl, like 1234567890_ +func (mail *Mail) GetTimeKey() string { + return ufmt.Sprintf("%d_%d", time.Now().Unix(), counter) +} diff --git a/examples/gno.land/r/demo/mail/mailbox.gno b/examples/gno.land/r/demo/mail/mailbox.gno new file mode 100644 index 00000000000..03faddf2674 --- /dev/null +++ b/examples/gno.land/r/demo/mail/mailbox.gno @@ -0,0 +1,248 @@ +package mail + +import ( + "std" + "time" + + "gno.land/p/demo/avl" +) + +/* +A mailbox connects you to the GNO mail system. +One day maybe GNO mail v.419 can be +used all over the Cosmos. +*/ +type Mailbox struct { + address std.Address + outgoingByRecipient avl.Tree // recipient Address -> *container + incomingBySender avl.Tree // sender Address -> *container + mails avl.Tree // time -> *Mail +} + +// Must be called by OrigCaller. +// This automatically creates mailboxes if necessary. +func SendMail(recipient std.Address, topic, body string) { + std.AssertOriginCall() + caller := std.GetCallerAt(2) + if caller != std.GetOrigCaller() { + panic("should not happen") + } + sender := std.GetOrigCaller() + coinsSent := std.GetOrigSend() + if len(coinsSent) == 0 { + panic("you didn't send any change for the stamps") + } else if len(coinsSent) > 1 { + panic("please send only one type of coins") + } else if coinsSent[0].Denom != "ugnot" { + panic("only ugnot is accepted at the moment") + } else if coinsSent[0].Amount < Fee.Amount { + panic("you didn't send enought for the stamps.") + } else { + boxSnd := mailboxOrCreate(sender) + boxRec := mailboxOrCreate(recipient) + if boxRec == nil || boxSnd == nil { + panic("should never happen") + } else { + banker := std.GetBanker(std.BankerTypeRealmIssue) + banker.SendCoins(std.GetOrigCaller(), std.GetOrigPkgAddr(), coinsSent) + mail := newMail(topic, body, sender, recipient) + boxSnd.pushOutgoing(recipient, mail) + boxRec.pushIncoming(sender, mail) + counter++ // realm state + } + } +} + +func MustMailbox(addr std.Address) *Mailbox { + if m := GetMailboxOf(addr); m == nil { + panic(addr.String() + " must have a mailbox") + } else { + return m + } +} + +func GetMailboxOf(addr std.Address) *Mailbox { + if box, exists := addr2Mailbox.Get(addr.String()); !exists { + return nil + } else if box.(*Mailbox).address != addr { + panic("should not happen") + } else { + return box.(*Mailbox) + } +} + +func (box *Mailbox) ReceivedFrom(sender std.Address, preds ...Predicate) []*Mail { + if ctr, exists := box.incomingBySender.Get(sender.String()); exists { + return ctr.(*container).filter(preds...) + } + return []*Mail{} +} + +func (box *Mailbox) SentTo(recipient std.Address, preds ...Predicate) []*Mail { + if ctr, exists := box.outgoingByRecipient.Get(recipient.String()); exists { + return ctr.(*container).filter(preds...) + } + return []*Mail{} +} + +// Find mails matching predicates. +// Matches should naturally be returned sorted by date. +func (box *Mailbox) Find(preds ...Predicate) []*Mail { + // We have 3 stores (by time, by sender, by recipient) + // To optimize, count: + // - the number of SenderIs{} + // - the number of RecipientIs{} + // - identify time min + // - time max + // If len senderis or recipientis >1, return [] + // If len == 1 && we don't have a narrow timerange, + // scan outgoingByRecipient or incomingBySender if it makes sense + // Otherwise + // use time min and max to restrain the traversing, then filter + isEmpty, recipientIs, senderIs, timeMin, timeMax, remainingPreds := runHeuristicForFind(preds) + const narrowInSeconds = 3600 * 24 * 7 // when narrow duration it is faster to scan box.mails + narrow := !timeMax.IsZero() && !timeMin.IsZero() && timeMax.Sub(timeMin).Seconds() < narrowInSeconds + if isEmpty { + return []*Mail{} + } else if !narrow && recipientIs != "" && recipientIs != box.address { + return box.SentTo(recipientIs, preds...) + } else if !narrow && senderIs != "" && senderIs != box.address { + return box.ReceivedFrom(senderIs, preds...) + } else { + var a []*Mail + min := "" + max := "" + if !timeMin.IsZero() { + min = timeMin.String() + } + if !timeMax.IsZero() { + max = timeMax.String() + } + box.mails.Iterate(min, max, func(node *avl.Node) bool { + mail := node.Value().(*Mail) + if mail.Satisfies(remainingPreds) { + a = append(a, mail) + } + return false + }) + return a + } +} + +func runHeuristicForFind(preds []Predicate) ( + isEmpty bool, // true means must exit early (e.g. Sender is Joe && Sender is Jack => ⊘) + recipientIs std.Address, + senderIs std.Address, + minTime time.Time, + maxTime time.Time, + remainingPreds []Predicate, // same as preds, minus what is useless and would only slow down the scan +) { + var timeMin time.Time + var timeMax time.Time + for _, pred := range preds { + switch v := pred.(type) { + case SenderIs: + if senderIs == "" { + senderIs = v.sender + } else { + if senderIs != v.sender { + isEmpty = true + return + } + } + case RecipientIs: + if recipientIs == "" { + recipientIs = v.recipient + } else { + if recipientIs != v.recipient { + isEmpty = true + return + } + } + case TimeBefore: + if timeMin.IsZero() || v.t.Before(timeMin) { + timeMin = v.t + } + remainingPreds = append(remainingPreds, v) + case TimeAfter: + if timeMax.IsZero() || v.t.After(timeMax) { + timeMax = v.t + } + remainingPreds = append(remainingPreds, v) + case TimeBetween: + if timeMin.IsZero() || v.min.Before(timeMin) { + timeMin = v.min + } + if timeMax.IsZero() || v.max.After(timeMax) { + timeMax = v.max + } + remainingPreds = append(remainingPreds, v) + default: + remainingPreds = append(remainingPreds, v) + } + } + if !timeMax.IsZero() && !timeMin.IsZero() && !timeMax.After(timeMin) { + isEmpty = true + } + return +} + +// mostly for tests +func HasAddressMailbox(addr std.Address) bool { return addr2Mailbox.Has(addr.String()) } + +// --- unexported --- + +func newMailbox(addr std.Address) *Mailbox { + return &Mailbox{ + address: addr, + outgoingByRecipient: avl.Tree{}, + incomingBySender: avl.Tree{}, + mails: avl.Tree{}, + } +} + +func mailboxOrCreate(addr std.Address) *Mailbox { + if m := GetMailboxOf(addr); m != nil { + return m + } + return installMailboxFor(addr) +} + +func installMailboxFor(addr std.Address) *Mailbox { + box := GetMailboxOf(addr) + if box == nil { + box = newMailbox(addr) + addr2Mailbox.Set(addr.String(), box) + } + return box +} + +func (box *Mailbox) pushIncoming(sender std.Address, mail *Mail) { + box.mails.Set(mail.GetTimeKey(), mail) + // incomingBySender (a tree) + var ctr *container + if x, exists := box.incomingBySender.Get(sender.String()); exists { + ctr = x.(*container) + } else { + ctr = newContainer() + } + ctr.push(mail) + inbox := box.incomingBySender + inbox.Set(sender.String(), ctr) + box.incomingBySender = inbox +} + +func (box *Mailbox) pushOutgoing(recipient std.Address, mail *Mail) { + box.mails.Set(mail.GetTimeKey(), mail) + // outgoingByRecipient (a tree) + var ctr *container + if x, exists := box.outgoingByRecipient.Get(recipient.String()); exists { + ctr = x.(*container) + } else { + ctr = newContainer() + } + ctr.push(mail) + outbox := box.outgoingByRecipient + outbox.Set(recipient.String(), ctr) + box.outgoingByRecipient = outbox +} diff --git a/examples/gno.land/r/demo/mail/predicates.gno b/examples/gno.land/r/demo/mail/predicates.gno new file mode 100644 index 00000000000..e497168ae41 --- /dev/null +++ b/examples/gno.land/r/demo/mail/predicates.gno @@ -0,0 +1,59 @@ +package mail + +import ( + "std" + "strings" + "time" +) + +type Predicate interface { + Satisfy(mail *Mail) bool +} + +type ( + SenderIs struct{ sender std.Address } + RecipientIs struct{ recipient std.Address } + AndPred struct{ a []Predicate } + Contains struct{ s string } + ContainsEither struct{ a []string } + TopicContains struct{ s string } + Not struct{ pred Predicate } + TimeBefore struct{ t time.Time } // <= + TimeAfter struct{ t time.Time } // >= + TimeBetween struct { + min time.Time + max time.Time + } +) + +// `And(Contains("virus"), TimeBefore(covid))` +func And(preds ...Predicate) Predicate { return AndPred{preds} } + +func (o AndPred) Satisfy(mail *Mail) bool { return mail.Satisfies(o.a) } +func (x SenderIs) Satisfy(mail *Mail) bool { return mail.sender == x.sender } +func (x RecipientIs) Satisfy(mail *Mail) bool { return mail.recipient == x.recipient } + +// searches are always case-insensitive, and within Topic+Body +// You may combine if neeeded: Not{Contains} or And{Not{Contains}, Not{Contains}} +func (o Contains) Satisfy(mail *Mail) bool { return mail.Contains(o.s) } +func (o Not) Satisfy(mail *Mail) bool { return !mail.Satisfies([]Predicate{o.pred}) } + +func (o TopicContains) Satisfy(mail *Mail) bool { + topic := strings.ToLower(mail.topic) + return strings.Contains(topic, strings.ToLower(o.s)) +} + +func (o ContainsEither) Satisfy(mail *Mail) bool { + for _, substring := range o.a { + if mail.Contains(substring) { + return true + } + } + return false +} + +func (o TimeBefore) Satisfy(mail *Mail) bool { return !mail.time.After(o.t) } +func (o TimeAfter) Satisfy(mail *Mail) bool { return !mail.time.Before(o.t) } +func (o TimeBetween) Satisfy(mail *Mail) bool { + return !mail.time.Before(o.min) && !mail.time.After(o.max) +} diff --git a/examples/gno.land/r/demo/mail/predicates_test.gno b/examples/gno.land/r/demo/mail/predicates_test.gno new file mode 100644 index 00000000000..f83981bf171 --- /dev/null +++ b/examples/gno.land/r/demo/mail/predicates_test.gno @@ -0,0 +1,73 @@ +package mail + +import ( + "strings" + "testing" + "time" +) + +var ( + UTC = time.UTC + y1985 = time.Date(1985, 1, 1, 0, 0, 0, 0, UTC) + y2015 = time.Date(2015, 1, 1, 0, 0, 0, 0, UTC) + y2023 = time.Date(2023, 1, 1, 0, 0, 0, 0, UTC) + y2024 = time.Date(2024, 1, 1, 0, 0, 0, 0, UTC) + y2048 = time.Date(2048, 1, 1, 0, 0, 0, 0, UTC) + timenarrow1 = time.Date(1914, 6, 28, 14, 11, 0, 0, UTC) + timenarrow2 = time.Date(1914, 6, 28, 14, 13, 0, 0, UTC) + timenarrow3 = time.Date(1914, 6, 28, 14, 16, 0, 0, UTC) + + oldmail = &Mail{1, "1985", "old", y1985} + nowmail = &Mail{2, "2023", "now", y2023} + farmail = &Mail{3, "2048", "far", y2048} +) + +func assert(t *testing.T, cond bool) { + t.Helper() + if !cond { + t.Errorf("condition failed") + } +} + +// this bad name actually means: +// assert array `a` has an `i`-th element that is a mail, +// with mail.body containing `s` +func assertContains(t *testing.T, a []*Mail, i int, s string) { + t.Helper() + if i > len(a)-1 { + t.Errorf("Array does not have requested index\n") + } else { + var mail *Mail = a[i] + if !strings.Contains(mail.body, s) { + t.Errorf("Mail must contain " + s + ". Actual: " + mail.body) + } + } +} + +func TestPredicates(t *testing.T) { + ctr := newContainerWith([]*Mail{oldmail, nowmail, farmail}) + { + a := ctr.filter(TimeAfter{y2015}) + if len(a) != 2 { + t.Fail() + } else { + assertContains(t, a, 0, "now") + assertContains(t, a, 1, "far") + } + } + { + a := ctr.filter(TimeBefore{y2015}) + assertContains(t, a, 0, "old") + assert(t, len(a) == 1) + } + { + a := ctr.filter(TimeBetween{y2015, y2024}) + assertContains(t, a, 0, "now") + assert(t, len(a) == 1) + } + { + a := ctr.filter(And(TimeAfter{y1985}, TimeBefore{y2024}, Contains{"now"}, Not{Contains{"far"}})) + assert(t, len(a) == 1) + assertContains(t, a, 0, "now") + } +} diff --git a/examples/gno.land/r/demo/mail/render.gno b/examples/gno.land/r/demo/mail/render.gno new file mode 100644 index 00000000000..7d8731c7722 --- /dev/null +++ b/examples/gno.land/r/demo/mail/render.gno @@ -0,0 +1,5 @@ +package mail + +func Render(path string) string { + return "TODO" +} diff --git a/examples/gno.land/r/demo/mail/state.gno b/examples/gno.land/r/demo/mail/state.gno new file mode 100644 index 00000000000..b1f34ea0b3f --- /dev/null +++ b/examples/gno.land/r/demo/mail/state.gno @@ -0,0 +1,19 @@ +package mail + +import ( + "std" + + "gno.land/p/demo/avl" +) + +// realm state +var ( + addr2Mailbox = avl.Tree{} // Address -> *Mailbox + counter int = 0 // number of mails ever sent + admin std.Address = "g1fjh9y7ausp27dqsdq0qrcsnmgvwm6829v2au7d" // @grepsuzette + + Fee std.Coin = std.Coin{ + Denom: "ugnot", + Amount: 250 * 1000, // should never cost more $0.12 or 0.25 + } +)