├── .envrc
├── .gitignore
├── CHANGELOG.md
├── Cargo.lock
├── Cargo.nix
├── Cargo.toml
├── DESIGN.org
├── LICENSE
├── README.md
├── flake.lock
├── flake.nix
├── mujmap.toml.example
├── shell.nix
└── src
├── args.rs
├── cache.rs
├── config.rs
├── jmap
├── mod.rs
├── request.rs
├── response.rs
└── session.rs
├── local.rs
├── main.rs
├── remote.rs
├── send.rs
└── sync.rs
/.envrc:
--------------------------------------------------------------------------------
1 | use nix
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /target
2 | /.direnv
3 | /design
4 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 | All notable changes to this project will be documented in this file.
3 |
4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6 |
7 | ## [Unreleased]
8 | ### Added
9 | - Support for bearer token authentication (#40)
10 | - Support for pushing changes without pulling (#26)
11 |
12 | ### Changed
13 | - mujmap now prints a more comprehensive guide on how to recover from a missing
14 | state file. (#15)
15 | - Leading and trailing whitespace (including newlines) is now removed from the
16 | password returned by `password_command`. (#41)
17 |
18 | ## [0.2.0] - 2022-06-06
19 | ### Added
20 | - mujmap can now send emails! See the readme for details.
21 | - New configuration option `convert_dos_to_unix` which converts DOS newlines to
22 | Unix newlines for all newly downloaded mail files.
23 | - New configuration option `cache_dir` which changes the directory where mujmap
24 | downloads new files before adding them to the maildir.
25 | - By default, try to discover the JMAP server from the domain part of the
26 | `username` configuration option. (#28)
27 |
28 | ### Changed
29 | - New mail files will have their line endings changed by default to Unix; see
30 | the above `convert_dos_to_unix` configuration option. Existing files are
31 | unaffected.
32 | - Most JMAP error objects now contain additional properties besides
33 | "description". (#20)
34 |
35 | ### Fixed
36 | - Introduced workaround for some JMAP servers which did not support the patch
37 | syntax that mujmap was using for updating mailboxes. (#19)
38 | - Mail which belongs to "ignored" mailboxes will no longer be added to the
39 | `archive`-role mailbox unnecessarily.
40 | - Symlinked maildirs now properly handled. (#16)
41 | - Messages managed by mujmap now synchronize their tags with the maildir flags
42 | if notmuch is configured to do so. This fixes interfaces which depend on such
43 | flags being present, like neomutt. (#8)
44 |
45 | ## [0.1.1] - 2022-05-17
46 | ### Changed
47 | - Improved diagnostics for password command/authentication failures.
48 | - mujmap will replace replace unindexed mail files in the maildir with files
49 | from the cache if they have the same filename.
50 |
51 | ### Fixed
52 | - Mail download operations will now correctly retry in all cases of failure.
53 | (#7)
54 | - A `retries` configuration option of `0` now correctly interpreted as infinite.
55 | - Automatic tags are no longer clobbered. mujmap will actively ignore automatic
56 | tags. (#9)
57 | - Messages considered duplicates by notmuch will now properly synchronize with
58 | the server. See #13 for more details about duplicate messages. (#12)
59 |
60 | ## [0.1.0] - 2022-05-12
61 | ### Added
62 | - Initial release.
63 |
64 | [Unreleased]: https://github.com/elizagamedev/mujmap/compare/v0.2.0...HEAD
65 | [0.2.0]: https://github.com/elizagamedev/mujmap/compare/v0.1.1...v0.2.0
66 | [0.1.1]: https://github.com/elizagamedev/mujmap/compare/v0.1.0...v0.1.1
67 | [0.1.0]: https://github.com/elizagamedev/mujmap/releases/tag/v0.1.0
68 |
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "mujmap"
3 | description = "JMAP integration for notmuch mail"
4 | authors = ["Eliza Velasquez"]
5 | version = "0.2.0"
6 | license = "GPL-3.0"
7 | keywords = ["notmuch", "mail", "email", "jmap"]
8 | categories = ["command-line-utilities", "email"]
9 | repository = "https://github.com/elizagamedev/mujmap/"
10 | homepage = "https://github.com/elizagamedev/mujmap/"
11 | edition = "2021"
12 |
13 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
14 |
15 | [dependencies]
16 | atty = "0.2.14"
17 | base64 = "0.13.0"
18 | clap = { version = "3.1.14", features = ["derive", "cargo"] }
19 | clap-verbosity-flag = "1.0.0"
20 | const_format = "0.2.22"
21 | directories = "4.0.1"
22 | either = "1.6.1"
23 | email-parser = "0.5.0"
24 | env_logger = "0.9.0"
25 | fqdn = "0.1.9"
26 | fslock = "0.2.1"
27 | indicatif = "0.16.2"
28 | itertools = "0.10.3"
29 | lazy_static = "1.4.0"
30 | loe = "0.3.0"
31 | log = "0.4.16"
32 | notmuch = "0.8.0"
33 | rayon = "1.5.2"
34 | regex = "1.5.5"
35 | serde = { version = "1.0.136", features = ["derive"] }
36 | serde_json = "1.0.79"
37 | snafu = "0.7.0"
38 | symlink = "0.1.0"
39 | termcolor = "1.1.3"
40 | toml = "0.5.9"
41 | trust-dns-resolver = "0.21.2"
42 | ureq = { version = "2.4.0", features = ["json"] }
43 | uritemplate-next = "0.2.0"
44 |
--------------------------------------------------------------------------------
/DESIGN.org:
--------------------------------------------------------------------------------
1 | The design of =mujmap= was heavily inspired by [[https://github.com/gauteh/lieer][lieer]].
2 |
3 | * Links
4 | - [[https://datatracker.ietf.org/doc/html/rfc8620][RFC 8620 - The JSON Meta Application Protocol (JMAP)]]
5 | - [[https://datatracker.ietf.org/doc/html/rfc8621][RFC 8621 - The JSON Meta Application Protocol (JMAP) for Mail]]
6 |
7 | * Local State
8 |
9 | ** Mail Files
10 | - We place new mail files to a cache directory =XDG_CACHE_HOME/mujmap= before
11 | eventually moving them into the user's maildir.
12 |
13 | - We use a standard [[https://cr.yp.to/proto/maildir.html][maildir]] structure. The user configures the location of this
14 | directory.
15 |
16 | - Each mail is stored with the name ={id}.{blobId}:2,= with additional maildir
17 | flags appended to the end. This format is important, because we parse
18 | filenames to associate files on-disk with files in the JMAP server.
19 |
20 | According to the [[https://cr.yp.to/proto/maildir.html][maildir spec]], the part of the mail filename before the colon
21 | is not supposed to have semantic meaning, but instead differentiate each mail
22 | with a unique identifier. We assign semantic meaning to them so that we don't
23 | have to maintain a separate mapping between =notmuch= IDs and JMAP IDs. As
24 | such, using =mujmap= to manage an existing maildir is ill-advised.
25 |
26 | ** Other Files
27 | Between each sync operation, =mujmap= stores the following information
28 | /independently/ from the mail files and notmuch's database.
29 |
30 | - The =notmuch= database revision from the time of the most recent sync.
31 |
32 | We use this to determine every change the user has made to their =notmuch=
33 | database between =mujmap= syncs.
34 |
35 | - The =state= property of the last call to the =Email/get= API.
36 |
37 | We use this to resolve changes more quickly via the =Email/changes= API. The
38 | JMAP spec recommends servers be able to provide state changes within 30 days
39 | of a previous request, but a poorly implemented server may not be able to
40 | resolve changes at all. =mujmap= handles both cases.
41 |
42 | - If a sync was interrupted, a partial-sync file with the list of updated
43 | =Email= properties and deleted =Email= IDs that haven't yet been processed.
44 |
45 | These are described in more detail below.
46 |
47 | - A lockfile to prevent multiple syncs from accidentally happening at the same
48 | time.
49 |
50 | * Sync Process
51 |
52 | ** Setup
53 | Before doing anything, check for or create a lock file so we don't accidentally
54 | run two instances of =mujmap= at once.
55 |
56 | ** Pulling
57 | The goal of a pull is to build a list of properties from all newly created or
58 | updated mail since our last sync which we can later interpret as changes to our
59 | =notmuch= database. The properties we collect are:
60 |
61 | - =id=, so we can identify this =Email=.
62 | - =blobId=, so that we can compare the server mail's content with our local copy
63 | (if one exists), and potentially later download the server mail.
64 | - =mailboxIds=, so that we can synchronize these with =notmuch= tags.
65 | - =keywords=, so that we can synchronize these with =notmuch= tags.
66 |
67 | Additionally, we gather the set of =Email= IDs have since been destroyed.
68 |
69 | *** Querying for changed =Email= IDs
70 | :PROPERTIES:
71 | :CUSTOM_ID: querying
72 | :END:
73 | - If we have a valid, cached =state=, we use =Email/changes= to retrieve a list
74 | of created, updated, and destroyed =Email= IDs since our previous sync. Place
75 | the created and updated =Email= IDs in an "update" queue and place the
76 | destroyed =Email= IDs in the "destroyed" set.
77 | - If we do /not/ have a valid, cached =state=, invoke =Email/query= to collect a
78 | list of all =Email= IDs that exist on the JMAP server. Since we don't know
79 | which of these have been updated since the last time we performed a sync,
80 | place them all in the "update" queue. Place each mail in our maildir that is
81 | not in this list into the "destroyed" set.
82 |
83 | *** Retrieving =Email= metadata
84 | Now for each =Email= ID in the queue, call =Email/get= to retrieve the
85 | properties of interest listed above. If at any point =Email/get= returns a new
86 | =state=, jump back to the [[#querying][querying]] algorithm with the new =state=, appending to
87 | the end of the queue. Thus if there is another update on the server to an
88 | =Email= we've already called =Email/get= for, we can simpy call it again and
89 | update the entry in our list.
90 |
91 | *** Downloading mail blobs
92 | For each mail in our "update" list whose blob file does not exist in either the
93 | maildir directory or =mujmap='s cache, download the blob data as described in
94 | [[https://datatracker.ietf.org/doc/html/rfc8620#section-6.2][Section 6.2 of RFC 8620]] into a temporary file and move it into =mujmap='s cache
95 | only once the file has been fully downloaded using the naming scheme described
96 | in the [[*Mail Files][Mail Files]] section. JMAP does not have built-in
97 | provisions for checking data integrity of blob files save for redowloading them
98 | entirely, so it's important that we do not store partially-downloaded files.
99 |
100 | ** Merging
101 | At this point, we have a list of newly updated and destroyed =Email= entries and
102 | their relevant properties as they exist now on the server. We must now perform
103 | the following steps for each mail:
104 |
105 | - Determine the set of tags to add and remove to/from =notmuch='s database
106 | entry.
107 |
108 | - Determine the set of keywords and =Mailbox= IDs to add and remove to/from the
109 | JMAP server's =Email= object via =Email/set=.
110 |
111 | - Apply the remote changes tags.
112 |
113 | This can be done without clobbering any other remote changes happening in
114 | parallel because the =keywords= and =mailboxId= properties are represented as
115 | objects with each keyword and =Mailbox= ID as keys and =true= as values, and
116 | =Email/set= supports inserting and removing arbitrary key/value pairs.
117 |
118 | - Apply the local changes if and only if the remote changes were successfully
119 | applied.
120 |
121 | This involves moving the mail file into the maildir, creating the new entry in
122 | =notmuch='s database if necessary, and applying the tag changes.
123 |
124 | ** Cleanup
125 | Update the =state= and =notmuch= revision property as described in the [[*Other
126 | Files][Other Files]] section. Then remove the lockfile. We're done!
127 |
128 | * Recovering from Failure
129 | In the event of interruption via SIGINT, unrecoverable server error, etc, we can
130 | elegantly pause the sync and resume it in the future. It isn't strictly
131 | necessary to handle this case specially, since retracing all of the changes from
132 | the previously recorded =notmuch= database revision and the last server =state=
133 | would end with the same result, but it can potentially save network usage.
134 |
135 | - Record the list of updated =Email= properties and the deleted =Email= IDs
136 | into a partial-sync file as described in the [[*Other Files][Other Files]] section.
137 |
138 | - Update the =state= but /not/ the =notmuch= database revision as described in
139 | the [[*Other Files][Other Files]] section.
140 |
141 | - Remove the lockfile. We're done.
142 |
143 | In the event of a completely catastrophic failure, which occurs in the middle of
144 | the [[*Merging][merging]] process, e.g. power outage, we still probably have a recoverable
145 | state, but it might be safer to replace the =notmuch= database from scratch by
146 | redoing an initial sync.
147 |
148 | * Future Work
149 | - A =mujmap= daemon which uses JMAP's push notifications as described in
150 | [[https://datatracker.ietf.org/doc/html/rfc8620#section-7][Section 7 of RFC
151 | 8620]] to continuously download new mail and propagate updates both ways.
152 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU GENERAL PUBLIC LICENSE
2 | Version 3, 29 June 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 | Preamble
9 |
10 | The GNU General Public License is a free, copyleft license for
11 | software and other kinds of works.
12 |
13 | The licenses for most software and other practical works are designed
14 | to take away your freedom to share and change the works. By contrast,
15 | the GNU General Public License is intended to guarantee your freedom to
16 | share and change all versions of a program--to make sure it remains free
17 | software for all its users. We, the Free Software Foundation, use the
18 | GNU General Public License for most of our software; it applies also to
19 | any other work released this way by its authors. You can apply it to
20 | your programs, too.
21 |
22 | When we speak of free software, we are referring to freedom, not
23 | price. Our General Public Licenses are designed to make sure that you
24 | have the freedom to distribute copies of free software (and charge for
25 | them if you wish), that you receive source code or can get it if you
26 | want it, that you can change the software or use pieces of it in new
27 | free programs, and that you know you can do these things.
28 |
29 | To protect your rights, we need to prevent others from denying you
30 | these rights or asking you to surrender the rights. Therefore, you have
31 | certain responsibilities if you distribute copies of the software, or if
32 | you modify it: responsibilities to respect the freedom of others.
33 |
34 | For example, if you distribute copies of such a program, whether
35 | gratis or for a fee, you must pass on to the recipients the same
36 | freedoms that you received. You must make sure that they, too, receive
37 | or can get the source code. And you must show them these terms so they
38 | know their rights.
39 |
40 | Developers that use the GNU GPL protect your rights with two steps:
41 | (1) assert copyright on the software, and (2) offer you this License
42 | giving you legal permission to copy, distribute and/or modify it.
43 |
44 | For the developers' and authors' protection, the GPL clearly explains
45 | that there is no warranty for this free software. For both users' and
46 | authors' sake, the GPL requires that modified versions be marked as
47 | changed, so that their problems will not be attributed erroneously to
48 | authors of previous versions.
49 |
50 | Some devices are designed to deny users access to install or run
51 | modified versions of the software inside them, although the manufacturer
52 | can do so. This is fundamentally incompatible with the aim of
53 | protecting users' freedom to change the software. The systematic
54 | pattern of such abuse occurs in the area of products for individuals to
55 | use, which is precisely where it is most unacceptable. Therefore, we
56 | have designed this version of the GPL to prohibit the practice for those
57 | products. If such problems arise substantially in other domains, we
58 | stand ready to extend this provision to those domains in future versions
59 | of the GPL, as needed to protect the freedom of users.
60 |
61 | Finally, every program is threatened constantly by software patents.
62 | States should not allow patents to restrict development and use of
63 | software on general-purpose computers, but in those that do, we wish to
64 | avoid the special danger that patents applied to a free program could
65 | make it effectively proprietary. To prevent this, the GPL assures that
66 | patents cannot be used to render the program non-free.
67 |
68 | The precise terms and conditions for copying, distribution and
69 | modification follow.
70 |
71 | TERMS AND CONDITIONS
72 |
73 | 0. Definitions.
74 |
75 | "This License" refers to version 3 of the GNU General Public License.
76 |
77 | "Copyright" also means copyright-like laws that apply to other kinds of
78 | works, such as semiconductor masks.
79 |
80 | "The Program" refers to any copyrightable work licensed under this
81 | License. Each licensee is addressed as "you". "Licensees" and
82 | "recipients" may be individuals or organizations.
83 |
84 | To "modify" a work means to copy from or adapt all or part of the work
85 | in a fashion requiring copyright permission, other than the making of an
86 | exact copy. The resulting work is called a "modified version" of the
87 | earlier work or a work "based on" the earlier work.
88 |
89 | A "covered work" means either the unmodified Program or a work based
90 | on the Program.
91 |
92 | To "propagate" a work means to do anything with it that, without
93 | permission, would make you directly or secondarily liable for
94 | infringement under applicable copyright law, except executing it on a
95 | computer or modifying a private copy. Propagation includes copying,
96 | distribution (with or without modification), making available to the
97 | public, and in some countries other activities as well.
98 |
99 | To "convey" a work means any kind of propagation that enables other
100 | parties to make or receive copies. Mere interaction with a user through
101 | a computer network, with no transfer of a copy, is not conveying.
102 |
103 | An interactive user interface displays "Appropriate Legal Notices"
104 | to the extent that it includes a convenient and prominently visible
105 | feature that (1) displays an appropriate copyright notice, and (2)
106 | tells the user that there is no warranty for the work (except to the
107 | extent that warranties are provided), that licensees may convey the
108 | work under this License, and how to view a copy of this License. If
109 | the interface presents a list of user commands or options, such as a
110 | menu, a prominent item in the list meets this criterion.
111 |
112 | 1. Source Code.
113 |
114 | The "source code" for a work means the preferred form of the work
115 | for making modifications to it. "Object code" means any non-source
116 | form of a work.
117 |
118 | A "Standard Interface" means an interface that either is an official
119 | standard defined by a recognized standards body, or, in the case of
120 | interfaces specified for a particular programming language, one that
121 | is widely used among developers working in that language.
122 |
123 | The "System Libraries" of an executable work include anything, other
124 | than the work as a whole, that (a) is included in the normal form of
125 | packaging a Major Component, but which is not part of that Major
126 | Component, and (b) serves only to enable use of the work with that
127 | Major Component, or to implement a Standard Interface for which an
128 | implementation is available to the public in source code form. A
129 | "Major Component", in this context, means a major essential component
130 | (kernel, window system, and so on) of the specific operating system
131 | (if any) on which the executable work runs, or a compiler used to
132 | produce the work, or an object code interpreter used to run it.
133 |
134 | The "Corresponding Source" for a work in object code form means all
135 | the source code needed to generate, install, and (for an executable
136 | work) run the object code and to modify the work, including scripts to
137 | control those activities. However, it does not include the work's
138 | System Libraries, or general-purpose tools or generally available free
139 | programs which are used unmodified in performing those activities but
140 | which are not part of the work. For example, Corresponding Source
141 | includes interface definition files associated with source files for
142 | the work, and the source code for shared libraries and dynamically
143 | linked subprograms that the work is specifically designed to require,
144 | such as by intimate data communication or control flow between those
145 | subprograms and other parts of the work.
146 |
147 | The Corresponding Source need not include anything that users
148 | can regenerate automatically from other parts of the Corresponding
149 | Source.
150 |
151 | The Corresponding Source for a work in source code form is that
152 | same work.
153 |
154 | 2. Basic Permissions.
155 |
156 | All rights granted under this License are granted for the term of
157 | copyright on the Program, and are irrevocable provided the stated
158 | conditions are met. This License explicitly affirms your unlimited
159 | permission to run the unmodified Program. The output from running a
160 | covered work is covered by this License only if the output, given its
161 | content, constitutes a covered work. This License acknowledges your
162 | rights of fair use or other equivalent, as provided by copyright law.
163 |
164 | You may make, run and propagate covered works that you do not
165 | convey, without conditions so long as your license otherwise remains
166 | in force. You may convey covered works to others for the sole purpose
167 | of having them make modifications exclusively for you, or provide you
168 | with facilities for running those works, provided that you comply with
169 | the terms of this License in conveying all material for which you do
170 | not control copyright. Those thus making or running the covered works
171 | for you must do so exclusively on your behalf, under your direction
172 | and control, on terms that prohibit them from making any copies of
173 | your copyrighted material outside their relationship with you.
174 |
175 | Conveying under any other circumstances is permitted solely under
176 | the conditions stated below. Sublicensing is not allowed; section 10
177 | makes it unnecessary.
178 |
179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
180 |
181 | No covered work shall be deemed part of an effective technological
182 | measure under any applicable law fulfilling obligations under article
183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or
184 | similar laws prohibiting or restricting circumvention of such
185 | measures.
186 |
187 | When you convey a covered work, you waive any legal power to forbid
188 | circumvention of technological measures to the extent such circumvention
189 | is effected by exercising rights under this License with respect to
190 | the covered work, and you disclaim any intention to limit operation or
191 | modification of the work as a means of enforcing, against the work's
192 | users, your or third parties' legal rights to forbid circumvention of
193 | technological measures.
194 |
195 | 4. Conveying Verbatim Copies.
196 |
197 | You may convey verbatim copies of the Program's source code as you
198 | receive it, in any medium, provided that you conspicuously and
199 | appropriately publish on each copy an appropriate copyright notice;
200 | keep intact all notices stating that this License and any
201 | non-permissive terms added in accord with section 7 apply to the code;
202 | keep intact all notices of the absence of any warranty; and give all
203 | recipients a copy of this License along with the Program.
204 |
205 | You may charge any price or no price for each copy that you convey,
206 | and you may offer support or warranty protection for a fee.
207 |
208 | 5. Conveying Modified Source Versions.
209 |
210 | You may convey a work based on the Program, or the modifications to
211 | produce it from the Program, in the form of source code under the
212 | terms of section 4, provided that you also meet all of these conditions:
213 |
214 | a) The work must carry prominent notices stating that you modified
215 | it, and giving a relevant date.
216 |
217 | b) The work must carry prominent notices stating that it is
218 | released under this License and any conditions added under section
219 | 7. This requirement modifies the requirement in section 4 to
220 | "keep intact all notices".
221 |
222 | c) You must license the entire work, as a whole, under this
223 | License to anyone who comes into possession of a copy. This
224 | License will therefore apply, along with any applicable section 7
225 | additional terms, to the whole of the work, and all its parts,
226 | regardless of how they are packaged. This License gives no
227 | permission to license the work in any other way, but it does not
228 | invalidate such permission if you have separately received it.
229 |
230 | d) If the work has interactive user interfaces, each must display
231 | Appropriate Legal Notices; however, if the Program has interactive
232 | interfaces that do not display Appropriate Legal Notices, your
233 | work need not make them do so.
234 |
235 | A compilation of a covered work with other separate and independent
236 | works, which are not by their nature extensions of the covered work,
237 | and which are not combined with it such as to form a larger program,
238 | in or on a volume of a storage or distribution medium, is called an
239 | "aggregate" if the compilation and its resulting copyright are not
240 | used to limit the access or legal rights of the compilation's users
241 | beyond what the individual works permit. Inclusion of a covered work
242 | in an aggregate does not cause this License to apply to the other
243 | parts of the aggregate.
244 |
245 | 6. Conveying Non-Source Forms.
246 |
247 | You may convey a covered work in object code form under the terms
248 | of sections 4 and 5, provided that you also convey the
249 | machine-readable Corresponding Source under the terms of this License,
250 | in one of these ways:
251 |
252 | a) Convey the object code in, or embodied in, a physical product
253 | (including a physical distribution medium), accompanied by the
254 | Corresponding Source fixed on a durable physical medium
255 | customarily used for software interchange.
256 |
257 | b) Convey the object code in, or embodied in, a physical product
258 | (including a physical distribution medium), accompanied by a
259 | written offer, valid for at least three years and valid for as
260 | long as you offer spare parts or customer support for that product
261 | model, to give anyone who possesses the object code either (1) a
262 | copy of the Corresponding Source for all the software in the
263 | product that is covered by this License, on a durable physical
264 | medium customarily used for software interchange, for a price no
265 | more than your reasonable cost of physically performing this
266 | conveying of source, or (2) access to copy the
267 | Corresponding Source from a network server at no charge.
268 |
269 | c) Convey individual copies of the object code with a copy of the
270 | written offer to provide the Corresponding Source. This
271 | alternative is allowed only occasionally and noncommercially, and
272 | only if you received the object code with such an offer, in accord
273 | with subsection 6b.
274 |
275 | d) Convey the object code by offering access from a designated
276 | place (gratis or for a charge), and offer equivalent access to the
277 | Corresponding Source in the same way through the same place at no
278 | further charge. You need not require recipients to copy the
279 | Corresponding Source along with the object code. If the place to
280 | copy the object code is a network server, the Corresponding Source
281 | may be on a different server (operated by you or a third party)
282 | that supports equivalent copying facilities, provided you maintain
283 | clear directions next to the object code saying where to find the
284 | Corresponding Source. Regardless of what server hosts the
285 | Corresponding Source, you remain obligated to ensure that it is
286 | available for as long as needed to satisfy these requirements.
287 |
288 | e) Convey the object code using peer-to-peer transmission, provided
289 | you inform other peers where the object code and Corresponding
290 | Source of the work are being offered to the general public at no
291 | charge under subsection 6d.
292 |
293 | A separable portion of the object code, whose source code is excluded
294 | from the Corresponding Source as a System Library, need not be
295 | included in conveying the object code work.
296 |
297 | A "User Product" is either (1) a "consumer product", which means any
298 | tangible personal property which is normally used for personal, family,
299 | or household purposes, or (2) anything designed or sold for incorporation
300 | into a dwelling. In determining whether a product is a consumer product,
301 | doubtful cases shall be resolved in favor of coverage. For a particular
302 | product received by a particular user, "normally used" refers to a
303 | typical or common use of that class of product, regardless of the status
304 | of the particular user or of the way in which the particular user
305 | actually uses, or expects or is expected to use, the product. A product
306 | is a consumer product regardless of whether the product has substantial
307 | commercial, industrial or non-consumer uses, unless such uses represent
308 | the only significant mode of use of the product.
309 |
310 | "Installation Information" for a User Product means any methods,
311 | procedures, authorization keys, or other information required to install
312 | and execute modified versions of a covered work in that User Product from
313 | a modified version of its Corresponding Source. The information must
314 | suffice to ensure that the continued functioning of the modified object
315 | code is in no case prevented or interfered with solely because
316 | modification has been made.
317 |
318 | If you convey an object code work under this section in, or with, or
319 | specifically for use in, a User Product, and the conveying occurs as
320 | part of a transaction in which the right of possession and use of the
321 | User Product is transferred to the recipient in perpetuity or for a
322 | fixed term (regardless of how the transaction is characterized), the
323 | Corresponding Source conveyed under this section must be accompanied
324 | by the Installation Information. But this requirement does not apply
325 | if neither you nor any third party retains the ability to install
326 | modified object code on the User Product (for example, the work has
327 | been installed in ROM).
328 |
329 | The requirement to provide Installation Information does not include a
330 | requirement to continue to provide support service, warranty, or updates
331 | for a work that has been modified or installed by the recipient, or for
332 | the User Product in which it has been modified or installed. Access to a
333 | network may be denied when the modification itself materially and
334 | adversely affects the operation of the network or violates the rules and
335 | protocols for communication across the network.
336 |
337 | Corresponding Source conveyed, and Installation Information provided,
338 | in accord with this section must be in a format that is publicly
339 | documented (and with an implementation available to the public in
340 | source code form), and must require no special password or key for
341 | unpacking, reading or copying.
342 |
343 | 7. Additional Terms.
344 |
345 | "Additional permissions" are terms that supplement the terms of this
346 | License by making exceptions from one or more of its conditions.
347 | Additional permissions that are applicable to the entire Program shall
348 | be treated as though they were included in this License, to the extent
349 | that they are valid under applicable law. If additional permissions
350 | apply only to part of the Program, that part may be used separately
351 | under those permissions, but the entire Program remains governed by
352 | this License without regard to the additional permissions.
353 |
354 | When you convey a copy of a covered work, you may at your option
355 | remove any additional permissions from that copy, or from any part of
356 | it. (Additional permissions may be written to require their own
357 | removal in certain cases when you modify the work.) You may place
358 | additional permissions on material, added by you to a covered work,
359 | for which you have or can give appropriate copyright permission.
360 |
361 | Notwithstanding any other provision of this License, for material you
362 | add to a covered work, you may (if authorized by the copyright holders of
363 | that material) supplement the terms of this License with terms:
364 |
365 | a) Disclaiming warranty or limiting liability differently from the
366 | terms of sections 15 and 16 of this License; or
367 |
368 | b) Requiring preservation of specified reasonable legal notices or
369 | author attributions in that material or in the Appropriate Legal
370 | Notices displayed by works containing it; or
371 |
372 | c) Prohibiting misrepresentation of the origin of that material, or
373 | requiring that modified versions of such material be marked in
374 | reasonable ways as different from the original version; or
375 |
376 | d) Limiting the use for publicity purposes of names of licensors or
377 | authors of the material; or
378 |
379 | e) Declining to grant rights under trademark law for use of some
380 | trade names, trademarks, or service marks; or
381 |
382 | f) Requiring indemnification of licensors and authors of that
383 | material by anyone who conveys the material (or modified versions of
384 | it) with contractual assumptions of liability to the recipient, for
385 | any liability that these contractual assumptions directly impose on
386 | those licensors and authors.
387 |
388 | All other non-permissive additional terms are considered "further
389 | restrictions" within the meaning of section 10. If the Program as you
390 | received it, or any part of it, contains a notice stating that it is
391 | governed by this License along with a term that is a further
392 | restriction, you may remove that term. If a license document contains
393 | a further restriction but permits relicensing or conveying under this
394 | License, you may add to a covered work material governed by the terms
395 | of that license document, provided that the further restriction does
396 | not survive such relicensing or conveying.
397 |
398 | If you add terms to a covered work in accord with this section, you
399 | must place, in the relevant source files, a statement of the
400 | additional terms that apply to those files, or a notice indicating
401 | where to find the applicable terms.
402 |
403 | Additional terms, permissive or non-permissive, may be stated in the
404 | form of a separately written license, or stated as exceptions;
405 | the above requirements apply either way.
406 |
407 | 8. Termination.
408 |
409 | You may not propagate or modify a covered work except as expressly
410 | provided under this License. Any attempt otherwise to propagate or
411 | modify it is void, and will automatically terminate your rights under
412 | this License (including any patent licenses granted under the third
413 | paragraph of section 11).
414 |
415 | However, if you cease all violation of this License, then your
416 | license from a particular copyright holder is reinstated (a)
417 | provisionally, unless and until the copyright holder explicitly and
418 | finally terminates your license, and (b) permanently, if the copyright
419 | holder fails to notify you of the violation by some reasonable means
420 | prior to 60 days after the cessation.
421 |
422 | Moreover, your license from a particular copyright holder is
423 | reinstated permanently if the copyright holder notifies you of the
424 | violation by some reasonable means, this is the first time you have
425 | received notice of violation of this License (for any work) from that
426 | copyright holder, and you cure the violation prior to 30 days after
427 | your receipt of the notice.
428 |
429 | Termination of your rights under this section does not terminate the
430 | licenses of parties who have received copies or rights from you under
431 | this License. If your rights have been terminated and not permanently
432 | reinstated, you do not qualify to receive new licenses for the same
433 | material under section 10.
434 |
435 | 9. Acceptance Not Required for Having Copies.
436 |
437 | You are not required to accept this License in order to receive or
438 | run a copy of the Program. Ancillary propagation of a covered work
439 | occurring solely as a consequence of using peer-to-peer transmission
440 | to receive a copy likewise does not require acceptance. However,
441 | nothing other than this License grants you permission to propagate or
442 | modify any covered work. These actions infringe copyright if you do
443 | not accept this License. Therefore, by modifying or propagating a
444 | covered work, you indicate your acceptance of this License to do so.
445 |
446 | 10. Automatic Licensing of Downstream Recipients.
447 |
448 | Each time you convey a covered work, the recipient automatically
449 | receives a license from the original licensors, to run, modify and
450 | propagate that work, subject to this License. You are not responsible
451 | for enforcing compliance by third parties with this License.
452 |
453 | An "entity transaction" is a transaction transferring control of an
454 | organization, or substantially all assets of one, or subdividing an
455 | organization, or merging organizations. If propagation of a covered
456 | work results from an entity transaction, each party to that
457 | transaction who receives a copy of the work also receives whatever
458 | licenses to the work the party's predecessor in interest had or could
459 | give under the previous paragraph, plus a right to possession of the
460 | Corresponding Source of the work from the predecessor in interest, if
461 | the predecessor has it or can get it with reasonable efforts.
462 |
463 | You may not impose any further restrictions on the exercise of the
464 | rights granted or affirmed under this License. For example, you may
465 | not impose a license fee, royalty, or other charge for exercise of
466 | rights granted under this License, and you may not initiate litigation
467 | (including a cross-claim or counterclaim in a lawsuit) alleging that
468 | any patent claim is infringed by making, using, selling, offering for
469 | sale, or importing the Program or any portion of it.
470 |
471 | 11. Patents.
472 |
473 | A "contributor" is a copyright holder who authorizes use under this
474 | License of the Program or a work on which the Program is based. The
475 | work thus licensed is called the contributor's "contributor version".
476 |
477 | A contributor's "essential patent claims" are all patent claims
478 | owned or controlled by the contributor, whether already acquired or
479 | hereafter acquired, that would be infringed by some manner, permitted
480 | by this License, of making, using, or selling its contributor version,
481 | but do not include claims that would be infringed only as a
482 | consequence of further modification of the contributor version. For
483 | purposes of this definition, "control" includes the right to grant
484 | patent sublicenses in a manner consistent with the requirements of
485 | this License.
486 |
487 | Each contributor grants you a non-exclusive, worldwide, royalty-free
488 | patent license under the contributor's essential patent claims, to
489 | make, use, sell, offer for sale, import and otherwise run, modify and
490 | propagate the contents of its contributor version.
491 |
492 | In the following three paragraphs, a "patent license" is any express
493 | agreement or commitment, however denominated, not to enforce a patent
494 | (such as an express permission to practice a patent or covenant not to
495 | sue for patent infringement). To "grant" such a patent license to a
496 | party means to make such an agreement or commitment not to enforce a
497 | patent against the party.
498 |
499 | If you convey a covered work, knowingly relying on a patent license,
500 | and the Corresponding Source of the work is not available for anyone
501 | to copy, free of charge and under the terms of this License, through a
502 | publicly available network server or other readily accessible means,
503 | then you must either (1) cause the Corresponding Source to be so
504 | available, or (2) arrange to deprive yourself of the benefit of the
505 | patent license for this particular work, or (3) arrange, in a manner
506 | consistent with the requirements of this License, to extend the patent
507 | license to downstream recipients. "Knowingly relying" means you have
508 | actual knowledge that, but for the patent license, your conveying the
509 | covered work in a country, or your recipient's use of the covered work
510 | in a country, would infringe one or more identifiable patents in that
511 | country that you have reason to believe are valid.
512 |
513 | If, pursuant to or in connection with a single transaction or
514 | arrangement, you convey, or propagate by procuring conveyance of, a
515 | covered work, and grant a patent license to some of the parties
516 | receiving the covered work authorizing them to use, propagate, modify
517 | or convey a specific copy of the covered work, then the patent license
518 | you grant is automatically extended to all recipients of the covered
519 | work and works based on it.
520 |
521 | A patent license is "discriminatory" if it does not include within
522 | the scope of its coverage, prohibits the exercise of, or is
523 | conditioned on the non-exercise of one or more of the rights that are
524 | specifically granted under this License. You may not convey a covered
525 | work if you are a party to an arrangement with a third party that is
526 | in the business of distributing software, under which you make payment
527 | to the third party based on the extent of your activity of conveying
528 | the work, and under which the third party grants, to any of the
529 | parties who would receive the covered work from you, a discriminatory
530 | patent license (a) in connection with copies of the covered work
531 | conveyed by you (or copies made from those copies), or (b) primarily
532 | for and in connection with specific products or compilations that
533 | contain the covered work, unless you entered into that arrangement,
534 | or that patent license was granted, prior to 28 March 2007.
535 |
536 | Nothing in this License shall be construed as excluding or limiting
537 | any implied license or other defenses to infringement that may
538 | otherwise be available to you under applicable patent law.
539 |
540 | 12. No Surrender of Others' Freedom.
541 |
542 | If conditions are imposed on you (whether by court order, agreement or
543 | otherwise) that contradict the conditions of this License, they do not
544 | excuse you from the conditions of this License. If you cannot convey a
545 | covered work so as to satisfy simultaneously your obligations under this
546 | License and any other pertinent obligations, then as a consequence you may
547 | not convey it at all. For example, if you agree to terms that obligate you
548 | to collect a royalty for further conveying from those to whom you convey
549 | the Program, the only way you could satisfy both those terms and this
550 | License would be to refrain entirely from conveying the Program.
551 |
552 | 13. Use with the GNU Affero General Public License.
553 |
554 | Notwithstanding any other provision of this License, you have
555 | permission to link or combine any covered work with a work licensed
556 | under version 3 of the GNU Affero General Public License into a single
557 | combined work, and to convey the resulting work. The terms of this
558 | License will continue to apply to the part which is the covered work,
559 | but the special requirements of the GNU Affero General Public License,
560 | section 13, concerning interaction through a network will apply to the
561 | combination as such.
562 |
563 | 14. Revised Versions of this License.
564 |
565 | The Free Software Foundation may publish revised and/or new versions of
566 | the GNU General Public License from time to time. Such new versions will
567 | be similar in spirit to the present version, but may differ in detail to
568 | address new problems or concerns.
569 |
570 | Each version is given a distinguishing version number. If the
571 | Program specifies that a certain numbered version of the GNU General
572 | Public License "or any later version" applies to it, you have the
573 | option of following the terms and conditions either of that numbered
574 | version or of any later version published by the Free Software
575 | Foundation. If the Program does not specify a version number of the
576 | GNU General Public License, you may choose any version ever published
577 | by the Free Software Foundation.
578 |
579 | If the Program specifies that a proxy can decide which future
580 | versions of the GNU General Public License can be used, that proxy's
581 | public statement of acceptance of a version permanently authorizes you
582 | to choose that version for the Program.
583 |
584 | Later license versions may give you additional or different
585 | permissions. However, no additional obligations are imposed on any
586 | author or copyright holder as a result of your choosing to follow a
587 | later version.
588 |
589 | 15. Disclaimer of Warranty.
590 |
591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
599 |
600 | 16. Limitation of Liability.
601 |
602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
610 | SUCH DAMAGES.
611 |
612 | 17. Interpretation of Sections 15 and 16.
613 |
614 | If the disclaimer of warranty and limitation of liability provided
615 | above cannot be given local legal effect according to their terms,
616 | reviewing courts shall apply local law that most closely approximates
617 | an absolute waiver of all civil liability in connection with the
618 | Program, unless a warranty or assumption of liability accompanies a
619 | copy of the Program in return for a fee.
620 |
621 | END OF TERMS AND CONDITIONS
622 |
623 | How to Apply These Terms to Your New Programs
624 |
625 | If you develop a new program, and you want it to be of the greatest
626 | possible use to the public, the best way to achieve this is to make it
627 | free software which everyone can redistribute and change under these terms.
628 |
629 | To do so, attach the following notices to the program. It is safest
630 | to attach them to the start of each source file to most effectively
631 | state the exclusion of warranty; and each file should have at least
632 | the "copyright" line and a pointer to where the full notice is found.
633 |
634 |
635 | Copyright (C)
636 |
637 | This program is free software: you can redistribute it and/or modify
638 | it under the terms of the GNU General Public License as published by
639 | the Free Software Foundation, either version 3 of the License, or
640 | (at your option) any later version.
641 |
642 | This program is distributed in the hope that it will be useful,
643 | but WITHOUT ANY WARRANTY; without even the implied warranty of
644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
645 | GNU General Public License for more details.
646 |
647 | You should have received a copy of the GNU General Public License
648 | along with this program. If not, see .
649 |
650 | Also add information on how to contact you by electronic and paper mail.
651 |
652 | If the program does terminal interaction, make it output a short
653 | notice like this when it starts in an interactive mode:
654 |
655 | Copyright (C)
656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
657 | This is free software, and you are welcome to redistribute it
658 | under certain conditions; type `show c' for details.
659 |
660 | The hypothetical commands `show w' and `show c' should show the appropriate
661 | parts of the General Public License. Of course, your program's commands
662 | might be different; for a GUI interface, you would use an "about box".
663 |
664 | You should also get your employer (if you work as a programmer) or school,
665 | if any, to sign a "copyright disclaimer" for the program, if necessary.
666 | For more information on this, and how to apply and follow the GNU GPL, see
667 | .
668 |
669 | The GNU General Public License does not permit incorporating your program
670 | into proprietary programs. If your program is a subroutine library, you
671 | may consider it more useful to permit linking proprietary applications with
672 | the library. If this is what you want to do, use the GNU Lesser General
673 | Public License instead of this License. But first, please read
674 | .
675 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # mujmap
2 |
3 | mujmap is a tool to synchronize your [notmuch](https://notmuchmail.org/)
4 | database with a server supporting the [JMAP mail
5 | protocol](https://jmap.io/spec.html). Specifically, it downloads new messages
6 | and synchronizes notmuch tags with mailboxes and keywords both ways and can send
7 | emails via a sendmail-like interface. It is very similar to
8 | [Lieer](https://github.com/gauteh/lieer) in terms of design and operation.
9 |
10 | ## Disclaimer
11 | mujmap is in quite an early state and comes with no warranty. I use it myself,
12 | it has been seeing steady adoption among other users, and I have taken caution
13 | to insert an abundance of paranoia where permanent changes are concerned. It is
14 | known to work on Linux and macOS with at least one webmail provider
15 | ([Fastmail](https://fastmail.com)).
16 |
17 | **If you do decide to use mujmap**, please look at the list of open issues
18 | first. If you are installing the latest Cargo release instead of the latest git
19 | revision, **also consider** looking at the issues in the
20 | [changelog](https://github.com/elizagamedev/mujmap/blob/main/CHANGELOG.md) that
21 | have been found and resolved since the latest release.
22 |
23 | ## Installation
24 | Please first read the [Disclaimer](#disclaimer) section.
25 |
26 | Install with [cargo](https://doc.rust-lang.org/cargo/):
27 |
28 | ```shell
29 | cargo install mujmap
30 | ```
31 |
32 | You may instead want to install from the latest `main` revision as bugs are
33 | regularly being fixed:
34 |
35 | ```shell
36 | cargo install --git https://github.com/elizagamedev/mujmap
37 | ```
38 |
39 | There is also an official [Nix
40 | package](https://github.com/NixOS/nixpkgs/blob/master/pkgs/applications/networking/mujmap/default.nix).
41 | A [home-manager module](https://github.com/nix-community/home-manager/pull/2960)
42 | is underway.
43 |
44 | ## Usage
45 | mujmap can be the sole mail agent in your notmuch database or live alongside
46 | others, it can manage two or more independent JMAP accounts in the same
47 | database, and be used across different notmuch databases, all with different
48 | configurations.
49 |
50 | In the directory that you want to use as the maildir for a specific mujmap
51 | instance, place a mujmap.toml file
52 | ([example](https://github.com/elizagamedev/mujmap/blob/main/mujmap.toml.example)).
53 | This directory *must* be a subdirectory of the notmuch root directory. Then,
54 | invoke mujmap from that directory, or from another directory pointing to it with
55 | the `-C` option. Check `mujmap --help` for more options. Specific
56 |
57 | ### Syncing
58 | Use `mujmap sync` to synchronize your mail. TL;DR: mujmap downloads new mail
59 | files, merges changes locally, preferring local changes in the event of a
60 | conflict, and then pushes changes to the remote.
61 |
62 | mujmap operates in roughly these steps:
63 |
64 | 1. mujmap gathers all metadata about emails that were created, potentially
65 | updated, or destroyed on the server since it was last run.
66 |
67 | JMAP does not tell us *exactly* what changes about a message, only that one
68 | of the [very many
69 | properties](https://datatracker.ietf.org/doc/html/rfc8621#section-4) of the
70 | JMAP `Email` object has changed. It's possible that nothing at all that we
71 | care about has changed. This is especially true if we're doing a "full
72 | sync", which can happen if we lose the state information from the last run
73 | or if such information expires server-side. In that case, we have to query
74 | everything from scratch and treat every single message as a "potential
75 | update".
76 | 2. mujmap downloads all new messages into a cache.
77 | 3. mujmap gathers a list of all messages which were updated in the database
78 | locally since it was last ran; we call these "locally updated" messages.
79 | 4. mujmap adds the new remote messages to the local notmuch database, then
80 | updates all local messages *except* the locally updated messages to reflect
81 | the remote state of the message.
82 |
83 | We skip updating the locally updated messages because again, there is no way
84 | to ask the JMAP server *what* changes were made; we can only retrieve the
85 | latest state of the tags as they exist on the server. We prefer preserving
86 | local tag changes over remote changes.
87 | 5. We push the locally updated messages to the remote.
88 |
89 | Unfortunately, the notmuch API also does not grant us any change history, so
90 | we are limited to looking at the latest state of the database entries as
91 | with JMAP. It seems possible that Xapian, the underlying database backend,
92 | does in fact support something like this, but it's not exposed by notmuch
93 | yet.
94 | 6. Record the *first* JMAP `Email` state we received and the *next* notmuch
95 | database revision in "mujmap.state.json" to be read next time mujmap is run
96 | back in step 1.
97 |
98 | For more of an explanation about this already probably over-explained process,
99 | the slightly out-of-date and not completely-accurately-implemented-as-written
100 | [DESIGN.org](https://github.com/elizagamedev/mujmap/blob/main/DESIGN.org) file
101 | goes into more detail.
102 |
103 | #### Pushing without Pulling
104 | Besides what is described above, you may also use `mujmap push` to local push
105 | changes without pulling in remote changes. This may be useful when invoking
106 | mujmap in pre/post notmuch hooks. You should only use `push` over `sync` when
107 | specifically necessary to reduce the number of redundant operations.
108 |
109 | There is no `mujmap pull`, since pulling without pushing complicates the design
110 | tenet that the mujmap database is the single source of truth during a conflict.
111 | (The reason being that pulling without pushing changes the notmuch database, and
112 | now mujmap thinks those changes are in fact local revisions which must be
113 | pushed, potentially reverting changes made by a third party on the remote. If
114 | that's confusing to you, sorry, it's not easy to describe the problem
115 | succinctly.) It's possible to sort of work around this issue, but in almost
116 | every case I can think of, you might as well just `sync` instead.
117 |
118 | ### Sending
119 | Use `mujmap send` to send an email. This subcommand is designed to operate
120 | mostly like sendmail; i.e., it reads an
121 | [RFC5322](https://datatracker.ietf.org/doc/html/rfc5322) mail file from stdin
122 | and sends it off into cyberspace. That said, this interface is still
123 | experimental.
124 |
125 | The arguments `-i`, `-oi`, `-f`, and `-F` are all accepted but ignored for
126 | sendmail compatibility. The sender is always determined from the email message
127 | itself.
128 |
129 | The recipients are specified in the same way as sendmail. They must either be
130 | specified at the end of the argument list, or mujmap can infer them from the
131 | message itself if you specify `-t`. If `-t` is specified, any recipient
132 | arguments at the end of the message are ignored, and mujmap will warn you.
133 |
134 | #### Emacs configuration
135 | ```elisp
136 | (setq sendmail-program "mujmap"
137 | message-sendmail-extra-arguments '("-C" "/path/to/mujmap/maildir" "send"))
138 | ```
139 |
140 | ## Quirks
141 | - If you change any of the "tag" options in the config file *after* you
142 | already have a working setup, be sure to heed the warning in the example
143 | config file and follow the instructions!
144 | - No matter how old the change, any messages changed in the local database
145 | in-between syncs will overwrite remote changes. This is due to an API
146 | limitation, described in more detail in the [Behavior](#behavior) section.
147 | - Duplicate messages may behave strangely. See #13.
148 | - This software probably doesn't work on Windows. I have no evidence of this
149 | being the case, it's just a hunch. Please prove me wrong.
150 |
151 | ## Migrating from IMAP+notmuch
152 | Unfortunately, there is no straightforward way to migrate yet. The following is
153 | an (untested) method you can use, **ONLY after you make a backup of your notmuch
154 | database**, and **ONLY after you have verified that mujmap works correctly for
155 | your account in an independent instance of a notmuch database (see the notmuch
156 | manpages for information on how to do this)**:
157 |
158 | 1. Ensure you're fully synchronized with the IMAP server.
159 | 2. Add a maildir for mujmap as a sibling of your already-existing maildirs.
160 | Configure it as you please, but don't invoke `mujmap sync` yet.
161 | 3. Create a file called `mujmap.state.json` in this directory alongside
162 | `mujmap.toml` with the following contents:
163 |
164 | ```json
165 | {"notmuch_revision":0}
166 | ```
167 | 4. Run `mujmap --dry-run sync` here. This will not actually make any changes to
168 | your maildir, but will allow you to verify your config and download email
169 | into a cache.
170 | 5. Run `mujmap sync` here to sync your mail for real. This will the downloaded
171 | email to the mujmap maildir and add them to your notmuch database. Because
172 | these messages should be duplicates of your existing messages, they will
173 | inherit the duplicates' tags, and then push them back to the server.
174 | 5. Remove your old IMAP maildirs and run `notmuch new --no-hooks`. If
175 | everything went smoothly, notmuch shouldn't mention any files being removed
176 | in its output.
177 |
178 | ## Limitations
179 | mujmap cannot and will never be able to:
180 |
181 | - Modify message contents.
182 | - Delete messages (other than tagging them as `deleted` or `spam`).
183 |
184 | ## Troubleshooting
185 | ### Status Code 401 (Unauthorized)
186 |
187 | If you're using Fastmail (which, let's be honest, is practically a certainty at
188 | the time of writing), you may have recently encountered errors with
189 | username/password authentication (HTTP Basic Auth). This may be caused by
190 | Fastmail phasing out username/password-based authentication methods, as
191 | described in [this blog
192 | post](https://jmap.topicbox.com/groups/fastmail-api/Tc47db6ee4fbb5451/).
193 |
194 | While this is objectively a good thing, and while it seems the intention was to
195 | roll this change out slowly, the API endpoint advertised by Fastmail DNS SRV
196 | records has almost immediately changed following the publication of this blog
197 | post, causing 401 errors in existing mujmap configurations. You have two
198 | options:
199 |
200 | - Switch to bearer tokens by following the guide in the blog post. mujmap
201 | supports bearer tokens via the `password_command` config option in the latest
202 | `main` branch revision but not yet in a versioned release.
203 | - Remove `fqdn` from your config if it's set, and add or change `session_url` to
204 | explicitly point to the old JMAP endpoint, located at
205 | `https://api.fastmail.com/.well-known/jmap`.
206 |
207 | If your 401 errors are unrelated to the above situation, try the following
208 | steps:
209 |
210 | - [ ] Ensure that your mail server supports either HTTP Basic Auth or Bearer
211 | token auth.
212 | - [ ] Verify that you are using the correct username and password/bearer token.
213 | If you are using HTTP Basic Auth, Fastmail requires a special third-party
214 | password *specifically for JMAP access*.
215 | - [ ] Verify that you are using a `password_command` which prints the correct
216 | password to stdout. If the password command fails, mujmap logs its stderr.
217 | - [ ] If using Fastmail, check your login logs on the website for additional
218 | context.
219 |
220 | ### Invalid cross-device link
221 | This error will occur if your mail directory is stored on a different device
222 | than your cache directory. By default, mujmap's cache is stored in
223 | `XDG_CONFIG_HOME` on Linux/FreeBSD and `~/Library/Caches` on macOS. You can
224 | change this location by setting `config_dir` in mujmap.toml.
225 |
226 | The rationale for downloading messages into a cache instead of directly into the
227 | maildir is because mujmap is designed to be able to roll-back local state
228 | changes in the event of a catastrophic failure to the best of its ability, which
229 | includes not leaving mail files in the maildir which haven't been fully
230 | integrated into notmuch's database. As an alternative, mujmap could have
231 | depended on notmuch being configured to ignore in-progress downloads, but this
232 | is much more prone to user error.
233 |
--------------------------------------------------------------------------------
/flake.lock:
--------------------------------------------------------------------------------
1 | {
2 | "nodes": {
3 | "cargo2nix": {
4 | "inputs": {
5 | "flake-compat": "flake-compat",
6 | "flake-utils": "flake-utils",
7 | "nixpkgs": "nixpkgs",
8 | "rust-overlay": "rust-overlay"
9 | },
10 | "locked": {
11 | "lastModified": 1682891040,
12 | "narHash": "sha256-hjajsi7lq24uYitUh4o04UJi1g0Qe6ruPL0s5DgPQMY=",
13 | "owner": "cargo2nix",
14 | "repo": "cargo2nix",
15 | "rev": "0167b39f198d72acdf009265634504fd6f5ace15",
16 | "type": "github"
17 | },
18 | "original": {
19 | "owner": "cargo2nix",
20 | "ref": "release-0.11.0",
21 | "repo": "cargo2nix",
22 | "type": "github"
23 | }
24 | },
25 | "flake-compat": {
26 | "flake": false,
27 | "locked": {
28 | "lastModified": 1650374568,
29 | "narHash": "sha256-Z+s0J8/r907g149rllvwhb4pKi8Wam5ij0st8PwAh+E=",
30 | "owner": "edolstra",
31 | "repo": "flake-compat",
32 | "rev": "b4a34015c698c7793d592d66adbab377907a2be8",
33 | "type": "github"
34 | },
35 | "original": {
36 | "owner": "edolstra",
37 | "repo": "flake-compat",
38 | "type": "github"
39 | }
40 | },
41 | "flake-utils": {
42 | "locked": {
43 | "lastModified": 1659877975,
44 | "narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=",
45 | "owner": "numtide",
46 | "repo": "flake-utils",
47 | "rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0",
48 | "type": "github"
49 | },
50 | "original": {
51 | "owner": "numtide",
52 | "repo": "flake-utils",
53 | "type": "github"
54 | }
55 | },
56 | "flake-utils_2": {
57 | "inputs": {
58 | "systems": "systems"
59 | },
60 | "locked": {
61 | "lastModified": 1681202837,
62 | "narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
63 | "owner": "numtide",
64 | "repo": "flake-utils",
65 | "rev": "cfacdce06f30d2b68473a46042957675eebb3401",
66 | "type": "github"
67 | },
68 | "original": {
69 | "owner": "numtide",
70 | "repo": "flake-utils",
71 | "type": "github"
72 | }
73 | },
74 | "nixpkgs": {
75 | "locked": {
76 | "lastModified": 1682600000,
77 | "narHash": "sha256-ha4BehR1dh8EnXSoE1m/wyyYVvHI9txjW4w5/oxsW5Y=",
78 | "owner": "nixos",
79 | "repo": "nixpkgs",
80 | "rev": "50fc86b75d2744e1ab3837ef74b53f103a9b55a0",
81 | "type": "github"
82 | },
83 | "original": {
84 | "owner": "nixos",
85 | "ref": "release-22.05",
86 | "repo": "nixpkgs",
87 | "type": "github"
88 | }
89 | },
90 | "root": {
91 | "inputs": {
92 | "cargo2nix": "cargo2nix",
93 | "flake-utils": "flake-utils_2",
94 | "nixpkgs": [
95 | "cargo2nix",
96 | "nixpkgs"
97 | ]
98 | }
99 | },
100 | "rust-overlay": {
101 | "inputs": {
102 | "flake-utils": [
103 | "cargo2nix",
104 | "flake-utils"
105 | ],
106 | "nixpkgs": [
107 | "cargo2nix",
108 | "nixpkgs"
109 | ]
110 | },
111 | "locked": {
112 | "lastModified": 1673490397,
113 | "narHash": "sha256-VCSmIYJy/ZzTvEGjdfITmTYfybXBgZpMjyjDndbou+8=",
114 | "owner": "oxalica",
115 | "repo": "rust-overlay",
116 | "rev": "0833f4d063a2bb75aa31680f703ba594a384ffe6",
117 | "type": "github"
118 | },
119 | "original": {
120 | "owner": "oxalica",
121 | "repo": "rust-overlay",
122 | "type": "github"
123 | }
124 | },
125 | "systems": {
126 | "locked": {
127 | "lastModified": 1681028828,
128 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
129 | "owner": "nix-systems",
130 | "repo": "default",
131 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
132 | "type": "github"
133 | },
134 | "original": {
135 | "owner": "nix-systems",
136 | "repo": "default",
137 | "type": "github"
138 | }
139 | }
140 | },
141 | "root": "root",
142 | "version": 7
143 | }
144 |
--------------------------------------------------------------------------------
/flake.nix:
--------------------------------------------------------------------------------
1 | {
2 | description = "Bridge for synchronizing email and tags between JMAP and notmuch";
3 | inputs = {
4 | flake-utils.url = "github:numtide/flake-utils";
5 | cargo2nix.url = "github:cargo2nix/cargo2nix/release-0.11.0";
6 | nixpkgs.follows = "cargo2nix/nixpkgs";
7 | };
8 |
9 | outputs = { self, nixpkgs, flake-utils, cargo2nix }:
10 | flake-utils.lib.eachSystem [ "x86_64-linux" ] (system:
11 | let
12 | pkgs = import nixpkgs {
13 | inherit system;
14 | overlays = [cargo2nix.overlays.default];
15 | };
16 | rustPkgs = pkgs.rustBuilder.makePackageSet {
17 | rustVersion = "1.61.0";
18 | packageFun = import ./Cargo.nix;
19 | };
20 | in
21 | {
22 | packages = rec {
23 | mujmap = ((rustPkgs.workspace.mujmap {}).overrideAttrs(oa: {
24 | propagatedBuildInputs = oa.propagatedBuildInputs ++ [ pkgs.notmuch ];
25 | })).bin;
26 |
27 | default = mujmap;
28 | };
29 | });
30 | }
31 |
--------------------------------------------------------------------------------
/mujmap.toml.example:
--------------------------------------------------------------------------------
1 | ################################################################################
2 | ## Required config
3 |
4 | ## Account username. Used for authentication to the server.
5 |
6 | username = "example@fastmail.com"
7 |
8 | ## Shell command which will print a password or token to stdout for
9 | ## authentication. You service provider might call this an "app password" or
10 | ## "API token".
11 |
12 | password_command = "pass example@fastmail.com"
13 |
14 | ## Fully qualified domain name of the JMAP service.
15 | ##
16 | ## mujmap looks up the JMAP SRV record for the domain part of the username to
17 | ## determine the JMAP session URL. Setting `fqdn` will cause it to use an
18 | ## alternate name for that lookup. Mutually exclusive with `session_url`.
19 |
20 | # fqdn = "fastmail.com"
21 |
22 | ## Session URL to connect to.
23 | ##
24 | ## Mutually exclusive with `fqdn`.
25 |
26 | # session_url = "https://api.fastmail.com/.well-known/jmap"
27 |
28 |
29 | ################################################################################
30 | ## Optional config
31 |
32 | ## Number of email files to download in parallel.
33 | ##
34 | ## This corresponds to the number of blocking OS threads that will be created for
35 | ## HTTP download requests. Increasing this number too high will likely result in
36 | ## many failed connections.
37 |
38 | # concurrent_downloads = 8
39 |
40 | ## Number of seconds before timing out on a stalled connection.
41 |
42 | # timeout = 5
43 |
44 | ## Number of retries to download an email file. 0 means infinite.
45 |
46 | # retries = 5
47 |
48 | ## Whether to create new mailboxes automatically on the server from notmuch
49 | ## tags.
50 |
51 | # auto_create_new_mailboxes = true
52 |
53 | ## If true, convert all DOS newlines in downloaded mail files to Unix newlines.
54 |
55 | # convert_dos_to_unix = true
56 |
57 | ## The cache directory in which to store mail files while they are being
58 | ## downloaded. The default is operating-system specific.
59 |
60 | # cache_dir =
61 |
62 |
63 | ################################################################################
64 | ## Tag config
65 | ##
66 | ## Customize the names and synchronization behaviors of notmuch tags with JMAP
67 | ## keywords and mailboxes. You can most likely leave these alone if you prefer
68 | ## the notmuch defaults, unless you would like to specifically ignore a tag.
69 | ##
70 | ## mujmap exposes as much configurability as reasonable here. However, there are
71 | ## limitations with the non-configurable special tags built-in to notmuch. These
72 | ## include:
73 | ##
74 | ## - draft
75 | ## - flagged
76 | ## - passed
77 | ## - replied
78 | ## - unread
79 | ##
80 | ## These are still synchronized with the appropriate mailboxes and keywords, but
81 | ## cannot be configured like the rest of the options here.
82 | ##
83 | ## https://notmuchmail.org/special-tags/
84 | ##
85 | ## BEWARE of changing any of these settings *after* you already have a nice and
86 | ## happy notmuch instance up and running! If you want to make changes here, your
87 | ## best option is to perform the following steps:
88 | ##
89 | ## 1. Before changing to the new config, make sure you've committed all pending
90 | ## changes by running mujmap.
91 | ##
92 | ## 2. Move all mail from the maildir into the mujmap cache directory, which is
93 | ## in XDG_CACHE_DIR/mujmap on Linux, ~/Library/Caches/sh.eliza.mujmap on macOS,
94 | ## and %APPDATA%/mujmap/cache on Windows. Rename each file so that they follow
95 | ## the pattern !home!username!path!to!maildir!XXX.YYY, where each ! replaces the
96 | ## path separator of the original file location, and XXX and YYY are the mail
97 | ## and blob IDs. If you have notmuch configured to sync tags with maildir flags,
98 | ## be sure to remove the trailing ":2," and everything past it on each filename.
99 | ##
100 | ## 3. Because step 2 is annoying to do, you can just delete them and have mujmap
101 | ## redownload them later if you'd prefer.
102 | ##
103 | ## 4. Run `notmuch new --no-hooks` so that all of the messages you just removed
104 | ## are also removed from the database.
105 | ##
106 | ## 5. Delete the "mujmap.state.json" file in the maildir to force a full sync.
107 | ##
108 | ## 6. Change all the configuration options you'd like, then run mujmap. It
109 | ## should synchronize all the messages properly now.
110 |
111 | [tags]
112 |
113 | ## Translate all mailboxes to lowercase names when mapping to notmuch tags.
114 |
115 | # lowercase = false
116 |
117 | ## Directory separator for mapping notmuch tags to maildirs.
118 |
119 | # directory_separator = "/"
120 |
121 | ## Tag for notmuch to use for messages stored in the mailbox labeled with the
122 | ## `Inbox` name attribute.
123 | ##
124 | ## If set to an empty string, this mailbox *and its child mailboxes* are not
125 | ## synchronized with a tag.
126 |
127 | # inbox = "inbox"
128 |
129 | ## Tag for notmuch to use for messages stored in the mailbox labeled with the
130 | ## `Trash` name attribute.
131 | ##
132 | ## This configuration option is called `deleted` instead of `trash` because
133 | ## notmuch's UIs all prefer "deleted" by default.
134 | ##
135 | ## If set to an empty string, this mailbox *and its child mailboxes* are not
136 | ## synchronized with a tag.
137 |
138 | # deleted = "deleted"
139 |
140 | ## Tag for notmuch to use for messages stored in the mailbox labeled with the
141 | ## `Sent` name attribute.
142 | ##
143 | ## If set to an empty string, this mailbox *and its child mailboxes* are not
144 | ## synchronized with a tag.
145 |
146 | # sent = "sent"
147 |
148 | ## Tag for notmuch to use for messages stored in the mailbox labeled with the
149 | ## `Junk` name attribute and/or with the `$Junk` keyword, *except* for messages
150 | ## with the `$NotJunk` keyword.
151 | ##
152 | ## The combination of these three traits becomes a bit tangled, so further
153 | ## explanation is warranted. Most email services in the modern day, especially
154 | ## those that support JMAP, provide a dedicated "Spam" or "Junk" mailbox which
155 | ## has the `Junk` name attribute mentioned above. However, there may exist
156 | ## services which do not have this mailbox, but still support the `$Junk` and
157 | ## `$NotJunk` keywords. mujmap behaves in the following way:
158 | ##
159 | ## * If the mailbox exists, it becomes the sole source of truth. mujmap
160 | ## will entirely disregard the `$Junk` and `$NotJunk` keywords.
161 | ## * If the mailbox does not exist, messages with the `$Junk` keyword *that
162 | ## do not also have* a `$NotJunk` keyword are tagged as spam. When
163 | ## pushing, both `$Junk` and `$NotJunk` are set appropriately.
164 | ##
165 | ## This configuration option is called `spam` instead of `junk` despite all of
166 | ## the aforementioned specifications preferring "junk" because notmuch's UIs all
167 | ## prefer "spam" by default.
168 | ##
169 | ## If set to an empty string, this mailbox, *its child mailboxes*, and these
170 | ## keywords are not synchronized with a tag.
171 |
172 | # spam = "spam"
173 |
174 | ## Tag for notmuch to use for messages stored in the mailbox labeled with the
175 | ## `Important` name attribute and/or with the `$Important` keyword.
176 | ##
177 | ## * If a mailbox with the `Important` role exists, this is used as the
178 | ## sole source of truth when pulling for tagging messages as "important".
179 | ## * If not, the `$Important` keyword is considered instead.
180 | ## * In both cases, the `$Important` keyword is set on the server when
181 | ## pushing. In the first case, it's also copied to the `Important`
182 | ## mailbox.
183 | ##
184 | ## If set to an empty string, this mailbox, *its child mailboxes*, and this
185 | ## keyword are not synchronized with a tag.
186 |
187 | # important = "important"
188 |
189 | ## Tag for notmuch to use for the IANA `$Phishing` keyword.
190 | ##
191 | ## If set to an empty string, this keyword is not synchronized with a tag.
192 |
193 | # phishing = "phishing"
194 |
--------------------------------------------------------------------------------
/shell.nix:
--------------------------------------------------------------------------------
1 | { pkgs ? import { } }:
2 |
3 | pkgs.mkShell {
4 | nativeBuildInputs = with pkgs; [
5 | cargo
6 | rust-analyzer
7 | rustc
8 | rustfmt
9 | ];
10 |
11 | buildInputs = with pkgs; [
12 | notmuch
13 | ];
14 |
15 | shellHook = ''
16 | '';
17 | }
18 |
--------------------------------------------------------------------------------
/src/args.rs:
--------------------------------------------------------------------------------
1 | use clap::{Parser, Subcommand};
2 | use clap_verbosity_flag::{Verbosity, WarnLevel};
3 | use const_format::formatcp;
4 | use std::path::PathBuf;
5 |
6 | const LICENSE: &str = "Copyright (C) 2022 Eliza Velasquez
7 | License GPLv3+: GNU GPL version 3 or later
8 | This is free software: you are free to change and redistribute it.
9 | There is NO WARRANTY, to the extent permitted by law.";
10 |
11 | const VERSION: &str = formatcp!("{}\n{}", clap::crate_version!(), LICENSE);
12 |
13 | #[derive(Parser, Debug)]
14 | #[clap(author, version, long_version = VERSION, about, long_about = None)]
15 | pub struct Args {
16 | /// Path to config file.
17 | ///
18 | /// Defaults to the current working directory.
19 | #[clap(short = 'C', long)]
20 | pub path: Option,
21 |
22 | /// Test a sync without committing any changes.
23 | #[clap(short, long)]
24 | pub dry_run: bool,
25 |
26 | #[clap(flatten)]
27 | pub verbose: Verbosity,
28 |
29 | #[clap(subcommand)]
30 | pub command: Command,
31 | }
32 |
33 | #[derive(Subcommand, Debug)]
34 | pub enum Command {
35 | /// Push mail without pulling changes.
36 | Push,
37 | /// Synchronize mail.
38 | Sync,
39 | /// Send mail.
40 | Send {
41 | /// Ignored sendmail-compatible flag.
42 | #[clap(short = 'i')]
43 | sendmail1: bool,
44 | /// Ignored sendmail-compatible flag.
45 | #[clap(short = 'f', name = "NAME")]
46 | sendmail2: Option,
47 | /// Ignored sendmail-compatible flag.
48 | #[clap(short = 'F', name = "FULLNAME")]
49 | sendmail3: Option,
50 | /// Read the message to obtain recipients.
51 | ///
52 | /// If specified, the recipient arguments are ignored.
53 | #[clap(short = 't', long)]
54 | read_recipients: bool,
55 | /// Email addresses of the recipients of the message.
56 | recipients: Vec,
57 | },
58 | }
59 |
--------------------------------------------------------------------------------
/src/cache.rs:
--------------------------------------------------------------------------------
1 | use crate::config::Config;
2 | use crate::jmap;
3 | use crate::sync::NewEmail;
4 | use directories::ProjectDirs;
5 | use snafu::prelude::*;
6 | use snafu::Snafu;
7 | use std::fs;
8 | use std::fs::File;
9 | use std::io;
10 | use std::io::Read;
11 | use std::path::Path;
12 | use std::path::PathBuf;
13 |
14 | #[derive(Debug, Snafu)]
15 | pub enum Error {
16 | #[snafu(display("Could not create cache dir `{}': {}", path.to_string_lossy(), source))]
17 | CreateCacheDir { path: PathBuf, source: io::Error },
18 |
19 | #[snafu(display("Could not create mail file `{}': {}", path.to_string_lossy(), source))]
20 | CreateUnixMailFile {
21 | path: PathBuf,
22 | source: loe::ParseError,
23 | },
24 |
25 | #[snafu(display("Could not create mail file `{}': {}", path.to_string_lossy(), source))]
26 | CreateMailFile { path: PathBuf, source: io::Error },
27 |
28 | #[snafu(display("Could not rename mail file from `{}' to `{}': {}", from.to_string_lossy(), to.to_string_lossy(), source))]
29 | RenameMailFile {
30 | from: PathBuf,
31 | to: PathBuf,
32 | source: io::Error,
33 | },
34 | }
35 |
36 | pub type Result = std::result::Result;
37 |
38 | pub struct Cache {
39 | /// The path to mujmap's cache, where emails are downloaded before being placed in the maildir.
40 | cache_dir: PathBuf,
41 | /// The prefix to prepend to files in the cache.
42 | ///
43 | /// Cached blobs are stored as full paths in the same format that Emacs uses to store backup
44 | /// files, i.e. the path of the filename with each ! doubled and each directory separator
45 | /// replaced with a !. This is done because the JMAP spec does not specify that IDs should be
46 | /// globally unique across accounts, and regardless, the user might configure multiple instances
47 | /// of mujmap to manage multiple accounts on different services. As a result, the cached files
48 | /// look something like this:
49 | ///
50 | /// `!home!username!Maildir!username@example.com!cur!XxXxXx.YyYyYy`
51 | cached_file_prefix: String,
52 | }
53 |
54 | impl Cache {
55 | /// Open the local store.
56 | ///
57 | /// `mail_dir` *must* be a subdirectory of the notmuch path.
58 | pub fn open(mail_cur_dir: impl AsRef, config: &Config) -> Result {
59 | let project_dirs = ProjectDirs::from("sh.eliza", "", "mujmap").unwrap();
60 | let default_cache_dir = project_dirs.cache_dir();
61 |
62 | let cache_dir = match &config.cache_dir {
63 | Some(cache_dir) => cache_dir.as_ref(),
64 | None => default_cache_dir,
65 | };
66 |
67 | // Ensure the cache dir exists.
68 | fs::create_dir_all(cache_dir).context(CreateCacheDirSnafu { path: cache_dir })?;
69 |
70 | // Create the cache filename prefix for this particular maildir. More information about this
71 | // is found in the documentation for `Local::cached_file_prefix`.
72 | let mut cached_file_prefix = mail_cur_dir
73 | .as_ref()
74 | .to_string_lossy()
75 | .as_ref()
76 | .replace("!", "!!")
77 | .replace("/", "!");
78 | cached_file_prefix.push('!');
79 |
80 | Ok(Self {
81 | cache_dir: cache_dir.into(),
82 | cached_file_prefix,
83 | })
84 | }
85 |
86 | /// Return the path in the cache for the given IDs.
87 | pub fn cache_path(&self, email_id: &jmap::Id, blob_id: &jmap::Id) -> PathBuf {
88 | self.cache_dir.join(format!(
89 | "{}{}.{}",
90 | self.cached_file_prefix, email_id.0, blob_id.0
91 | ))
92 | }
93 |
94 | /// Save the data from the given reader into the cache.
95 | ///
96 | /// This is done first by downloading to a temporary file so that in the event of a catastrophic
97 | /// failure, e.g. sudden power outage, there will (hopefully less likely) be half-downloaded
98 | /// mail files. JMAP doesn't expose any means of checking data integrity other than comparing
99 | /// blob IDs, so it's important we take every precaution.
100 | pub fn download_into_cache(
101 | &self,
102 | new_email: &NewEmail,
103 | mut reader: impl Read,
104 | convert_dos_to_unix: bool,
105 | ) -> Result<()> {
106 | // Download to temporary file...
107 | let temporary_file_path = self.cache_dir.join(format!(
108 | "{}in_progress_download.{}",
109 | self.cached_file_prefix,
110 | rayon::current_thread_index().unwrap_or(0)
111 | ));
112 | let mut writer = File::create(&temporary_file_path).context(CreateMailFileSnafu {
113 | path: &temporary_file_path,
114 | })?;
115 | if convert_dos_to_unix {
116 | loe::process(&mut reader, &mut writer, loe::Config::default()).context(
117 | CreateUnixMailFileSnafu {
118 | path: &temporary_file_path,
119 | },
120 | )?;
121 | } else {
122 | io::copy(&mut reader, &mut writer).context(CreateMailFileSnafu {
123 | path: &temporary_file_path,
124 | })?;
125 | }
126 | // ...and move to its proper location.
127 | fs::rename(&temporary_file_path, &new_email.cache_path).context(RenameMailFileSnafu {
128 | from: &temporary_file_path,
129 | to: &new_email.cache_path,
130 | })?;
131 | Ok(())
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/src/config.rs:
--------------------------------------------------------------------------------
1 | use serde::Deserialize;
2 | use snafu::prelude::*;
3 | use std::{
4 | fs, io,
5 | path::{Path, PathBuf},
6 | process::{Command, ExitStatus},
7 | string::FromUtf8Error,
8 | };
9 |
10 | use snafu::Snafu;
11 |
12 | #[derive(Debug, Snafu)]
13 | pub enum Error {
14 | #[snafu(display("Could not read config file `{}': {}", filename.to_string_lossy(), source))]
15 | ReadConfigFile {
16 | filename: PathBuf,
17 | source: io::Error,
18 | },
19 |
20 | #[snafu(display("Could not parse config file `{}': {}", filename.to_string_lossy(), source))]
21 | ParseConfigFile {
22 | filename: PathBuf,
23 | source: toml::de::Error,
24 | },
25 |
26 | #[snafu(display("Can only specify one of `fqdn' or `session_url' in the same config"))]
27 | FqdnOrSessionUrl {},
28 |
29 | #[snafu(display("Must specify at least 1 for `concurrent_downloads'"))]
30 | ConcurrentDownloadsIsZero {},
31 |
32 | #[snafu(display("`directory_separator' must not be empty"))]
33 | EmptyDirectorySeparator {},
34 |
35 | #[snafu(display("Could not execute password command: {}", source))]
36 | ExecutePasswordCommand { source: io::Error },
37 |
38 | #[snafu(display("Password command exited with `{}': {}", status, stderr))]
39 | PasswordCommandStatus { status: ExitStatus, stderr: String },
40 |
41 | #[snafu(display("Could not decode password command output as utf-8"))]
42 | DecodePasswordCommand { source: FromUtf8Error },
43 | }
44 |
45 | pub type Result = std::result::Result;
46 |
47 | #[derive(Debug, Deserialize)]
48 | pub struct Config {
49 | /// Username for basic HTTP authentication.
50 | pub username: String,
51 |
52 | /// Shell command which will print a password to stdout for basic HTTP authentication.
53 | pub password_command: String,
54 |
55 | /// Fully qualified domain name of the JMAP service.
56 | ///
57 | /// mujmap looks up the JMAP SRV record for this host to determine the JMAP session URL.
58 | /// Mutually exclusive with `session_url`.
59 | pub fqdn: Option,
60 |
61 | /// Session URL to connect to.
62 | ///
63 | /// Mutually exclusive with `fqdn`.
64 | pub session_url: Option,
65 |
66 | /// Number of email files to download in parallel.
67 | ///
68 | /// This corresponds to the number of blocking OS threads that will be created for HTTP download
69 | /// requests. Increasing this number too high will likely result in many failed connections.
70 | #[serde(default = "default_concurrent_downloads")]
71 | pub concurrent_downloads: usize,
72 |
73 | /// Number of seconds before timing out on a stalled connection.
74 | #[serde(default = "default_timeout")]
75 | pub timeout: u64,
76 |
77 | /// Number of retries to download an email file. 0 means infinite.
78 | #[serde(default = "default_retries")]
79 | pub retries: usize,
80 |
81 | /// Whether to create new mailboxes automatically on the server from notmuch tags.
82 | #[serde(default = "default_auto_create_new_mailboxes")]
83 | pub auto_create_new_mailboxes: bool,
84 |
85 | /// If true, convert all DOS newlines in downloaded mail files to Unix newlines.
86 | #[serde(default = "default_convert_dos_to_unix")]
87 | pub convert_dos_to_unix: bool,
88 |
89 | /// The cache directory in which to store mail files while they are being downloaded. The
90 | /// default is operating-system specific.
91 | #[serde(default = "Default::default")]
92 | pub cache_dir: Option,
93 |
94 | /// Customize the names and synchronization behaviors of notmuch tags with JMAP keywords and
95 | /// mailboxes.
96 | #[serde(default = "Default::default")]
97 | pub tags: Tags,
98 | }
99 |
100 | #[derive(Debug, Deserialize)]
101 | pub struct Tags {
102 | /// Translate all mailboxes to lowercase names when mapping to notmuch tags.
103 | ///
104 | /// Defaults to `false`.
105 | #[serde(default = "default_lowercase")]
106 | pub lowercase: bool,
107 |
108 | /// Directory separator for mapping notmuch tags to maildirs.
109 | ///
110 | /// Defaults to `"/"`.
111 | #[serde(default = "default_directory_separator")]
112 | pub directory_separator: String,
113 |
114 | /// Tag for notmuch to use for messages stored in the mailbox labeled with the [Inbox name
115 | /// attribute](https://www.rfc-editor.org/rfc/rfc8621.html).
116 | ///
117 | /// If set to an empty string, this mailbox *and its child mailboxes* are not synchronized with
118 | /// a tag.
119 | ///
120 | /// Defaults to `"inbox"`.
121 | #[serde(default = "default_inbox")]
122 | pub inbox: String,
123 |
124 | /// Tag for notmuch to use for messages stored in the mailbox labeled with the [Trash name
125 | /// attribute](https://www.rfc-editor.org/rfc/rfc6154.html).
126 | ///
127 | /// This configuration option is called `deleted` instead of `trash` because notmuch's UIs all
128 | /// prefer "deleted" by default.
129 | ///
130 | /// If set to an empty string, this mailbox *and its child mailboxes* are not synchronized with
131 | /// a tag.
132 | ///
133 | /// Defaults to `"deleted"`.
134 | #[serde(default = "default_deleted")]
135 | pub deleted: String,
136 |
137 | /// Tag for notmuch to use for messages stored in the mailbox labeled with the [`Sent` name
138 | /// attribute](https://www.rfc-editor.org/rfc/rfc6154.html).
139 | ///
140 | /// If set to an empty string, this mailbox *and its child mailboxes* are not synchronized with
141 | /// a tag.
142 | ///
143 | /// Defaults to `"sent"`.
144 | #[serde(default = "default_sent")]
145 | pub sent: String,
146 |
147 | /// Tag for notmuch to use for messages stored in the mailbox labeled with the [`Junk` name
148 | /// attribute](https://www.rfc-editor.org/rfc/rfc8621.html) and/or with the [`$Junk`
149 | /// keyword](https://www.iana.org/assignments/imap-jmap-keywords/junk/junk-template), except for
150 | /// messages with the [`$NotJunk`
151 | /// keyword](https://www.iana.org/assignments/imap-jmap-keywords/notjunk/notjunk-template).
152 | ///
153 | /// The combination of these three traits becomes a bit tangled, so further explanation is
154 | /// warranted. Most email services in the modern day, especially those that support JMAP,
155 | /// provide a dedicated "Spam" or "Junk" mailbox which has the `Junk` name attribute mentioned
156 | /// above. However, there may exist services which do not have this mailbox, but still support
157 | /// the `$Junk` and `$NotJunk` keywords. mujmap behaves in the following way:
158 | ///
159 | /// * If the mailbox exists, it becomes the sole source of truth. mujmap will entirely disregard
160 | /// the `$Junk` and `$NotJunk` keywords. * If the mailbox does not exist, messages with the
161 | /// `$Junk` keyword *that do not also have* a `$NotJunk` keyword are tagged as spam. When
162 | /// pushing, both `$Junk` and `$NotJunk` are set appropriately.
163 | ///
164 | /// This configuration option is called `spam` instead of `junk` despite all of the
165 | /// aforementioned specifications preferring "junk" because notmuch's UIs all prefer "spam" by
166 | /// default.
167 | ///
168 | /// If set to an empty string, this mailbox, *its child mailboxes*, and these keywords are not
169 | /// synchronized with a tag.
170 | ///
171 | /// Defaults to `"spam"`.
172 | #[serde(default = "default_spam")]
173 | pub spam: String,
174 |
175 | /// Tag for notmuch to use for messages stored in the mailbox labeled with the [`Important` name
176 | /// attribute](https://www.rfc-editor.org/rfc/rfc8457.html) and/or with the [`$Important`
177 | /// keyword](https://www.rfc-editor.org/rfc/rfc8457.html).
178 | ///
179 | /// * If a mailbox with the `Important` role exists, this is used as the sole source of truth
180 | /// when pulling for tagging messages as "important". * If not, the `$Important` keyword is
181 | /// considered instead. * In both cases, the `$Important` keyword is set on the server when
182 | /// pushing. In the first case, it's also copied to the `Important` mailbox.
183 | ///
184 | /// If set to an empty string, this mailbox, *its child mailboxes*, and this keyword are not
185 | /// synchronized with a tag.
186 | ///
187 | /// Defaults to `"important"`.
188 | #[serde(default = "default_important")]
189 | pub important: String,
190 |
191 | /// Tag for notmuch to use for the [IANA `$Phishing`
192 | /// keyword](https://www.iana.org/assignments/imap-jmap-keywords/phishing/phishing-template).
193 | ///
194 | /// If set to an empty string, this keyword is not synchronized with a tag.
195 | ///
196 | /// Defaults to `"phishing"`.
197 | #[serde(default = "default_phishing")]
198 | pub phishing: String,
199 | }
200 |
201 | impl Default for Tags {
202 | fn default() -> Self {
203 | Self {
204 | lowercase: default_lowercase(),
205 | directory_separator: default_directory_separator(),
206 | inbox: default_inbox(),
207 | deleted: default_deleted(),
208 | sent: default_sent(),
209 | spam: default_spam(),
210 | important: default_important(),
211 | phishing: default_phishing(),
212 | }
213 | }
214 | }
215 |
216 | fn default_lowercase() -> bool {
217 | false
218 | }
219 |
220 | fn default_directory_separator() -> String {
221 | "/".to_owned()
222 | }
223 |
224 | fn default_inbox() -> String {
225 | "inbox".to_owned()
226 | }
227 |
228 | fn default_deleted() -> String {
229 | "deleted".to_owned()
230 | }
231 |
232 | fn default_sent() -> String {
233 | "sent".to_owned()
234 | }
235 |
236 | fn default_spam() -> String {
237 | "spam".to_owned()
238 | }
239 |
240 | fn default_important() -> String {
241 | "important".to_owned()
242 | }
243 |
244 | fn default_phishing() -> String {
245 | "phishing".to_owned()
246 | }
247 |
248 | fn default_concurrent_downloads() -> usize {
249 | 8
250 | }
251 |
252 | fn default_timeout() -> u64 {
253 | 5
254 | }
255 |
256 | fn default_retries() -> usize {
257 | 5
258 | }
259 |
260 | fn default_auto_create_new_mailboxes() -> bool {
261 | true
262 | }
263 |
264 | fn default_convert_dos_to_unix() -> bool {
265 | true
266 | }
267 |
268 | impl Config {
269 | pub fn from_file(path: impl AsRef) -> Result {
270 | let contents = fs::read_to_string(path.as_ref()).context(ReadConfigFileSnafu {
271 | filename: path.as_ref(),
272 | })?;
273 | let config: Self = toml::from_str(contents.as_str()).context(ParseConfigFileSnafu {
274 | filename: path.as_ref(),
275 | })?;
276 |
277 | // Perform final validation.
278 | ensure!(
279 | !(config.fqdn.is_some() && config.session_url.is_some()),
280 | FqdnOrSessionUrlSnafu {}
281 | );
282 | ensure!(
283 | config.concurrent_downloads > 0,
284 | ConcurrentDownloadsIsZeroSnafu {}
285 | );
286 | ensure!(
287 | !config.tags.directory_separator.is_empty(),
288 | EmptyDirectorySeparatorSnafu {}
289 | );
290 | Ok(config)
291 | }
292 |
293 | pub fn password(&self) -> Result {
294 | let output = Command::new("sh")
295 | .arg("-c")
296 | .arg(self.password_command.as_str())
297 | .output()
298 | .context(ExecutePasswordCommandSnafu {})?;
299 | ensure!(
300 | output.status.success(),
301 | PasswordCommandStatusSnafu {
302 | status: output.status,
303 | stderr: String::from_utf8(output.stderr)
304 | .unwrap_or_else(|e| format!("")),
305 | }
306 | );
307 | let stdout = String::from_utf8(output.stdout).context(DecodePasswordCommandSnafu {})?;
308 | Ok(stdout.trim().to_string())
309 | }
310 | }
311 |
--------------------------------------------------------------------------------
/src/jmap/mod.rs:
--------------------------------------------------------------------------------
1 | mod request;
2 | mod response;
3 | mod session;
4 |
5 | use core::fmt;
6 |
7 | pub use request::*;
8 | pub use response::*;
9 | use serde::{Deserialize, Serialize};
10 | pub use session::*;
11 |
12 | #[derive(Eq, PartialEq, Hash, Serialize, Deserialize, Debug, Clone)]
13 | pub struct Id(pub String);
14 |
15 | impl fmt::Display for Id {
16 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
17 | self.0.fmt(f)
18 | }
19 | }
20 |
21 | #[derive(Eq, PartialEq, Hash, Serialize, Deserialize, Debug, Clone)]
22 | pub struct State(pub String);
23 |
24 | impl fmt::Display for State {
25 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
26 | self.0.fmt(f)
27 | }
28 | }
29 |
30 | /// Keywords that assign meaning to email.
31 | ///
32 | /// Note that JMAP mandates that these be lowercase.
33 | ///
34 | /// See .
35 | #[derive(Clone, Copy, Eq, PartialEq, Hash, Debug, Serialize, Deserialize)]
36 | pub enum EmailKeyword {
37 | #[serde(rename = "$draft")]
38 | Draft,
39 | #[serde(rename = "$seen")]
40 | Seen,
41 | #[serde(rename = "$flagged")]
42 | Flagged,
43 | #[serde(rename = "$answered")]
44 | Answered,
45 | #[serde(rename = "$forwarded")]
46 | Forwarded,
47 | #[serde(rename = "$junk")]
48 | Junk,
49 | #[serde(rename = "$notjunk")]
50 | NotJunk,
51 | #[serde(rename = "$phishing")]
52 | Phishing,
53 | #[serde(rename = "$important")]
54 | Important,
55 | #[serde(other)]
56 | Unknown,
57 | }
58 |
--------------------------------------------------------------------------------
/src/jmap/request.rs:
--------------------------------------------------------------------------------
1 | use super::{EmailKeyword, Id, State};
2 | use serde::{ser::SerializeSeq, Serialize, Serializer};
3 | use serde_json::Value;
4 | use std::collections::HashMap;
5 |
6 | #[derive(Serialize)]
7 | pub enum CapabilityKind {
8 | #[serde(rename = "urn:ietf:params:jmap:mail")]
9 | Mail,
10 | #[serde(rename = "urn:ietf:params:jmap:submission")]
11 | Submission,
12 | }
13 |
14 | #[derive(Serialize)]
15 | #[serde(rename_all = "camelCase")]
16 | pub struct Request<'a> {
17 | /// The set of capabilities the client wishes to use. The client MAY include capability
18 | /// identifiers even if the method calls it makes do not utilise those capabilities.
19 | pub using: &'a [CapabilityKind],
20 | /// An array of method calls to process on the server. The method calls MUST be processed
21 | /// sequentially, in order.
22 | pub method_calls: &'a [RequestInvocation<'a>],
23 | /// A map of a (client-specified) creation id to the id the server assigned when a record was
24 | /// successfully created.
25 | ///
26 | /// As described later in this specification, some records may have a property that contains the
27 | /// id of another record. To allow more efficient network usage, you can set this property to
28 | /// reference a record created earlier in the same API request. Since the real id is unknown
29 | /// when the request is created, the client can instead specify the creation id it assigned,
30 | /// prefixed with a #.
31 | ///
32 | /// As the server processes API requests, any time it successfully creates a new record, it adds
33 | /// the creation id to this map with the server-assigned real id as the value. If it comes
34 | /// across a reference to a creation id in a create/update, it looks it up in the map and
35 | /// replaces the reference with the real id, if found.
36 | ///
37 | /// The client can pass an initial value for this map as the `created_ids` property of the
38 | /// `Request` object. This may be an empty object. If given in the request, the response will
39 | /// also include a `created_ids` property.
40 | #[serde(skip_serializing_if = "Option::is_none")]
41 | pub created_ids: Option>,
42 | }
43 |
44 | pub struct RequestInvocation<'a> {
45 | pub call: MethodCall<'a>,
46 | /// An arbitrary string from the client to be echoed back with the responses emitted by that
47 | /// method call (a method may return 1 or more responses, as it may make implicit calls to other
48 | /// methods; all responses initiated by this method call get the same method call id in the
49 | /// response).
50 | pub id: &'a str,
51 | }
52 |
53 | impl<'a> Serialize for RequestInvocation<'a> {
54 | fn serialize(&self, serializer: S) -> Result
55 | where
56 | S: Serializer,
57 | {
58 | let mut seq = serializer.serialize_seq(Some(3))?;
59 |
60 | match self.call {
61 | MethodCall::EmailGet { .. } => {
62 | seq.serialize_element("Email/get")?;
63 | }
64 | MethodCall::EmailQuery { .. } => {
65 | seq.serialize_element("Email/query")?;
66 | }
67 | MethodCall::EmailChanges { .. } => {
68 | seq.serialize_element("Email/changes")?;
69 | }
70 | MethodCall::EmailSet { .. } => {
71 | seq.serialize_element("Email/set")?;
72 | }
73 | MethodCall::EmailImport { .. } => {
74 | seq.serialize_element("Email/import")?;
75 | }
76 | MethodCall::MailboxGet { .. } => {
77 | seq.serialize_element("Mailbox/get")?;
78 | }
79 | MethodCall::MailboxSet { .. } => {
80 | seq.serialize_element("Mailbox/set")?;
81 | }
82 | MethodCall::IdentityGet { .. } => {
83 | seq.serialize_element("Identity/get")?;
84 | }
85 | MethodCall::EmailSubmissionSet { .. } => {
86 | seq.serialize_element("EmailSubmission/set")?;
87 | }
88 | }
89 |
90 | seq.serialize_element(&self.call)?;
91 | seq.serialize_element(self.id)?;
92 | seq.end()
93 | }
94 | }
95 |
96 | #[derive(Serialize)]
97 | #[serde(untagged)]
98 | pub enum MethodCall<'a> {
99 | #[serde(rename_all = "camelCase")]
100 | EmailGet {
101 | #[serde(flatten)]
102 | get: MethodCallGet<'a>,
103 | },
104 |
105 | #[serde(rename_all = "camelCase")]
106 | EmailQuery {
107 | #[serde(flatten)]
108 | query: MethodCallQuery<'a>,
109 | },
110 |
111 | #[serde(rename_all = "camelCase")]
112 | EmailChanges {
113 | #[serde(flatten)]
114 | changes: MethodCallChanges<'a>,
115 | },
116 |
117 | #[serde(rename_all = "camelCase")]
118 | EmailSet {
119 | #[serde(flatten)]
120 | set: MethodCallSet<'a, EmptyCreate>,
121 | },
122 |
123 | #[serde(rename_all = "camelCase")]
124 | EmailImport {
125 | /// The id of the account to use.
126 | account_id: &'a Id,
127 | /// A map of creation id (client specified) to `EmailImport` objects.
128 | emails: HashMap<&'a Id, EmailImport<'a>>,
129 | },
130 |
131 | #[serde(rename_all = "camelCase")]
132 | MailboxGet {
133 | #[serde(flatten)]
134 | get: MethodCallGet<'a>,
135 | },
136 |
137 | #[serde(rename_all = "camelCase")]
138 | MailboxSet {
139 | #[serde(flatten)]
140 | set: MethodCallSet<'a, MailboxCreate>,
141 | },
142 |
143 | #[serde(rename_all = "camelCase")]
144 | IdentityGet {
145 | #[serde(flatten)]
146 | get: MethodCallGet<'a>,
147 | },
148 |
149 | #[serde(rename_all = "camelCase")]
150 | EmailSubmissionSet {
151 | #[serde(flatten)]
152 | set: MethodCallSet<'a, EmailSubmissionCreate<'a>>,
153 | /// A map of `EmailSubmission` id to an object containing properties to update on the
154 | /// `Email` object referenced by the `EmailSubmission` if the create/update/destroy
155 | /// succeeds. (For references to `EmailSubmission`s created in the same "/set" invocation,
156 | /// this is equivalent to a creation-reference, so the id will be the creation id prefixed
157 | /// with a "#".)
158 | #[serde(skip_serializing_if = "Option::is_none")]
159 | on_success_update_email: Option>>,
160 | },
161 | }
162 |
163 | #[derive(Serialize)]
164 | #[serde(rename_all = "camelCase")]
165 | pub struct MethodCallGet<'a> {
166 | /// The id of the account to use.
167 | pub account_id: &'a Id,
168 | /// The ids of the Foo objects to return. If `None`, then all records of the data type are
169 | /// returned, if this is supported for that data type and the number of records does not exceed
170 | /// the `max_objects_in_get` limit.
171 | #[serde(skip_serializing_if = "Option::is_none")]
172 | pub ids: Option<&'a [&'a Id]>,
173 | /// If supplied, only the properties listed in the array are returned for each Foo object. If
174 | /// `None`, all properties of the object are returned. The id property of the object is always
175 | /// returned, even if not explicitly requested. If an invalid property is requested, the call
176 | /// MUST be rejected with a `ResponseError::InvalidArguments` error.
177 | #[serde(skip_serializing_if = "Option::is_none")]
178 | pub properties: Option<&'a [&'a str]>,
179 | }
180 |
181 | #[derive(Serialize)]
182 | #[serde(rename_all = "camelCase")]
183 | pub struct MethodCallQuery<'a> {
184 | /// The id of the account to use.
185 | pub account_id: &'a Id,
186 | /// The zero-based index of the first id in the full list of results to return.
187 | ///
188 | /// If a negative value is given, it is an offset from the end of the list. Specifically, the
189 | /// negative value MUST be added to the total number of results given the filter, and if still
190 | /// negative, it’s clamped to 0. This is now the zero-based index of the first id to return.
191 | ///
192 | /// If the index is greater than or equal to the total number of objects in the results list,
193 | /// then the ids array in the response will be empty, but this is not an error.
194 | #[serde(default, skip_serializing_if = "default")]
195 | pub position: i64,
196 | /// A `Foo` id. If supplied, the position argument is ignored. The index of this id in the
197 | /// results will be used in combination with the `anchor_offset` argument to determine the index
198 | /// of the first result to return.
199 | #[serde(skip_serializing_if = "Option::is_none")]
200 | pub anchor: Option<&'a Id>,
201 | /// The index of the first result to return relative to the index of the anchor, if an anchor is
202 | /// given. This MAY be negative. For example, -1 means the Foo immediately preceding the anchor
203 | /// is the first result in the list returned.
204 | #[serde(default, skip_serializing_if = "default")]
205 | pub anchor_offset: i64,
206 | /// The maximum number of results to return. If `None`, no limit presumed. The server MAY choose
207 | /// to enforce a maximum limit argument. In this case, if a greater value is given (or if it is
208 | /// `None`), the limit is clamped to the maximum; the new limit is returned with the response so
209 | /// the client is aware. If a negative value is given, the call MUST be rejected with a
210 | /// `jmap::ResponseError::InvalidArguments` error.
211 | #[serde(skip_serializing_if = "Option::is_none")]
212 | pub limit: Option,
213 | /// Does the client wish to know the total number of results in the query? This may be slow and
214 | /// expensive for servers to calculate, particularly with complex filters, so clients should
215 | /// take care to only request the total when needed.
216 | #[serde(default, skip_serializing_if = "default")]
217 | pub calculate_total: bool,
218 | }
219 |
220 | #[derive(Serialize)]
221 | #[serde(rename_all = "camelCase")]
222 | pub struct MethodCallChanges<'a> {
223 | /// The id of the account to use.
224 | pub account_id: &'a Id,
225 | /// The current state of the client. This is the string that was returned as the state argument
226 | /// in the Foo/get response. The server will return the changes that have occurred since this
227 | /// state.
228 | pub since_state: &'a State,
229 | /// The maximum number of ids to return in the response. The server MAY choose to return fewer
230 | /// than this value but MUST NOT return more. If not given by the client, the server may choose
231 | /// how many to return. If supplied by the client, the value MUST be a positive integer greater
232 | /// than 0. If a value outside of this range is given, the server MUST reject the call with an
233 | /// invalidArguments error.
234 | #[serde(skip_serializing_if = "Option::is_none")]
235 | pub max_changes: Option,
236 | }
237 |
238 | #[derive(Serialize)]
239 | #[serde(rename_all = "camelCase")]
240 | pub struct MethodCallSet<'a, C> {
241 | /// The id of the account to use.
242 | pub account_id: &'a Id,
243 | /// This is a state string as returned by the `Foo/get` method (representing the state of all
244 | /// objects of this type in the account). If supplied, the string must match the current state;
245 | /// otherwise, the method will be aborted and a stateMismatch error returned. If `None`, any
246 | /// changes will be applied to the current state.
247 | #[serde(skip_serializing_if = "Option::is_none")]
248 | pub if_in_state: Option<&'a Id>,
249 | /// A map of a creation id (a temporary id set by the client) to `Foo` objects, or `None` if no
250 | /// objects are to be created.
251 | ///
252 | /// The Foo object type definition may define default values for properties. Any such property
253 | /// may be omitted by the client.
254 | ///
255 | /// The client MUST omit any properties that may only be set by the server (for example, the id
256 | /// property on most object types).
257 | #[serde(skip_serializing_if = "Option::is_none")]
258 | pub create: Option>,
259 | /// A map of an id to a Patch object to apply to the current `Foo` object with that id, or
260 | /// `None` if no objects are to be updated.
261 | ///
262 | /// A `PatchObject` is of type `String[*]` and represents an unordered set of patches. The keys
263 | /// are a path in JSON Pointer Format
264 | /// \[[RFC6901](https://datatracker.ietf.org/doc/html/rfc6901)\], with an implicit leading “/”
265 | /// (i.e., prefix each key with “/” before applying the JSON Pointer evaluation algorithm).
266 | ///
267 | /// All paths MUST also conform to the following restrictions; if there is any violation, the
268 | /// update MUST be rejected with an invalidPatch error:
269 | ///
270 | /// * The pointer MUST NOT reference inside an array (i.e., you MUST NOT insert/delete from an
271 | /// array; the array MUST be replaced in its entirety instead). * All parts prior to the last
272 | /// (i.e., the value after the final slash) MUST already exist on the object being patched. *
273 | /// There MUST NOT be two patches in the PatchObject where the pointer of one is the prefix of
274 | /// the pointer of the other, e.g., “alerts/1/offset” and “alerts”.
275 | ///
276 | /// The value associated with each pointer determines how to apply that patch:
277 | ///
278 | /// * If `None`, set to the default value if specified for this property; otherwise, remove the
279 | /// property from the patched object. If the key is not present in the parent, this a no-op. *
280 | /// Anything else: The value to set for this property (this may be a replacement or addition to
281 | /// the object being patched).
282 | ///
283 | /// Any server-set properties MAY be included in the patch if their value is identical to the
284 | /// current server value (before applying the patches to the object). Otherwise, the update MUST
285 | /// be rejected with an invalidProperties SetError.
286 | ///
287 | /// This patch definition is designed such that an entire `Foo` object is also a valid
288 | /// `PatchObject`. The client may choose to optimise network usage by just sending the diff or
289 | /// may send the whole object; the server processes it the same either way.
290 | #[serde(skip_serializing_if = "Option::is_none")]
291 | pub update: Option>>,
292 | /// A list of ids for `Foo` objects to permanently delete, or `None` if no objects are to be
293 | /// destroyed.
294 | #[serde(skip_serializing_if = "Option::is_none")]
295 | pub destroy: Option<&'a [&'a Id]>,
296 | }
297 |
298 | #[derive(Serialize)]
299 | #[serde(rename_all = "camelCase")]
300 | pub struct EmptyCreate;
301 |
302 | #[derive(Debug, Serialize)]
303 | #[serde(rename_all = "camelCase")]
304 | pub struct MailboxCreate {
305 | /// The Mailbox id for the parent of this `Mailbox`, or `None` if this Mailbox is at the top
306 | /// level. Mailboxes form acyclic graphs (forests) directed by the child-to-parent relationship.
307 | /// There MUST NOT be a loop.
308 | #[serde(skip_serializing_if = "Option::is_none")]
309 | pub parent_id: Option,
310 | /// User-visible name for the Mailbox, e.g., “Inbox”. This MUST be a Net-Unicode string
311 | /// \[[RFC5198](https://datatracker.ietf.org/doc/html/rfc5198)\] of at least 1 character in
312 | /// length, subject to the maximum size given in the capability object. There MUST NOT be two
313 | /// sibling Mailboxes with both the same parent and the same name. Servers MAY reject names that
314 | /// violate server policy (e.g., names containing a slash (/) or control characters).
315 | pub name: String,
316 | }
317 |
318 | #[derive(Debug, Serialize)]
319 | #[serde(rename_all = "camelCase")]
320 | pub struct EmailImport<'a> {
321 | /// The id of the blob containing the raw message
322 | /// \[[RFC5322](https://datatracker.ietf.org/doc/html/rfc5322)\].
323 | pub blob_id: Id,
324 | /// The ids of the Mailboxes to assign this Email to. At least one Mailbox MUST be given.
325 | pub mailbox_ids: HashMap<&'a Id, bool>,
326 | /// The keywords to apply to the Email.
327 | pub keywords: HashMap,
328 | }
329 |
330 | #[derive(Debug, Serialize)]
331 | #[serde(rename_all = "camelCase")]
332 | pub struct EmailSubmissionCreate<'a> {
333 | /// The id of the Identity to associate with this submission.
334 | pub identity_id: &'a Id,
335 | /// The id of the `Email` to send. The `Email` being sent does not have to be a draft, for
336 | /// example, when "redirecting" an existing `Email` to a different address.
337 | pub email_id: &'a Id,
338 | /// Information for use when sending via SMTP.
339 | ///
340 | /// NB: The JMAP spec says that this can be omitted by the client and the server MUST generate
341 | /// it instead from the email headers. Fastmail does not support this.
342 | pub envelope: Envelope<'a>,
343 | }
344 |
345 | #[derive(Debug, Serialize)]
346 | #[serde(rename_all = "camelCase")]
347 | pub struct Envelope<'a> {
348 | /// The email address to use as the return address in the SMTP submission, plus any parameters
349 | /// to pass with the MAIL FROM address. The JMAP server MAY allow the address to be the empty
350 | /// string.
351 | ///
352 | /// When a JMAP server performs an SMTP message submission, it MAY use the same id string for
353 | /// the ENVID parameter [RFC3461] and the `EmailSubmission` object id. Servers that do this MAY
354 | /// replace a client-provided value for ENVID with a server-provided value.
355 | pub mail_from: Address<'a>,
356 | /// The email addresses to send the message to, and any RCPT TO parameters to pass with the
357 | /// recipient.
358 | pub rcpt_to: &'a [Address<'a>],
359 | }
360 |
361 | #[derive(Debug, Serialize)]
362 | #[serde(rename_all = "camelCase")]
363 | pub struct Address<'a> {
364 | /// The email address being represented by the object. This is a "Mailbox" as used in the
365 | /// Reverse-path or Forward-path of the MAIL FROM or RCPT TO command in [RFC532].
366 | pub email: &'a str,
367 | }
368 |
369 | fn default(t: &T) -> bool {
370 | *t == Default::default()
371 | }
372 |
--------------------------------------------------------------------------------
/src/jmap/response.rs:
--------------------------------------------------------------------------------
1 | use serde::{
2 | de::{Error, SeqAccess, Visitor},
3 | Deserialize, Deserializer,
4 | };
5 | use std::{
6 | collections::HashMap,
7 | fmt::{self, Display},
8 | };
9 |
10 | use super::{EmailKeyword, Id, State};
11 |
12 | #[derive(Debug, Deserialize)]
13 | #[serde(rename_all = "camelCase")]
14 | pub struct Response {
15 | /// An array of responses. The output of the methods MUST be added to the `method_responses`
16 | /// array in the same order that the methods are processed.
17 | pub method_responses: Vec,
18 | /// (optional; only returned if given in the request) A map of a (client-specified) creation id
19 | /// to the id the server assigned when a record was successfully created. This MUST include all
20 | /// creation ids passed in the original createdIds parameter of the Request object, as well as
21 | /// any additional ones added for newly created records.
22 | pub created_ids: Option>,
23 | /// The current value of the “state” string on the `Session` object. Clients may use this to
24 | /// detect if this object has changed and needs to be refetched.
25 | pub session_state: State,
26 | }
27 |
28 | #[derive(Debug)]
29 | pub struct ResponseInvocation {
30 | pub call: MethodResponse,
31 | /// An arbitrary string from the client to be echoed back with the responses emitted by that
32 | /// method call (a method may return 1 or more responses, as it may make implicit calls to other
33 | /// methods; all responses initiated by this method call get the same method call id in the
34 | /// response).
35 | pub id: String,
36 | }
37 |
38 | impl<'de> Deserialize<'de> for ResponseInvocation {
39 | fn deserialize(deserializer: D) -> Result
40 | where
41 | D: Deserializer<'de>,
42 | {
43 | struct MethodResponseVisitor;
44 |
45 | impl<'de> Visitor<'de> for MethodResponseVisitor {
46 | type Value = ResponseInvocation;
47 |
48 | fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
49 | formatter.write_str("a sequence of [string, map, string]")
50 | }
51 |
52 | fn visit_seq(self, mut seq: A) -> Result
53 | where
54 | A: SeqAccess<'de>,
55 | {
56 | let name: String = seq.next_element()?.ok_or(Error::invalid_length(0, &"3"))?;
57 |
58 | let length_err = Error::invalid_length(1, &"3");
59 | let call = match name.as_str() {
60 | "Email/get" => Ok(MethodResponse::EmailGet(
61 | seq.next_element::>()?
62 | .ok_or(length_err)?,
63 | )),
64 | "Email/query" => Ok(MethodResponse::EmailQuery(
65 | seq.next_element::()?
66 | .ok_or(length_err)?,
67 | )),
68 | "Email/changes" => Ok(MethodResponse::EmailChanges(
69 | seq.next_element::()?
70 | .ok_or(length_err)?,
71 | )),
72 | "Email/set" => Ok(MethodResponse::EmailSet(
73 | seq.next_element::>()?
74 | .ok_or(length_err)?,
75 | )),
76 | "Email/import" => Ok(MethodResponse::EmailImport(
77 | seq.next_element::()?
78 | .ok_or(length_err)?,
79 | )),
80 | "Mailbox/get" => Ok(MethodResponse::MailboxGet(
81 | seq.next_element::>()?
82 | .ok_or(length_err)?,
83 | )),
84 | "Mailbox/set" => Ok(MethodResponse::MailboxSet(
85 | seq.next_element::>()?
86 | .ok_or(length_err)?,
87 | )),
88 | "Identity/get" => Ok(MethodResponse::IdentityGet(
89 | seq.next_element::()?
90 | .ok_or(length_err)?,
91 | )),
92 | "EmailSubmission/set" => Ok(MethodResponse::EmailSubmissionSet(
93 | seq.next_element::>()?
94 | .ok_or(length_err)?,
95 | )),
96 | "error" => Ok(MethodResponse::Error(
97 | seq.next_element::()?
98 | .ok_or(length_err)?,
99 | )),
100 | _ => Err(Error::unknown_field(
101 | name.as_str(),
102 | &[
103 | "Email/get",
104 | "Email/query",
105 | "Email/changes",
106 | "Email/set",
107 | "Email/import",
108 | "Mailbox/get",
109 | "Mailbox/set",
110 | "Identity/get",
111 | "EmailSubmission/set",
112 | "error",
113 | ],
114 | )),
115 | }?;
116 |
117 | let id: String = seq.next_element()?.ok_or(Error::invalid_length(2, &"3"))?;
118 |
119 | Ok(ResponseInvocation { call, id })
120 | }
121 | }
122 | deserializer.deserialize_seq(MethodResponseVisitor)
123 | }
124 | }
125 |
126 | #[derive(Debug, Deserialize)]
127 | #[serde(rename_all = "camelCase")]
128 | pub struct MethodResponseGet {
129 | /// The id of the account used for the call.
130 | pub account_id: Id,
131 | /// A (preferably short) string representing the state on the server for all the data of this
132 | /// type in the account (not just the objects returned in this call). If the data changes, this
133 | /// string MUST change. If the Foo data is unchanged, servers SHOULD return the same state
134 | /// string on subsequent requests for this data type.
135 | ///
136 | /// When a client receives a response with a different state string to a previous call, it MUST
137 | /// either throw away all currently cached objects for the type or call Foo/changes to get the
138 | /// exact changes.
139 | pub state: State,
140 | /// An array of the Foo objects requested. This is the empty array if no objects were found or
141 | /// if the ids argument passed in was also an empty array. The results MAY be in a different
142 | /// order to the ids in the request arguments. If an identical id is included more than once in
143 | /// the request, the server MUST only include it once in either the list or the notFound
144 | /// argument of the response.
145 | pub list: Vec,
146 | /// This array contains the ids passed to the method for records that do not exist. The array is
147 | /// empty if all requested ids were found or if the ids argument passed in was either null or an
148 | /// empty array.
149 | ///
150 | /// NB: The spec does not specify this value can be `null`, but Fastmail's Cyrus can return
151 | /// `null` here when invoking `Identity/get`. Associated issue:
152 | ///
153 | pub not_found: Vec,
154 | }
155 |
156 | /// This is a `/get` method specific to `Identity/get`. We do not reuse `MethodResponseGet` here
157 | /// because Cyrus has a pair of bugs related to `Identity/get`.
158 | ///
159 | /// - https://github.com/cyrusimap/cyrus-imapd/issues/2671
160 | /// - https://github.com/cyrusimap/cyrus-imapd/issues/4122
161 | #[derive(Debug, Deserialize)]
162 | #[serde(rename_all = "camelCase")]
163 | pub struct MethodResponseGetIdentity {
164 | /// The id of the account used for the call.
165 | pub account_id: Id,
166 | /// An array of the `Identity` objects requested. This is the empty array if no objects were
167 | /// found or if the ids argument passed in was also an empty array. The results MAY be in a
168 | /// different order to the ids in the request arguments. If an identical id is included more
169 | /// than once in the request, the server MUST only include it once in either the list or the
170 | /// notFound argument of the response.
171 | pub list: Vec,
172 | }
173 |
174 | #[derive(Debug, Deserialize)]
175 | #[serde(rename_all = "camelCase")]
176 | pub struct MethodResponseQuery {
177 | /// The id of the account used for the call.
178 | pub account_id: Id,
179 | /// A string encoding the current state of the query on the server. This string MUST change if
180 | /// the results of the query (i.e., the matching ids and their sort order) have changed. The
181 | /// `query_state` string MAY change if something has changed on the server, which means the
182 | /// results may have changed but the server doesn’t know for sure.
183 | ///
184 | /// The `query_state` string only represents the ordered list of ids that match the particular
185 | /// query (including its sort/filter). There is no requirement for it to change if a property on
186 | /// an object matching the query changes but the query results are unaffected (indeed, it is
187 | /// more efficient if the `query_state` string does not change in this case). The queryState
188 | /// string only has meaning when compared to future responses to a query with the same
189 | /// type/sort/filter or when used with /queryChanges to fetch changes.
190 | ///
191 | /// Should a client receive back a response with a different `query_state` string to a previous
192 | /// call, it MUST either throw away the currently cached query and fetch it again (note, this
193 | /// does not require fetching the records again, just the list of ids) or call
194 | /// `Foo/queryChanges` to get the difference.
195 | pub query_state: State,
196 | /// This is true if the server supports calling Foo/queryChanges with these filter/sort
197 | /// parameters. Note, this does not guarantee that the Foo/queryChanges call will succeed, as it
198 | /// may only be possible for a limited time afterwards due to server internal implementation
199 | /// details.
200 | pub can_calculate_changes: bool,
201 | /// The zero-based index of the first result in the ids array within the complete list of query
202 | /// results.
203 | pub position: u64,
204 | /// The list of ids for each Foo in the query results, starting at the index given by the
205 | /// position argument of this response and continuing until it hits the end of the results or
206 | /// reaches the limit number of ids. If position is >= total, this MUST be the empty list.
207 | pub ids: Vec,
208 | /// (only if requested) The total number of Foos in the results (given the filter). This
209 | /// argument MUST be omitted if the `calculate_total` request argument is not true.
210 | pub total: Option,
211 | /// The limit enforced by the server on the maximum number of results to return. This is only
212 | /// returned if the server set a limit or used a different limit than that given in the request.
213 | pub limit: Option,
214 | }
215 |
216 | #[derive(Debug, Deserialize)]
217 | #[serde(rename_all = "camelCase")]
218 | pub struct MethodResponseChanges {
219 | /// The id of the account used for the call.
220 | pub account_id: Id,
221 | /// This is the sinceState argument echoed back; it’s the state from which the server is
222 | /// returning changes.
223 | pub old_state: State,
224 | /// This is the state the client will be in after applying the set of changes to the old state.
225 | pub new_state: State,
226 | /// If true, the client may call Foo/changes again with the newState returned to get further
227 | /// updates. If false, newState is the current server state.
228 | pub has_more_changes: bool,
229 | /// An array of ids for records that have been created since the old state.
230 | pub created: Vec,
231 | /// An array of ids for records that have been updated since the old state.
232 | pub updated: Vec,
233 | /// An array of ids for records that have been destroyed since the old state.
234 | pub destroyed: Vec,
235 | }
236 |
237 | #[derive(Debug, Deserialize)]
238 | #[serde(rename_all = "camelCase")]
239 | pub struct MethodResponseSet {
240 | /// The id of the account used for the call.
241 | pub account_id: Id,
242 | /// The state string that would have been returned by `T/get` before making the requested
243 | /// changes, or `None` if the server doesn’t know what the previous state string was.
244 | pub old_state: Option,
245 | /// The state string that will now be returned by `T/get`.
246 | pub new_state: Option,
247 | /// A map of the creation id to an object containing any properties of the created `T` object
248 | /// that were not sent by the client. This includes all server-set properties (such as the id in
249 | /// most object types) and any properties that were omitted by the client and thus set to a
250 | /// default by the server.
251 | ///
252 | /// This argument is `None` if no `T` objects were successfully created.
253 | pub created: Option>,
254 | /// The keys in this map are the ids of all `T`s that were successfully updated.
255 | ///
256 | /// The value for each id is a `T` object containing any property that changed in a way not
257 | /// explicitly requested by the PatchObject sent to the server, or null if none. This lets the
258 | /// client know of any changes to server-set or computed properties.
259 | ///
260 | /// This argument is `None` if no `T` objects were successfully updated.
261 | pub updated: Option>,
262 | /// A list of `T` ids for records that were successfully destroyed, or `None` if none.
263 | pub destroyed: Option>,
264 | /// A map of the creation id to a `MethodResponseError` object for each record that failed to be
265 | /// created, or `None` if all successful.
266 | pub not_created: Option>,
267 | /// A map of the `T` id to a `MethodResponseError` object for each record that failed to be
268 | /// updated, or `None` if all successful.
269 | pub not_updated: Option>,
270 | /// A map of the `T` id to a `MethodResponseError` object for each record that failed to be
271 | /// destroyed, or `None` if all successful.
272 | pub not_destroyed: Option>,
273 | }
274 |
275 | #[derive(Debug, Deserialize)]
276 | #[serde(rename_all = "camelCase")]
277 | pub struct MethodResponseEmailImport {
278 | /// The id of the account used for the call.
279 | pub account_id: Id,
280 | /// The state string that would have been returned by `Email/get` before making the requested
281 | /// changes, or `None` if the server doesn’t know what the previous state string was.
282 | pub old_state: Option,
283 | /// The state string that will now be returned by `Email/get`.
284 | pub new_state: Option,
285 | /// A map of the creation id to an object containing the "id" property for each successfully
286 | /// imported `Email`, or `None` if none.
287 | pub created: Option>,
288 | /// A map of the creation id to a SetError object for each `Email` that failed to be created, or
289 | /// `None` if all successful.
290 | pub not_created: Option>,
291 | }
292 |
293 | /// Struct for updates in a call to `T/set` which we don't care about.
294 | #[derive(Debug, Deserialize)]
295 | pub struct EmptySetUpdated;
296 |
297 | /// Struct for interpreting created IDs.
298 | #[derive(Debug, Deserialize)]
299 | #[serde(rename_all = "camelCase")]
300 | pub struct GenericObjectWithId {
301 | /// The id of the Mailbox.
302 | pub id: Id,
303 | }
304 |
305 | #[derive(Debug, Deserialize)]
306 | #[serde(rename_all = "camelCase")]
307 | pub struct Email {
308 | pub id: Id,
309 | pub blob_id: Id,
310 | pub keywords: HashMap,
311 | pub mailbox_ids: HashMap,
312 | }
313 |
314 | #[derive(Debug, Deserialize)]
315 | #[serde(rename_all = "camelCase")]
316 | pub struct Mailbox {
317 | /// The id of the Mailbox.
318 | pub id: Id,
319 | /// The Mailbox id for the parent of this `Mailbox`, or `None` if this `Mailbox` is at the top
320 | /// level. Mailboxes form acyclic graphs (forests) directed by the child-to-parent relationship.
321 | /// There MUST NOT be a loop.
322 | pub parent_id: Option,
323 | /// User-visible name for the Mailbox, e.g., “Inbox”. This MUST be a Net-Unicode string
324 | /// \[[RFC5198](https://datatracker.ietf.org/doc/html/rfc5198)\] of at least 1 character in
325 | /// length, subject to the maximum size given in the capability object. There MUST NOT be two
326 | /// sibling Mailboxes with both the same parent and the same name. Servers MAY reject names that
327 | /// violate server policy (e.g., names containing a slash (/) or control characters).
328 | pub name: String,
329 | /// Identifies Mailboxes that have a particular common purpose (e.g., the “inbox”), regardless
330 | /// of the name property (which may be localised).
331 | pub role: Option,
332 | }
333 |
334 | /// See
335 | /// .
336 | #[derive(Clone, Copy, Eq, PartialEq, Hash, Debug, Deserialize)]
337 | #[serde(rename_all = "lowercase")]
338 | pub enum MailboxRole {
339 | /// All messages.
340 | All,
341 | /// Archived messages.
342 | Archive,
343 | /// Messages that are working drafts.
344 | Drafts,
345 | /// Messages with the \Flagged flag.
346 | Flagged,
347 | /// Messages deemed important to user.
348 | Important,
349 | /// Messages New mail is delivered here by default.
350 | Inbox,
351 | /// Messages identified as Spam/Junk.
352 | Junk,
353 | /// Sent mail.
354 | Sent,
355 | /// Messages the user has discarded.
356 | Trash,
357 | /// As-of-yet defined roles, or roles we don't care about.
358 | #[serde(other)]
359 | Unknown,
360 | }
361 |
362 | #[derive(Debug, Deserialize)]
363 | #[serde(rename_all = "camelCase")]
364 | pub struct Identity {
365 | /// The id of the `Identity`.
366 | pub id: Id,
367 | /// The "From" email address the client MUST use when creating a new Email from this `Identity`.
368 | /// If the "mailbox" part of the address (the section before the "@") is the single character
369 | /// "*" (e.g., "*@example.com"), the client may use any valid address ending in that domain
370 | /// (e.g., "foo@example.com").
371 | pub email: String,
372 | }
373 |
374 | #[derive(Debug)]
375 | pub enum MethodResponse {
376 | EmailGet(MethodResponseGet),
377 | EmailQuery(MethodResponseQuery),
378 | EmailChanges(MethodResponseChanges),
379 | EmailSet(MethodResponseSet),
380 | EmailImport(MethodResponseEmailImport),
381 |
382 | MailboxGet(MethodResponseGet),
383 | MailboxSet(MethodResponseSet),
384 |
385 | IdentityGet(MethodResponseGetIdentity),
386 |
387 | EmailSubmissionSet(MethodResponseSet),
388 |
389 | Error(MethodResponseError),
390 | }
391 |
392 | /// If a method encounters an error, the appropriate error response MUST be inserted at the current
393 | /// point in the methodResponses array and, unless otherwise specified, further processing MUST NOT
394 | /// happen within that method call.
395 | ///
396 | /// Any further method calls in the request MUST then be processed as normal. Errors at the method
397 | /// level MUST NOT generate an HTTP-level error.
398 | #[derive(Debug, Deserialize)]
399 | #[serde(tag = "type", rename_all = "camelCase")]
400 | pub enum MethodResponseError {
401 | /// The accountId does not correspond to a valid account.
402 | AccountNotFound,
403 | /// The accountId given corresponds to a valid account, but the account does not support this
404 | /// method or data type.
405 | AccountNotSupportedByMethod,
406 | /// This method modifies state, but the account is read-only (as returned on the corresponding
407 | /// Account in the Session object).
408 | AccountReadOnly,
409 | /// An anchor argument was supplied, but it cannot be found in the results of the query.
410 | AnchorNotFound,
411 | /// The server forbids duplicates, and the record already exists in the target account. An
412 | /// existingId property of type Id MUST be included on the `MethodResponseError` object with the
413 | /// id of the existing record.
414 | #[serde(rename_all = "camelCase")]
415 | AlreadyExists { existing_id: Id },
416 | /// The server cannot calculate the changes from the state string given by the client.
417 | CannotCalculateChanges,
418 | /// The action would violate an ACL or other permissions policy.
419 | Forbidden,
420 | /// The fromAccountId does not correspond to a valid account.
421 | FromAccountNotFound,
422 | /// The fromAccountId given corresponds to a valid account, but the account does not support
423 | /// this data type.
424 | FromAccountNotSupportedByMethod,
425 | /// One of the arguments is of the wrong type or otherwise invalid, or a required argument is
426 | /// missing.
427 | InvalidArguments { description: Option },
428 | /// The PatchObject given to update the record was not a valid patch.
429 | InvalidPatch,
430 | /// The record given is invalid.
431 | InvalidProperties { properties: Option> },
432 | /// The id given cannot be found.
433 | NotFound,
434 | /// The content type of the request was not application/json or the request did not parse as
435 | /// I-JSON.
436 | NotJSON,
437 | /// The request parsed as JSON but did not match the type signature of the Request object.
438 | NotRequest,
439 | /// The create would exceed a server-defined limit on the number or total size of objects of
440 | /// this type.
441 | OverQuota,
442 | /// Too many objects of this type have been created recently, and a server-defined rate limit
443 | /// has been reached. It may work if tried again later.
444 | RateLimit,
445 | /// The total number of actions exceeds the maximum number the server is willing to process in a
446 | /// single method call.
447 | RequestTooLarge,
448 | /// The method used a result reference for one of its arguments, but this failed to resolve.
449 | InvalidResultReference,
450 | /// An unexpected or unknown error occurred during the processing of the call. The method call
451 | /// made no changes to the server’s state.
452 | ServerFail { description: Option },
453 | /// Some, but not all, expected changes described by the method occurred. The client MUST
454 | /// re-synchronise impacted data to determine server state. Use of this error is strongly
455 | /// discouraged.
456 | ServerPartialFail,
457 | /// Some internal server resource was temporarily unavailable. Attempting the same operation
458 | /// later (perhaps after a backoff with a random factor) may succeed.
459 | ServerUnavailable,
460 | /// This is a singleton type, so you cannot create another one or destroy the existing one.
461 | Singleton,
462 | /// An ifInState argument was supplied, and it does not match the current state.
463 | StateMismatch,
464 | /// The action would result in an object that exceeds a server-defined limit for the maximum
465 | /// size of a single object of this type.
466 | #[serde(rename_all = "camelCase")]
467 | TooLarge { max_size: u64 },
468 | /// There are more changes than the client’s maxChanges argument.
469 | TooManyChanges,
470 | /// The client included a capability in the “using” property of the request that the server does
471 | /// not support.
472 | UnknownCapability,
473 | /// The server does not recognise this method name.
474 | UnknownMethod,
475 | /// The filter is syntactically valid, but the server cannot process it.
476 | UnsupportedFilter,
477 | /// The sort is syntactically valid, but includes a property the server does not support sorting
478 | /// on, or a collation method it does not recognise.
479 | UnsupportedSort,
480 | /// The client requested an object be both updated and destroyed in the same /set request, and
481 | /// the server has decided to therefore ignore the update.
482 | WillDestroy,
483 | /// The Mailbox still has at least one child Mailbox. The client MUST remove these before it can
484 | /// delete the parent Mailbox.
485 | MailboxHasChild,
486 | /// The Mailbox has at least one message assigned to it and the onDestroyRemoveEmails argument
487 | /// was false.
488 | MailboxHasEmail,
489 | /// At least one blob id referenced in the object doesn’t exist.
490 | #[serde(rename_all = "camelCase")]
491 | BlobNotFound { not_found: Vec },
492 | /// The change to the Email’s keywords would exceed a server-defined maximum.
493 | TooManyKeywords,
494 | /// The change to the set of Mailboxes that this Email is in would exceed a server-defined
495 | /// maximum.
496 | TooManyMailboxes,
497 | /// The Email to be sent is invalid in some way.
498 | InvalidEmail { properties: Option> },
499 | /// The envelope \[[RFC5321](https://datatracker.ietf.org/doc/html/rfc5321)\] (supplied or
500 | /// generated) has more recipients than the server allows.
501 | #[serde(rename_all = "camelCase")]
502 | TooManyRecipients { max_recipients: u64 },
503 | /// The envelope \[[RFC5321](https://datatracker.ietf.org/doc/html/rfc5321)\] (supplied or
504 | /// generated) does not have any rcptTo email addresses.
505 | NoRecipients,
506 | /// The rcptTo property of the envelope
507 | /// \[[RFC5321](https://datatracker.ietf.org/doc/html/rfc5321)\] (supplied or generated)
508 | /// contains at least one rcptTo value that is not a valid email address for sending to.
509 | #[serde(rename_all = "camelCase")]
510 | InvalidRecipients { invalid_recipients: Vec },
511 | /// The server does not permit the user to send a message with this envelope From address
512 | /// \[[RFC5321](https://datatracker.ietf.org/doc/html/rfc5321)\].
513 | ForbiddenMailFrom,
514 | /// The server does not permit the user to send a message with the From header field
515 | /// \[[RFC5321](https://datatracker.ietf.org/doc/html/rfc5321)\] of the message to be sent.
516 | ForbiddenFrom,
517 | /// The user does not have permission to send at all right now.
518 | ForbiddenToSend { description: Option },
519 | }
520 |
521 | impl Display for MethodResponseError {
522 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
523 | write!(f, "{:?}", self)
524 | }
525 | }
526 |
527 | impl std::error::Error for MethodResponseError {
528 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
529 | None
530 | }
531 | }
532 |
533 | /// Response from a blob upload.
534 | #[derive(Debug, Deserialize)]
535 | #[serde(rename_all = "camelCase")]
536 | pub struct BlobUploadResponse {
537 | /// The id of the account used for the call.
538 | pub account_id: Id,
539 | /// The id representing the binary data uploaded. The data for this id is immutable. The id
540 | /// *only* refers to the binary data, not any metadata.
541 | pub blob_id: Id,
542 | // The media type of the file (as specified in
543 | // \[[RFC6838](https://datatracker.ietf.org/doc/html/rfc6838)\], Section 4.2) as set in the
544 | // Content-Type header of the upload HTTP request.
545 | #[serde(rename = "type")]
546 | pub content_type: String,
547 | /// The size of the file in octets.
548 | pub size: u64,
549 | }
550 |
--------------------------------------------------------------------------------
/src/jmap/session.rs:
--------------------------------------------------------------------------------
1 | use serde::Deserialize;
2 | use std::collections::HashMap;
3 |
4 | use super::{Id, State};
5 |
6 | #[derive(Debug, Deserialize)]
7 | #[serde(rename_all = "camelCase")]
8 | pub struct Session {
9 | /// An object specifying the capabilities of this server.
10 | pub capabilities: Capabilities,
11 | /// A map of an account id to an `Account` object for each account the user has access to.
12 | pub accounts: HashMap,
13 | /// A map of capabilities to the account id that is considered to be the user’s main or default
14 | /// account for data pertaining to that capability.
15 | pub primary_accounts: PrimaryAccounts,
16 | /// The username associated with the given credentials, or the empty string if none.
17 | pub username: String,
18 | /// The URL to use for JMAP API requests.
19 | pub api_url: String,
20 | /// The URL endpoint to use when downloading files, in URI Template (level 1) format
21 | /// \[[RFC6570](https://datatracker.ietf.org/doc/html/rfc6570)\]. The URL MUST contain variables
22 | /// called accountId, blobId, type, and name.
23 | pub download_url: String,
24 | /// The URL endpoint to use when uploading files, in URI Template (level 1) format
25 | /// \[[RFC6570](https://datatracker.ietf.org/doc/html/rfc6570)\]. The URL MUST contain a
26 | /// variable called accountId.
27 | pub upload_url: String,
28 | /// The URL to connect to for push events in URI Template (level 1) format
29 | /// \[[RFC6570](https://datatracker.ietf.org/doc/html/rfc6570)\]. The URL MUST contain variables
30 | /// called types, closeafter, and ping.
31 | pub event_source_url: String,
32 | /// A string representing the state of this object on the server. If the value of any other
33 | /// property on the Session object changes, this string will change. The current value is also
34 | /// returned on the API Response object, allowing clients to quickly determine if the session
35 | /// information has changed (e.g., an account has been added or removed), so they need to
36 | /// refetch the object.
37 | pub state: State,
38 | }
39 |
40 | #[derive(Debug, Deserialize)]
41 | pub struct PrimaryAccounts {
42 | #[serde(rename = "urn:ietf:params:jmap:core")]
43 | pub core: Id,
44 | #[serde(rename = "urn:ietf:params:jmap:mail")]
45 | pub mail: Id,
46 | }
47 |
48 | #[derive(Debug, Deserialize)]
49 | pub struct Capabilities {
50 | #[serde(rename = "urn:ietf:params:jmap:core")]
51 | pub core: CoreCapabilities,
52 | #[serde(rename = "urn:ietf:params:jmap:mail")]
53 | pub mail: EmptyCapabilities,
54 | }
55 |
56 | #[derive(Debug, Deserialize)]
57 | #[serde(rename_all = "camelCase")]
58 | pub struct CoreCapabilities {
59 | /// The maximum file size, in octets, that the server will accept for a single file upload (for
60 | /// any purpose).
61 | pub max_size_upload: u64,
62 | /// The maximum number of concurrent requests the server will accept to the upload endpoint.
63 | pub max_concurrent_upload: u64,
64 | /// The maximum size, in octets, that the server will accept for a single request to the API
65 | /// endpoint.
66 | pub max_size_request: u64,
67 | /// The maximum number of concurrent requests the server will accept to the API endpoint.
68 | pub max_concurrent_requests: u64,
69 | /// The maximum number of method calls the server will accept in a single request to the API
70 | /// endpoint.
71 | pub max_calls_in_request: u64,
72 | /// The maximum number of objects that the client may request in a single /get type method call.
73 | pub max_objects_in_get: u64,
74 | /// The maximum number of objects the client may send to create, update, or destroy in a single
75 | /// /set type method call. This is the combined total, e.g., if the maximum is 10, you could not
76 | /// create 7 objects and destroy 6, as this would be 13 actions, which exceeds the limit.
77 | pub max_objects_in_set: u64,
78 | /// A list of identifiers for algorithms registered in the collation registry, as defined in
79 | /// \[[RFC4790](https://datatracker.ietf.org/doc/html/rfc4790)\], that the server supports for
80 | /// sorting when querying records.
81 | pub collation_algorithms: Vec,
82 | }
83 |
84 | #[derive(Debug, Deserialize)]
85 | pub struct EmptyCapabilities {}
86 |
87 | #[derive(Debug, Deserialize)]
88 | #[serde(rename_all = "camelCase")]
89 | pub struct Account {
90 | /// A user-friendly string to show when presenting content from this account, e.g., the email
91 | /// address representing the owner of the account.
92 | pub name: String,
93 | /// This is `true` if the account belongs to the authenticated user rather than a group account
94 | /// or a personal account of another user that has been shared with them.
95 | pub is_personal: bool,
96 | /// This is `true` if the entire account is read-only.
97 | pub is_read_only: bool,
98 | /// The set of capabilities for the methods supported in this account. Each key is capability
99 | /// that has methods you can use with this account. The value for each of these keys is an
100 | /// object with further information about the account’s permissions and restrictions with
101 | /// respect to this capability, as defined in the capability’s specification.
102 | pub account_capabilities: AccountCapabilities,
103 | }
104 |
105 | #[derive(Debug, Deserialize)]
106 | pub struct AccountCapabilities {
107 | #[serde(rename = "urn:ietf:params:jmap:core")]
108 | pub core: EmptyCapabilities,
109 | #[serde(rename = "urn:ietf:params:jmap:mail")]
110 | pub mail: MailAccountCapabilities,
111 | }
112 |
113 | #[derive(Debug, Deserialize)]
114 | #[serde(rename_all = "camelCase")]
115 | pub struct MailAccountCapabilities {
116 | /// The maximum number of Mailboxes that can be can assigned to a single Email object. This MUST
117 | /// be an integer >= 1, or `None` for no limit (or rather, the limit is always the number of
118 | /// Mailboxes in the account).
119 | pub max_mailboxes_per_email: Option,
120 | /// The maximum depth of the Mailbox hierarchy (i.e., one more than the maximum number of
121 | /// ancestors a Mailbox may have), or `None` for no limit.
122 | pub max_mailbox_depth: Option,
123 | /// The maximum length, in (UTF-8) octets, allowed for the name of a Mailbox. This MUST be at
124 | /// least 100, although it is recommended servers allow more.
125 | pub max_size_mailbox_name: u64,
126 | /// The maximum total size of attachments, in octets, allowed for a single Email object. A
127 | /// server MAY still reject the import or creation of an Email with a lower attachment size
128 | /// total (for example, if the body includes several megabytes of text, causing the size of the
129 | /// encoded MIME structure to be over some server-defined limit).
130 | ///
131 | /// Note that this limit is for the sum of unencoded attachment sizes. Users are generally not
132 | /// knowledgeable about encoding overhead, etc., nor should they need to be, so marketing and
133 | /// help materials normally tell them the “max size attachments”. This is the unencoded size
134 | /// they see on their hard drive, so this capability matches that and allows the client to
135 | /// consistently enforce what the user understands as the limit.
136 | ///
137 | /// The server may separately have a limit for the total size of the message
138 | /// \[[RFC5322](https://datatracker.ietf.org/doc/html/rfc5322)\], created by combining the
139 | /// attachments (often base64 encoded) with the message headers and bodies. For example, suppose
140 | /// the server advertises `max_size_attachments_per_email`: 50000000 (50 MB). The enforced
141 | /// server limit may be for a message size of 70000000 octets. Even with base64 encoding and a 2
142 | /// MB HTML body, 50 MB attachments would fit under this limit.
143 | pub max_size_attachments_per_email: u64,
144 | /// A list of all the values the server supports for the “property” field of the Comparator
145 | /// object in an Email/query sort. This MAY include properties the client does not recognise
146 | /// (for example, custom properties specified in a vendor extension). Clients MUST ignore any
147 | /// unknown properties in the list.
148 | pub email_query_sort_options: Vec,
149 | /// If true, the user may create a Mailbox in this account with a `None` parentId. (Permission
150 | /// for creating a child of an existing Mailbox is given by the myRights property on that
151 | /// Mailbox.)
152 | pub may_create_top_level_mailbox: bool,
153 | }
154 |
--------------------------------------------------------------------------------
/src/local.rs:
--------------------------------------------------------------------------------
1 | use crate::jmap;
2 | use crate::sync::NewEmail;
3 | use const_format::formatcp;
4 | use lazy_static::lazy_static;
5 | use log::debug;
6 | use notmuch::ConfigKey;
7 | use notmuch::Database;
8 | use notmuch::Exclude;
9 | use notmuch::Message;
10 | use regex::Regex;
11 | use snafu::prelude::*;
12 | use snafu::Snafu;
13 | use std::collections::HashMap;
14 | use std::collections::HashSet;
15 | use std::fs;
16 | use std::io;
17 | use std::path::Path;
18 | use std::path::PathBuf;
19 | use std::path::StripPrefixError;
20 |
21 | const ID_PATTERN: &'static str = r"[-A-Za-z0-9_]+";
22 | const MAIL_PATTERN: &'static str = formatcp!(r"^({})\.({})(?:$|:)", ID_PATTERN, ID_PATTERN);
23 |
24 | lazy_static! {
25 | /// mujmap *must not* touch automatic tags, and should warn if the JMAP server contains
26 | /// mailboxes that match these tags.
27 | ///
28 | /// These values taken from: https://notmuchmail.org/special-tags/
29 | pub static ref AUTOMATIC_TAGS: HashSet<&'static str> =
30 | HashSet::from(["attachment", "signed", "encrypted"]);
31 | }
32 |
33 | #[derive(Debug, Snafu)]
34 | pub enum Error {
35 | #[snafu(display("Could not canonicalize given path: {}", source))]
36 | Canonicalize { source: io::Error },
37 |
38 | #[snafu(display(
39 | "Given maildir path `{}' is not a subdirectory of the notmuch root `{}'",
40 | mail_dir.to_string_lossy(),
41 | notmuch_root.to_string_lossy(),
42 | ))]
43 | MailDirNotASubdirOfNotmuchRoot {
44 | mail_dir: PathBuf,
45 | notmuch_root: PathBuf,
46 | source: StripPrefixError,
47 | },
48 |
49 | #[snafu(display("Could not open notmuch database: {}", source))]
50 | OpenDatabase { source: notmuch::Error },
51 |
52 | #[snafu(display("Could not create Maildir dir `{}': {}", path.to_string_lossy(), source))]
53 | CreateMaildirDir { path: PathBuf, source: io::Error },
54 |
55 | #[snafu(display("Could not create notmuch query `{}': {}", query, source))]
56 | CreateNotmuchQuery {
57 | query: String,
58 | source: notmuch::Error,
59 | },
60 |
61 | #[snafu(display("Could not execute notmuch query `{}': {}", query, source))]
62 | ExecuteNotmuchQuery {
63 | query: String,
64 | source: notmuch::Error,
65 | },
66 | }
67 |
68 | pub type Result = std::result::Result;
69 |
70 | #[derive(Debug)]
71 | pub struct Email {
72 | pub id: jmap::Id,
73 | pub blob_id: jmap::Id,
74 | pub message_id: String,
75 | pub path: PathBuf,
76 | pub tags: HashSet,
77 | }
78 |
79 | pub struct Local {
80 | /// Notmuch database.
81 | db: Database,
82 | /// The path to mujmap's maildir/cur.
83 | pub mail_cur_dir: PathBuf,
84 | /// Notmuch search query which searches for all mail in mujmap's maildir.
85 | all_mail_query: String,
86 | /// Flag, whether or not notmuch should add maildir flags to message filenames.
87 | pub synchronize_maildir_flags: bool,
88 | }
89 |
90 | impl Local {
91 | /// Open the local store.
92 | ///
93 | /// `mail_dir` *must* be a subdirectory of the notmuch path.
94 | pub fn open(mail_dir: impl AsRef, read_only: bool) -> Result {
95 | // Open the notmuch database with default config options.
96 | let db = Database::open_with_config::(
97 | None,
98 | if read_only {
99 | notmuch::DatabaseMode::ReadOnly
100 | } else {
101 | notmuch::DatabaseMode::ReadWrite
102 | },
103 | None,
104 | None,
105 | )
106 | .context(OpenDatabaseSnafu {})?;
107 |
108 | // Get the relative directory of the maildir to the database path.
109 | let canonical_db_path = db.path().canonicalize().context(CanonicalizeSnafu {})?;
110 | let canonical_mail_dir_path = mail_dir
111 | .as_ref()
112 | .canonicalize()
113 | .context(CanonicalizeSnafu {})?;
114 | let relative_mail_dir = canonical_mail_dir_path
115 | .strip_prefix(&canonical_db_path)
116 | .context(MailDirNotASubdirOfNotmuchRootSnafu {
117 | mail_dir: &canonical_mail_dir_path,
118 | notmuch_root: &canonical_db_path,
119 | })?;
120 |
121 | // Build the query to search for all mail in our maildir.
122 | let all_mail_query = format!("path:\"{}/**\"", relative_mail_dir.to_str().unwrap());
123 |
124 | // Ensure the maildir contains the standard cur, new, and tmp dirs.
125 | let mail_cur_dir = canonical_mail_dir_path.join("cur");
126 | if !read_only {
127 | for path in &[
128 | &mail_cur_dir,
129 | &canonical_mail_dir_path.join("new"),
130 | &canonical_mail_dir_path.join("tmp"),
131 | ] {
132 | fs::create_dir_all(path).context(CreateMaildirDirSnafu { path })?;
133 | }
134 | }
135 |
136 | let synchronize_maildir_flags = db.config_bool(ConfigKey::MaildirFlags).unwrap_or(true);
137 |
138 | Ok(Self {
139 | db,
140 | mail_cur_dir,
141 | all_mail_query,
142 | synchronize_maildir_flags,
143 | })
144 | }
145 |
146 | pub fn revision(&self) -> u64 {
147 | self.db.revision().revision
148 | }
149 |
150 | /// Create a path for a newly added file to the maildir.
151 | pub fn new_maildir_path(&self, id: &jmap::Id, blob_id: &jmap::Id) -> PathBuf {
152 | self.mail_cur_dir.join(format!("{}.{}", id, blob_id))
153 | }
154 |
155 | /// Return all `Email`s that mujmap owns for this maildir.
156 | pub fn all_emails(&self) -> Result> {
157 | self.query(&self.all_mail_query)
158 | }
159 |
160 | /// Return all `Email`s that mujmap owns which were modified since the given database revision.
161 | pub fn all_emails_since(&self, last_revision: u64) -> Result> {
162 | self.query(&format!(
163 | "{} and lastmod:{}..{}",
164 | self.all_mail_query,
165 | last_revision,
166 | self.revision()
167 | ))
168 | }
169 |
170 | /// Return all tags in the database.
171 | pub fn all_tags(&self) -> Result {
172 | self.db.all_tags()
173 | }
174 |
175 | /// Begin atomic database operation.
176 | pub fn begin_atomic(&self) -> Result<(), notmuch::Error> {
177 | self.db.begin_atomic()
178 | }
179 |
180 | /// End atomic database operation.
181 | pub fn end_atomic(&self) -> Result<(), notmuch::Error> {
182 | self.db.end_atomic()
183 | }
184 |
185 | /// Add the given email into the database.
186 | pub fn add_new_email(&self, new_email: &NewEmail) -> Result {
187 | debug!("Adding new email: {:?}", new_email);
188 | let message = self.db.index_file(&new_email.maildir_path, None)?;
189 | let tags = message
190 | .tags()
191 | .into_iter()
192 | .filter(|tag| !AUTOMATIC_TAGS.contains(tag.as_str()))
193 | .collect();
194 | Ok(Email {
195 | id: new_email.remote_email.id.clone(),
196 | blob_id: new_email.remote_email.blob_id.clone(),
197 | message_id: message.id().to_string(),
198 | path: new_email.maildir_path.clone(),
199 | tags,
200 | })
201 | }
202 |
203 | /// Remove the given email file from notmuch's database and the disk.
204 | pub fn remove_email(&self, email: &Email) -> Result<(), notmuch::Error> {
205 | debug!("Removing email: {:?}", email);
206 | self.db.remove_message(&email.path)
207 | }
208 |
209 | fn query(&self, query_string: &str) -> Result> {
210 | debug!("notmuch query: {}", query_string);
211 |
212 | let query =
213 | self.db
214 | .create_query(query_string)
215 | .with_context(|_| CreateNotmuchQuerySnafu {
216 | query: query_string.clone(),
217 | })?;
218 | query.set_omit_excluded(Exclude::False);
219 | let messages = query
220 | .search_messages()
221 | .with_context(|_| ExecuteNotmuchQuerySnafu {
222 | query: query_string.clone(),
223 | })?;
224 | Ok(messages
225 | .into_iter()
226 | .flat_map(|x| self.emails_from_message(x))
227 | .map(|x| (x.id.clone(), x))
228 | .collect())
229 | }
230 |
231 | /// Get a notmuch Message object for the wanted id.
232 | pub fn get_message(&self, id: &str) -> Result