Skip to content

Commit b1e41f4

Browse files
committed
✨ Add imap plugin
1 parent 385ab2a commit b1e41f4

10 files changed

+1027
-0
lines changed

plugins/imap/.gitignore

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
2+
# Go template downloaded with gut
3+
*.exe
4+
*.exe~
5+
*.dll
6+
*.so
7+
*.dylib
8+
*.test
9+
*.out
10+
go.work
11+
.gut
12+
13+
# Dev files
14+
*.log
15+
devManifest.*
16+
.init
17+
18+
dist/

plugins/imap/.goreleaser.yaml

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
2+
version: 2
3+
4+
before:
5+
hooks:
6+
# You may remove this if you don't use go modules.
7+
- go mod tidy
8+
9+
builds:
10+
- env:
11+
- CGO_ENABLED=0
12+
goos:
13+
- linux
14+
- windows
15+
- darwin
16+
17+
goarch:
18+
- amd64
19+
- arm64
20+
21+
archives:
22+
- format: binary
23+
24+
changelog:
25+
sort: asc
26+
filters:
27+
exclude:
28+
- "^docs:"
29+
- "^test:"

plugins/imap/Makefile

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
2+
files := $(wildcard *.go)
3+
4+
all: $(files)
5+
go build -o imap.out $(files)
6+
7+
prod: $(files)
8+
go build -o imap.out -ldflags "-s -w" $(files)
9+
10+
clean:
11+
rm -f notion.out
12+
13+
.PHONY: all clean

plugins/imap/README.md

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Imap plugin
2+
3+
Run SQL queries on your mailboxes.
4+
5+
## Setup
6+
7+
```bash
8+
anyquery install imap
9+
```
10+
11+
## Usage
12+
13+
Fill in the required configuration when installing the plugin. When you're done, you can run these SQL queries for example:
14+
15+
```sql
16+
-- List all folders in the mailbox
17+
SELECT * FROM imap_folders;
18+
19+
-- List all emails in the inbox
20+
SELECT * FROM imap_emails;
21+
22+
-- List all emails in the inbox with a specific subject
23+
SELECT * FROM imap_emails WHERE subject LIKE '%important%';
24+
25+
-- List unseen emails in the inbox
26+
SELECT * FROM imap_emails WHERE flags NOT LIKE '%"Seen"%';
27+
28+
-- List all emails in the inbox with a specific sender
29+
SELECT * FROM imap_emails EXISTS (SELECT 1 FROM json_tree(_from) WHERE key = 'email' AND value = '<the sender email');
30+
31+
```
32+
33+
```bash
34+
# Export all emails as HTML in a JSON file
35+
anyquery -q "SELECT html FROM imap_emails" --json > emails.json
36+
```
37+
38+
## Schema
39+
40+
### imap_folders
41+
42+
| Column index | Column name | type |
43+
| ------------ | ----------- | ---- |
44+
| 0 | folder | TEXT |
45+
46+
### imap_emails
47+
48+
| Column index | Column name | type |
49+
| ------------ | ----------- | ------- |
50+
| 0 | uid | INTEGER |
51+
| 1 | subject | TEXT |
52+
| 2 | sent_at | TEXT |
53+
| 3 | received_at | TEXT |
54+
| 4 | _from | TEXT |
55+
| 5 | to | TEXT |
56+
| 6 | reply_to | TEXT |
57+
| 7 | cc | TEXT |
58+
| 8 | bcc | TEXT |
59+
| 9 | message_id | TEXT |
60+
| 10 | flags | TEXT |
61+
| 11 | size | INTEGER |
62+
| 12 | folder | TEXT |
63+
64+
## Caveats and known information
65+
66+
- The plugin caches the emails for 30 days. If you want to refresh the cache, you can run `SELECT clear_plugin_cache('imap');`
67+
- On a Macbook Air M1 with a gigabit connection, the plugin fetches 110 emails per second on average with Outlook and 180 per second with Gmail.
68+
- The plugin is not able to fetch the body of the email, only the "metadata" (subject, sender, etc.).
69+
- The plugin is not able to fetch the attachments of the email.

plugins/imap/folders.go

+154
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/emersion/go-imap"
7+
"github.com/emersion/go-imap/client"
8+
"github.com/julien040/anyquery/rpc"
9+
)
10+
11+
// A constructor to create a new table instance
12+
// This function is called everytime a new connection is made to the plugin
13+
//
14+
// It should return a new table instance, the database schema and if there is an error
15+
func foldersCreator(args rpc.TableCreatorArgs) (rpc.Table, *rpc.DatabaseSchema, error) {
16+
config, err := getArgs(args.UserConfig)
17+
if err != nil {
18+
return nil, nil, err
19+
}
20+
21+
dialer, err := client.DialTLS(fmt.Sprintf("%s:%d", config.Host, config.Port), nil)
22+
if err != nil {
23+
return nil, nil, fmt.Errorf("failed to connect to imap server: %v", err)
24+
}
25+
26+
err = dialer.Login(config.Username, config.Password)
27+
if err != nil {
28+
return nil, nil, fmt.Errorf("failed to login to imap server: %v", err)
29+
}
30+
31+
return &foldersTable{
32+
dialer: dialer,
33+
}, &rpc.DatabaseSchema{
34+
PrimaryKey: -1,
35+
Columns: []rpc.DatabaseSchemaColumn{
36+
{
37+
Name: "folder",
38+
Type: rpc.ColumnTypeString,
39+
},
40+
},
41+
}, nil
42+
}
43+
44+
type foldersTable struct {
45+
dialer *client.Client
46+
}
47+
48+
type foldersCursor struct {
49+
dialer *client.Client
50+
}
51+
52+
// Return a slice of rows that will be returned to Anyquery and filtered.
53+
// The second return value is true if the cursor has no more rows to return
54+
//
55+
// The constraints are used for optimization purposes to "pre-filter" the rows
56+
// If the rows returned don't match the constraints, it's not an issue. Anyquery will filter them out
57+
func (t *foldersCursor) Query(constraints rpc.QueryConstraint) ([][]interface{}, bool, error) {
58+
folders := make([]string, 0)
59+
// Get the list of folders
60+
mailboxes := make(chan *imap.MailboxInfo, 10)
61+
done := make(chan error, 1)
62+
go func() {
63+
done <- t.dialer.List("", "*", mailboxes)
64+
}()
65+
66+
for m := range mailboxes {
67+
if m == nil {
68+
continue
69+
}
70+
folders = append(folders, m.Name)
71+
}
72+
err := <-done
73+
if err != nil {
74+
return nil, true, fmt.Errorf("failed to get folders: %v", err)
75+
}
76+
77+
// Convert the list of folders to a slice of rows
78+
rows := make([][]interface{}, 0, len(folders))
79+
for _, folder := range folders {
80+
rows = append(rows, []interface{}{folder})
81+
}
82+
83+
return rows, true, nil
84+
}
85+
86+
// Create a new cursor that will be used to read rows
87+
func (t *foldersTable) CreateReader() rpc.ReaderInterface {
88+
return &foldersCursor{
89+
dialer: t.dialer,
90+
}
91+
}
92+
93+
// A slice of rows to insert
94+
func (t *foldersTable) Insert(rows [][]interface{}) error {
95+
return nil
96+
}
97+
98+
// A slice of rows to update
99+
// The first element of each row is the primary key
100+
// while the rest are the values to update
101+
// The primary key is therefore present twice
102+
func (t *foldersTable) Update(rows [][]interface{}) error {
103+
return nil
104+
}
105+
106+
// A slice of primary keys to delete
107+
func (t *foldersTable) Delete(primaryKeys []interface{}) error {
108+
return nil
109+
}
110+
111+
// A destructor to clean up resources
112+
func (t *foldersTable) Close() error {
113+
return nil
114+
}
115+
116+
type userConfig struct {
117+
Username string
118+
Password string
119+
Port int
120+
Host string
121+
}
122+
123+
func getArgs(args rpc.PluginConfig) (userConfig, error) {
124+
var config userConfig
125+
var ok bool
126+
var rawString string
127+
var rawFloat float64
128+
129+
if rawString, ok = args["username"].(string); !ok {
130+
return config, fmt.Errorf("username is not a string")
131+
} else if config.Username = rawString; config.Username == "" {
132+
return config, fmt.Errorf("username is empty")
133+
}
134+
135+
if rawString, ok = args["password"].(string); !ok {
136+
return config, fmt.Errorf("password is not a string")
137+
} else if config.Password = rawString; config.Password == "" {
138+
return config, fmt.Errorf("password is empty")
139+
}
140+
141+
if rawString, ok = args["host"].(string); !ok {
142+
return config, fmt.Errorf("host is not a string")
143+
} else if config.Host = rawString; config.Host == "" {
144+
return config, fmt.Errorf("host is empty")
145+
}
146+
147+
if rawFloat, ok = args["port"].(float64); !ok {
148+
return config, fmt.Errorf("port is not a number")
149+
} else if config.Port = int(rawFloat); config.Port == 0 {
150+
return config, fmt.Errorf("port is 0")
151+
}
152+
153+
return config, nil
154+
}

plugins/imap/go.mod

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
module github.com/julien040/anyquery/plugins/imap
2+
3+
go 1.22.5
4+
5+
require (
6+
github.com/adrg/xdg v0.5.0
7+
github.com/dgraph-io/badger/v4 v4.2.0
8+
github.com/emersion/go-imap v1.2.1
9+
github.com/julien040/anyquery v0.0.0-20240717200741-385ab2a44760
10+
)
11+
12+
require (
13+
github.com/cespare/xxhash/v2 v2.3.0 // indirect
14+
github.com/dgraph-io/ristretto v0.1.1 // indirect
15+
github.com/dustin/go-humanize v1.0.1 // indirect
16+
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect
17+
github.com/fatih/color v1.17.0 // indirect
18+
github.com/gogo/protobuf v1.3.2 // indirect
19+
github.com/golang/glog v1.2.1 // indirect
20+
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
21+
github.com/golang/protobuf v1.5.4 // indirect
22+
github.com/golang/snappy v0.0.3 // indirect
23+
github.com/google/flatbuffers v1.12.1 // indirect
24+
github.com/hashicorp/go-hclog v1.6.3 // indirect
25+
github.com/hashicorp/go-plugin v1.6.1 // indirect
26+
github.com/hashicorp/yamux v0.1.1 // indirect
27+
github.com/klauspost/compress v1.17.9 // indirect
28+
github.com/mattn/go-colorable v0.1.13 // indirect
29+
github.com/mattn/go-isatty v0.0.20 // indirect
30+
github.com/mitchellh/go-testing-interface v1.14.1 // indirect
31+
github.com/oklog/run v1.1.0 // indirect
32+
github.com/pkg/errors v0.9.1 // indirect
33+
go.opencensus.io v0.24.0 // indirect
34+
golang.org/x/net v0.27.0 // indirect
35+
golang.org/x/sys v0.22.0 // indirect
36+
golang.org/x/text v0.16.0 // indirect
37+
google.golang.org/genproto/googleapis/rpc v0.0.0-20240711142825-46eb208f015d // indirect
38+
google.golang.org/grpc v1.65.0 // indirect
39+
google.golang.org/protobuf v1.34.2 // indirect
40+
)

0 commit comments

Comments
 (0)