Implement internal mail skill MVP
This commit is contained in:
1127
internal/service/service.go
Normal file
1127
internal/service/service.go
Normal file
File diff suppressed because it is too large
Load Diff
210
internal/service/service_test.go
Normal file
210
internal/service/service_test.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"internal-mail-skill/internal/db"
|
||||
"internal-mail-skill/internal/model"
|
||||
mailsmtp "internal-mail-skill/internal/smtp"
|
||||
)
|
||||
|
||||
func TestThreadAssemblyLinksReferences(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, err := db.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
if err := store.Migrate(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := New(store, nil, nil, "default")
|
||||
|
||||
root, err := svc.SaveParsedEmail(ctx, SaveEmailInput{
|
||||
POP3UID: "uid-root",
|
||||
Parsed: model.ParsedEmail{
|
||||
MessageIDHeader: "<root@example.com>",
|
||||
Subject: "Root",
|
||||
SenderEmail: "a@example.com",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reply, err := svc.SaveParsedEmail(ctx, SaveEmailInput{
|
||||
POP3UID: "uid-reply",
|
||||
Parsed: model.ParsedEmail{
|
||||
MessageIDHeader: "<reply@example.com>",
|
||||
InReplyTo: "<root@example.com>",
|
||||
References: "<root@example.com>",
|
||||
Subject: "Re: Root",
|
||||
SenderEmail: "b@example.com",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
thread, err := svc.GetThread(ctx, reply)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(thread) != 2 || thread[0].ID != root || thread[1].ID != reply {
|
||||
t.Fatalf("thread = %#v", thread)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchMarkLabelsAndDraftLifecycle(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, err := db.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
if err := store.Migrate(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := New(store, nil, nil, "default")
|
||||
|
||||
emailID, err := svc.SaveParsedEmail(ctx, SaveEmailInput{
|
||||
POP3UID: "uid-1",
|
||||
Parsed: model.ParsedEmail{
|
||||
MessageIDHeader: "<m@example.com>",
|
||||
Subject: "Quarterly Plan",
|
||||
BodyText: "Please review",
|
||||
SenderEmail: "boss@example.com",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.MarkEmail(ctx, emailID, MarkInput{Read: boolPtr(true), Processed: boolPtr(true)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
label, err := svc.CreateLabel(ctx, "work", "#336699")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.ApplyLabels(ctx, []int64{emailID}, []int64{label.ID}, "add"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
results, err := svc.SearchEmails(ctx, SearchFilters{Q: "Quarterly", Read: boolPtr(true), Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(results) != 1 || results[0].Labels[0].Name != "work" {
|
||||
t.Fatalf("search results = %#v", results)
|
||||
}
|
||||
|
||||
draft, err := svc.CreateDraft(ctx, DraftInput{To: []string{"x@example.com"}, Subject: "Draft", Body: "Hi"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updated, err := svc.UpdateDraft(ctx, draft.ID, DraftInput{To: []string{"x@example.com"}, Subject: "Updated", Body: "Hello"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.Subject != "Updated" {
|
||||
t.Fatalf("updated subject = %q", updated.Subject)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveParsedEmailRollsBackWhenAttachmentStorageFails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, err := db.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
if err := store.Migrate(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
blocker := filepath.Join(dir, "attachments-blocker")
|
||||
if err := os.WriteFile(blocker, []byte("not a directory"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := NewWithOptions(store, nil, nil, "default", Options{AttachmentDir: blocker})
|
||||
|
||||
_, err = svc.SaveParsedEmail(ctx, SaveEmailInput{
|
||||
POP3UID: "uid-broken-attachment",
|
||||
Parsed: model.ParsedEmail{
|
||||
MessageIDHeader: "<broken@example.com>",
|
||||
Subject: "Broken Attachment",
|
||||
SenderEmail: "a@example.com",
|
||||
Attachments: []model.ParsedAttachment{{
|
||||
Filename: "note.txt",
|
||||
ContentType: "text/plain",
|
||||
SizeBytes: 4,
|
||||
Data: []byte("nope"),
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("SaveParsedEmail() error = nil, want attachment storage failure")
|
||||
}
|
||||
|
||||
results, err := svc.SearchEmails(ctx, SearchFilters{Q: "Broken Attachment", Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(results) != 0 {
|
||||
t.Fatalf("failed save left searchable message: %#v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendDraftIsIdempotentAfterSuccess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store, err := db.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
if err := store.Migrate(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mailer := &fakeMailer{}
|
||||
svc := NewWithOptions(store, nil, mailer, "default", Options{FromAddress: "sender@example.com"})
|
||||
|
||||
draft, err := svc.CreateDraft(ctx, DraftInput{To: []string{"x@example.com"}, Subject: "Once", Body: "hello"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := svc.SendDraft(ctx, draft.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := svc.SendDraft(ctx, draft.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mailer.calls != 1 {
|
||||
t.Fatalf("mailer calls = %d, want 1", mailer.calls)
|
||||
}
|
||||
if second.SentMessage.ID != first.SentMessage.ID {
|
||||
t.Fatalf("second send message id = %d, want %d", second.SentMessage.ID, first.SentMessage.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func boolPtr(v bool) *bool { return &v }
|
||||
|
||||
type fakeMailer struct {
|
||||
calls int
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeMailer) Send(ctx context.Context, req mailsmtp.SendRequest) (mailsmtp.SendResult, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return mailsmtp.SendResult{}, f.err
|
||||
}
|
||||
if req.FromAddress == "" {
|
||||
return mailsmtp.SendResult{}, errors.New("from required")
|
||||
}
|
||||
return mailsmtp.SendResult{MessageIDHeader: "<sent@example.com>", SentAt: time.Now().UTC()}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user