├── .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, notmuch::Error> { 233 | let query_string = format!("id:{}", id); 234 | let query = self.db.create_query(query_string.as_str())?; 235 | query.set_omit_excluded(Exclude::False); 236 | let messages = query.search_messages()?; 237 | Ok(messages.into_iter().next()) 238 | } 239 | 240 | /// Returns a separate `Email` object for each duplicate email file mujmap owns. 241 | fn emails_from_message(&self, message: Message) -> Vec { 242 | lazy_static! { 243 | static ref MAIL_FILE: Regex = Regex::new(MAIL_PATTERN).unwrap(); 244 | } 245 | message 246 | .filenames() 247 | .into_iter() 248 | .filter(|x| x.starts_with(&self.mail_cur_dir)) 249 | .flat_map(|path| { 250 | MAIL_FILE 251 | .captures(&path.file_name().unwrap().to_string_lossy()) 252 | .map(|x| { 253 | let id = jmap::Id(x.get(1).unwrap().as_str().to_string()); 254 | let blob_id = jmap::Id(x.get(2).unwrap().as_str().to_string()); 255 | (id, blob_id) 256 | }) 257 | .map(|(id, blob_id)| (id, blob_id, path)) 258 | }) 259 | .map(|(id, blob_id, path)| Email { 260 | id, 261 | blob_id, 262 | message_id: message.id().to_string(), 263 | path, 264 | tags: message 265 | .tags() 266 | .into_iter() 267 | .filter(|tag| !AUTOMATIC_TAGS.contains(tag.as_str())) 268 | .collect(), 269 | }) 270 | .collect() 271 | } 272 | 273 | pub fn update_email_tags( 274 | &self, 275 | email: &Email, 276 | tags: HashSet<&str>, 277 | ) -> Result<(), notmuch::Error> { 278 | if let Some(message) = self.get_message(&email.message_id)? { 279 | // Build diffs for tags and apply them. 280 | message.freeze()?; 281 | let extant_tags: HashSet = message.tags().into_iter().collect(); 282 | let tags_to_remove: Vec<&str> = extant_tags 283 | .iter() 284 | .map(|tag| tag.as_str()) 285 | .filter(|tag| !tags.contains(tag) && !AUTOMATIC_TAGS.contains(tag)) 286 | .collect(); 287 | let tags_to_add: Vec<&str> = tags 288 | .iter() 289 | .cloned() 290 | .filter(|&tag| !extant_tags.contains(tag)) 291 | .collect(); 292 | debug!( 293 | "Updating local email: {email:?}, by adding tags: {tags_to_add:?}, removing tags: {tags_to_remove:?}" 294 | ); 295 | for tag in tags_to_remove { 296 | message.remove_tag(tag)?; 297 | } 298 | for tag in tags_to_add { 299 | message.add_tag(tag)?; 300 | } 301 | message.thaw()?; 302 | if self.synchronize_maildir_flags { 303 | message.tags_to_maildir_flags()?; 304 | } 305 | } 306 | Ok(()) 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![doc = include_str!("../README.md")] 2 | 3 | /// Command line arguments. 4 | mod args; 5 | /// Local cache interface. 6 | mod cache; 7 | /// Configuration file options. 8 | mod config; 9 | /// Miniature JMAP API. 10 | mod jmap; 11 | /// Local notmuch database interface. 12 | mod local; 13 | /// Remote JMAP interface. 14 | mod remote; 15 | /// Send command. 16 | mod send; 17 | /// Sync command. 18 | mod sync; 19 | 20 | use args::Args; 21 | use atty::Stream; 22 | use clap::Parser; 23 | use config::Config; 24 | use log::debug; 25 | use send::send; 26 | use snafu::prelude::*; 27 | use std::path::PathBuf; 28 | use std::{env, io::Write}; 29 | use sync::sync; 30 | use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor}; 31 | 32 | #[derive(Debug, Snafu)] 33 | pub enum Error { 34 | #[snafu(display("Could not open config file: {}", source))] 35 | OpenConfigFile { source: config::Error }, 36 | 37 | #[snafu(display("Could not sync mail: {}", source))] 38 | Sync { source: sync::Error }, 39 | 40 | #[snafu(display("Could not send mail: {}", source))] 41 | Send { source: send::Error }, 42 | } 43 | 44 | pub type Result = std::result::Result; 45 | 46 | fn try_main(stdout: &mut StandardStream) -> Result<(), Error> { 47 | // HACK: Remove -oi from the command-line arguments. If someone is weird enough to have named 48 | // their maildir "-oi", or something like that, this would cause mujmap to fail unnecessarily. 49 | // However, clap does not yet support "long" arguments with more than one character, so this is 50 | // our best option. See: https://github.com/clap-rs/clap/issues/1210 51 | let args = Args::parse_from(env::args().into_iter().filter(|a| a != "-oi")); 52 | 53 | env_logger::Builder::new() 54 | .filter_level(args.verbose.log_level_filter()) 55 | .parse_default_env() 56 | .init(); 57 | 58 | let info_color_spec = ColorSpec::new() 59 | .set_fg(Some(Color::Green)) 60 | .set_bold(true) 61 | .to_owned(); 62 | 63 | // Determine working directory and load all data files. 64 | let mail_dir = args.path.clone().unwrap_or_else(|| PathBuf::from(".")); 65 | 66 | let config = Config::from_file(mail_dir.join("mujmap.toml")).context(OpenConfigFileSnafu {})?; 67 | debug!("Using config: {:?}", config); 68 | 69 | match args.command { 70 | args::Command::Push => sync( 71 | stdout, 72 | info_color_spec, 73 | mail_dir, 74 | args, 75 | config, 76 | /*pull=*/ false, 77 | ) 78 | .context(SyncSnafu {}), 79 | args::Command::Sync => sync( 80 | stdout, 81 | info_color_spec, 82 | mail_dir, 83 | args, 84 | config, 85 | /*pull=*/ true, 86 | ) 87 | .context(SyncSnafu {}), 88 | args::Command::Send { 89 | read_recipients, 90 | recipients, 91 | .. 92 | } => send(read_recipients, recipients, config).context(SendSnafu {}), 93 | } 94 | } 95 | 96 | fn main() { 97 | let mut stdout = StandardStream::stdout(if atty::is(Stream::Stdout) { 98 | ColorChoice::Auto 99 | } else { 100 | ColorChoice::Never 101 | }); 102 | let mut stderr = StandardStream::stderr(if atty::is(Stream::Stderr) { 103 | ColorChoice::Auto 104 | } else { 105 | ColorChoice::Never 106 | }); 107 | 108 | std::process::exit(match try_main(&mut stdout) { 109 | Ok(_) => 0, 110 | Err(err) => { 111 | stderr 112 | .set_color(ColorSpec::new().set_fg(Some(Color::Red))) 113 | .ok(); 114 | writeln!(&mut stderr, "error: {err}").ok(); 115 | 1 116 | } 117 | }); 118 | } 119 | -------------------------------------------------------------------------------- /src/send.rs: -------------------------------------------------------------------------------- 1 | use either::Either; 2 | use fqdn::FQDN; 3 | use log::{debug, warn}; 4 | use snafu::prelude::*; 5 | use std::{ 6 | collections::HashSet, 7 | io::{Cursor, Read}, 8 | iter, 9 | str::FromStr, 10 | string::FromUtf8Error, 11 | }; 12 | 13 | use crate::{ 14 | config::Config, 15 | jmap, 16 | remote::{self, Remote}, 17 | }; 18 | 19 | #[derive(Debug, Snafu)] 20 | pub enum Error { 21 | #[snafu(display("Could not read mail from stdin: {}", source))] 22 | ReadStdin { source: loe::ParseError }, 23 | 24 | #[snafu(display("Could not read mail from CRLF stdin buffer: {}", source))] 25 | ReadCrlfStdin { source: FromUtf8Error }, 26 | 27 | #[snafu(display("Could not parse mail: {}", source))] 28 | ParseEmail { source: email_parser::error::Error }, 29 | 30 | #[snafu(display("Could not parse sender domain: {}", source))] 31 | ParseSenderDomain { domain: String, source: fqdn::Error }, 32 | 33 | #[snafu(display("Could not open remote session: {}", source))] 34 | OpenRemote { source: remote::Error }, 35 | 36 | #[snafu(display("Could not enumerate identities: {}", source))] 37 | GetIdentities { source: remote::Error }, 38 | 39 | #[snafu(display("JMAP server has identity with invalid email address `{}'", address))] 40 | InvalidEmailAddress { address: String }, 41 | 42 | #[snafu(display("Could not parse JMAP identity domain `{}': {}", domain, source))] 43 | ParseIdentityDomain { domain: String, source: fqdn::Error }, 44 | 45 | #[snafu(display("No JMAP identities match sender `{}'", sender))] 46 | NoIdentitiesForSender { sender: String }, 47 | 48 | #[snafu(display("Could not index mailboxes: {}", source))] 49 | IndexMailboxes { source: remote::Error }, 50 | 51 | #[snafu(display("No recipients specified. Did you forget to specify `-t'?"))] 52 | NoRecipients {}, 53 | 54 | #[snafu(display("Could not send email: {}", source))] 55 | SendEmail { source: remote::Error }, 56 | } 57 | 58 | pub type Result = std::result::Result; 59 | 60 | pub fn send(read_recipients: bool, recipients: Vec, config: Config) -> Result<()> { 61 | // Read mail from stdin, converting Unix newlines to DOS newlines to coimply with RFC5322. 62 | // Truncate the input so we don't infinitely grow a buffer if someone pipes /dev/urandom into 63 | // mujmap or something similar by mistake. 64 | let mut stdio_crlf = Cursor::new(Vec::new()); 65 | loe::process( 66 | &mut std::io::stdin().take(10_000_000), 67 | &mut stdio_crlf, 68 | loe::Config::default().transform(loe::TransformMode::Crlf), 69 | ) 70 | .context(ReadStdinSnafu {})?; 71 | 72 | let email_string = String::from_utf8(stdio_crlf.into_inner()).context(ReadCrlfStdinSnafu {})?; 73 | let parsed_email = 74 | email_parser::email::Email::parse(email_string.as_bytes()).context(ParseEmailSnafu {})?; 75 | 76 | let mut remote = Remote::open(&config).context(OpenRemoteSnafu {})?; 77 | 78 | let identity_id = 79 | get_identity_id_for_sender_address(&parsed_email.sender.address, &mut remote)?; 80 | let mailboxes = remote 81 | .get_mailboxes(&config.tags) 82 | .context(IndexMailboxesSnafu {})?; 83 | 84 | let from_address = address_to_string(&parsed_email.sender.address); 85 | let addresses_to_iter = |a| { 86 | // Use `as' here as a workaround for lifetime inference. 87 | (a as Option>).map_or_else( 88 | || Either::Left(iter::empty()), 89 | |x| Either::Right(x.into_iter()), 90 | ) 91 | }; 92 | let to_addresses: HashSet = if read_recipients { 93 | if !recipients.is_empty() { 94 | warn!(concat!( 95 | "Both `-t' and recipients were specified in the same command; ", 96 | "ignoring recipient arguments" 97 | )); 98 | } 99 | addresses_to_iter(parsed_email.to) 100 | .chain(addresses_to_iter(parsed_email.cc)) 101 | .chain(addresses_to_iter(parsed_email.bcc)) 102 | .flat_map(|x| match x { 103 | email_parser::address::Address::Mailbox(mailbox) => { 104 | Either::Left(iter::once(address_to_string(&mailbox.address))) 105 | } 106 | email_parser::address::Address::Group((_, mailboxes)) => Either::Right( 107 | mailboxes 108 | .into_iter() 109 | .map(|mailbox| address_to_string(&mailbox.address)), 110 | ), 111 | }) 112 | .collect() 113 | } else { 114 | // TODO: Locally verify that all recipients are valid email addresses. 115 | recipients.into_iter().collect() 116 | }; 117 | 118 | ensure!(!to_addresses.is_empty(), NoRecipientsSnafu {}); 119 | 120 | debug!( 121 | "Envelope sender is `{}', recipients are `{:?}'", 122 | from_address, to_addresses 123 | ); 124 | 125 | // Create the email! 126 | remote 127 | .send_email( 128 | identity_id, 129 | &mailboxes, 130 | &from_address, 131 | &to_addresses, 132 | &email_string, 133 | ) 134 | .context(SendEmailSnafu {})?; 135 | 136 | Ok(()) 137 | } 138 | 139 | fn get_identity_id_for_sender_address( 140 | sender_address: &email_parser::address::EmailAddress, 141 | remote: &mut Remote, 142 | ) -> Result { 143 | let sender_local_part = &sender_address.local_part; 144 | let sender_domain = &sender_address.domain; 145 | let sender_fqdn = FQDN::from_str(sender_domain.as_ref()).context(ParseSenderDomainSnafu { 146 | domain: sender_domain.as_ref(), 147 | })?; 148 | debug!( 149 | "Sender is `{}@{}', fqdn `{}'", 150 | sender_local_part, sender_domain, sender_fqdn 151 | ); 152 | 153 | // Find the identity which matches the sender of this email. 154 | let identities = remote.get_identities().context(GetIdentitiesSnafu {})?; 155 | let sender_identities: Vec<_> = identities 156 | .iter() 157 | .map(|identity| { 158 | let (local_part, domain) = 159 | identity 160 | .email 161 | .split_once('@') 162 | .context(InvalidEmailAddressSnafu { 163 | address: &identity.email, 164 | })?; 165 | let fqdn = FQDN::from_str(domain).context(ParseIdentityDomainSnafu { domain })?; 166 | Ok((identity, local_part, fqdn)) 167 | }) 168 | .collect::>>()? 169 | .into_iter() 170 | .filter(|(_, local_part, fqdn)| { 171 | *fqdn == sender_fqdn && (*local_part == "*" || *local_part == sender_local_part) 172 | }) 173 | .map(|(identity, local_part, _)| (identity, local_part)) 174 | .collect(); 175 | ensure!( 176 | !sender_identities.is_empty(), 177 | NoIdentitiesForSenderSnafu { 178 | sender: address_to_string(&sender_address), 179 | } 180 | ); 181 | // Prefer a concrete identity over a wildcard. 182 | let identity = sender_identities 183 | .iter() 184 | .filter(|(_, local_part)| *local_part != "*") 185 | .map(|(identity, _)| identity) 186 | .next() 187 | .unwrap_or_else(|| { 188 | sender_identities 189 | .first() 190 | .map(|(identity, _)| identity) 191 | .unwrap() 192 | }); 193 | debug!("JMAP identity for sender is `{:?}'", identity); 194 | 195 | // TODO: avoid clone here? 196 | Ok(identity.id.clone()) 197 | } 198 | 199 | fn address_to_string(address: &email_parser::address::EmailAddress) -> String { 200 | format!("{}@{}", address.local_part, address.domain) 201 | } 202 | -------------------------------------------------------------------------------- /src/sync.rs: -------------------------------------------------------------------------------- 1 | use crate::args::Args; 2 | use crate::cache::{self, Cache}; 3 | use crate::remote::{self, Remote}; 4 | use crate::{config::Config, local::Local}; 5 | use crate::{jmap, local}; 6 | use atty::Stream; 7 | use fslock::LockFile; 8 | use indicatif::ProgressBar; 9 | use log::{debug, error, warn}; 10 | use rayon::{prelude::*, ThreadPoolBuildError}; 11 | use serde::{Deserialize, Serialize}; 12 | use snafu::prelude::*; 13 | use std::collections::{HashMap, HashSet}; 14 | use std::fs::{self, File}; 15 | use std::io::Write; 16 | use std::io::{self, BufReader, BufWriter}; 17 | use std::path::{Path, PathBuf}; 18 | use symlink::symlink_file; 19 | use termcolor::{ColorSpec, StandardStream, WriteColor}; 20 | 21 | #[derive(Debug, Snafu)] 22 | pub enum Error { 23 | #[snafu(display("Could not open lock file `{}': {}", path.to_string_lossy(), source))] 24 | OpenLockFile { path: PathBuf, source: io::Error }, 25 | 26 | #[snafu(display("Could not lock: {}", source))] 27 | Lock { source: io::Error }, 28 | 29 | #[snafu(display("Could not log string: {}", source))] 30 | Log { source: io::Error }, 31 | 32 | #[snafu(display("Could not read mujmap state file `{}': {}", filename.to_string_lossy(), source))] 33 | ReadStateFile { 34 | filename: PathBuf, 35 | source: io::Error, 36 | }, 37 | 38 | #[snafu(display("Could not parse mujmap state file `{}': {}", filename.to_string_lossy(), source))] 39 | ParseStateFile { 40 | filename: PathBuf, 41 | source: serde_json::Error, 42 | }, 43 | 44 | #[snafu(display("Could not create mujmap state file `{}': {}", filename.to_string_lossy(), source))] 45 | CreateStateFile { 46 | filename: PathBuf, 47 | source: io::Error, 48 | }, 49 | 50 | #[snafu(display("Could not write to mujmap state file `{}': {}", filename.to_string_lossy(), source))] 51 | WriteStateFile { 52 | filename: PathBuf, 53 | source: serde_json::Error, 54 | }, 55 | 56 | #[snafu(display("Could not open local database: {}", source))] 57 | OpenLocal { source: local::Error }, 58 | 59 | #[snafu(display("Could not open local cache: {}", source))] 60 | OpenCache { source: cache::Error }, 61 | 62 | #[snafu(display("Could not open remote session: {}", source))] 63 | OpenRemote { source: remote::Error }, 64 | 65 | #[snafu(display("Could not index mailboxes: {}", source))] 66 | IndexMailboxes { source: remote::Error }, 67 | 68 | #[snafu(display("JMAP server is missing mailboxes for these tags: {:?}", tags))] 69 | MissingMailboxes { tags: Vec }, 70 | 71 | #[snafu(display("Could not create missing mailboxes for tags `{:?}': {}", tags, source))] 72 | CreateMailboxes { 73 | tags: Vec, 74 | source: remote::Error, 75 | }, 76 | 77 | #[snafu(display("Could not index notmuch tags: {}", source))] 78 | IndexTags { source: notmuch::Error }, 79 | 80 | #[snafu(display("Could not index local emails: {}", source))] 81 | IndexLocalEmails { source: local::Error }, 82 | 83 | #[snafu(display("Could not index all remote email IDs for a full sync: {}", source))] 84 | IndexRemoteEmails { source: remote::Error }, 85 | 86 | #[snafu(display("Could not retrieve email properties from remote: {}", source))] 87 | GetRemoteEmails { source: remote::Error }, 88 | 89 | #[snafu(display("Could not create download thread pool: {}", source))] 90 | CreateDownloadThreadPool { source: ThreadPoolBuildError }, 91 | 92 | #[snafu(display("Could not download email from remote: {}", source))] 93 | DownloadRemoteEmail { source: remote::Error }, 94 | 95 | #[snafu(display("Could not save email to cache: {}", source))] 96 | CacheNewEmail { source: cache::Error }, 97 | 98 | #[snafu(display("Missing last notmuch database revision"))] 99 | MissingNotmuchDatabaseRevision {}, 100 | 101 | #[snafu(display("Could not index local updated emails: {}", source))] 102 | IndexLocalUpdatedEmails { source: local::Error }, 103 | 104 | #[snafu(display("Could not add new local email `{}': {}", filename.to_string_lossy(), source))] 105 | AddLocalEmail { 106 | filename: PathBuf, 107 | source: notmuch::Error, 108 | }, 109 | 110 | #[snafu(display("Could not update local email: {}", source))] 111 | UpdateLocalEmail { source: notmuch::Error }, 112 | 113 | #[snafu(display("Could not remove local email: {}", source))] 114 | RemoveLocalEmail { source: notmuch::Error }, 115 | 116 | #[snafu(display("Could not get local message from notmuch: {}", source))] 117 | GetNotmuchMessage { source: notmuch::Error }, 118 | 119 | #[snafu(display( 120 | "Could not remove unindexed mail file `{}': {}", 121 | path.to_string_lossy(), 122 | source 123 | ))] 124 | RemoveUnindexedMailFile { path: PathBuf, source: io::Error }, 125 | 126 | #[snafu(display( 127 | "Could not make symlink from cache `{}' to maildir `{}': {}", 128 | from.to_string_lossy(), 129 | to.to_string_lossy(), 130 | source 131 | ))] 132 | MakeMaildirSymlink { 133 | from: PathBuf, 134 | to: PathBuf, 135 | source: io::Error, 136 | }, 137 | 138 | #[snafu(display("Could not rename mail file from `{}' to `{}': {}", from.to_string_lossy(), to.to_string_lossy(), source))] 139 | RenameMailFile { 140 | from: PathBuf, 141 | to: PathBuf, 142 | source: io::Error, 143 | }, 144 | 145 | #[snafu(display("Could not remove mail file `{}': {}", path.to_string_lossy(), source))] 146 | RemoveMailFile { path: PathBuf, source: io::Error }, 147 | 148 | #[snafu(display("Could not begin atomic database operation: {}", source))] 149 | BeginAtomic { source: notmuch::Error }, 150 | 151 | #[snafu(display("Could not end atomic database operation: {}", source))] 152 | EndAtomic { source: notmuch::Error }, 153 | 154 | #[snafu(display("Could not push changes to JMAP server: {}", source))] 155 | PushChanges { source: remote::Error }, 156 | 157 | #[snafu(display("Programmer error!"))] 158 | ProgrammerError {}, 159 | } 160 | 161 | pub type Result = std::result::Result; 162 | 163 | /// A new email to be eventually added to the maildir. 164 | #[derive(Debug)] 165 | pub struct NewEmail<'a> { 166 | pub remote_email: &'a remote::Email, 167 | pub cache_path: PathBuf, 168 | pub maildir_path: PathBuf, 169 | } 170 | 171 | #[derive(Serialize, Deserialize)] 172 | pub struct LatestState { 173 | /// Latest revision of the notmuch database since the last time mujmap was run. 174 | pub notmuch_revision: Option, 175 | /// Latest JMAP Email state returned by `Email/get`. 176 | pub jmap_state: Option, 177 | } 178 | 179 | impl LatestState { 180 | fn open(filename: impl AsRef) -> Result { 181 | let filename = filename.as_ref(); 182 | let file = File::open(filename).context(ReadStateFileSnafu { filename })?; 183 | let reader = BufReader::new(file); 184 | serde_json::from_reader(reader).context(ParseStateFileSnafu { filename }) 185 | } 186 | 187 | fn save(&self, filename: impl AsRef) -> Result<()> { 188 | let filename = filename.as_ref(); 189 | let file = File::create(filename).context(CreateStateFileSnafu { filename })?; 190 | let writer = BufWriter::new(file); 191 | serde_json::to_writer(writer, self).context(WriteStateFileSnafu { filename }) 192 | } 193 | 194 | fn empty() -> Self { 195 | Self { 196 | notmuch_revision: None, 197 | jmap_state: None, 198 | } 199 | } 200 | } 201 | 202 | pub fn sync( 203 | stdout: &mut StandardStream, 204 | info_color_spec: ColorSpec, 205 | mail_dir: PathBuf, 206 | args: Args, 207 | config: Config, 208 | pull: bool, 209 | ) -> Result<(), Error> { 210 | // Grab lock. 211 | let lock_file_path = mail_dir.join("mujmap.lock"); 212 | let mut lock = LockFile::open(&lock_file_path).context(OpenLockFileSnafu { 213 | path: lock_file_path, 214 | })?; 215 | let is_locked = lock.try_lock().context(LockSnafu {})?; 216 | if !is_locked { 217 | println!("Lock file owned by another process. Waiting..."); 218 | lock.lock().context(LockSnafu {})?; 219 | } 220 | 221 | // Load the intermediary state. 222 | let latest_state_filename = mail_dir.join("mujmap.state.json"); 223 | let latest_state = LatestState::open(&latest_state_filename).unwrap_or_else(|e| { 224 | warn!("{e}"); 225 | LatestState::empty() 226 | }); 227 | 228 | // Open the local notmuch database. 229 | let local = Local::open(mail_dir, args.dry_run || !pull).context(OpenLocalSnafu {})?; 230 | 231 | // Open the local cache. 232 | let cache = Cache::open(&local.mail_cur_dir, &config).context(OpenCacheSnafu {})?; 233 | 234 | // Open the remote session. 235 | let mut remote = Remote::open(&config).context(OpenRemoteSnafu {})?; 236 | 237 | // List all remote mailboxes and convert them to notmuch tags. 238 | let mut mailboxes = remote 239 | .get_mailboxes(&config.tags) 240 | .context(IndexMailboxesSnafu {})?; 241 | debug!("Got mailboxes: {:?}", mailboxes); 242 | 243 | // Query local database for all email. 244 | let local_emails = local.all_emails().context(IndexLocalEmailsSnafu {})?; 245 | 246 | // Function which performs a full sync, i.e. a sync which considers all remote IDs as updated, 247 | // and determines destroyed IDs by finding the difference of all remote IDs from all local IDs. 248 | let full_sync = 249 | |remote: &mut Remote| -> Result<(jmap::State, HashSet, HashSet)> { 250 | let (state, updated_ids) = remote.all_email_ids().context(IndexRemoteEmailsSnafu {})?; 251 | // TODO can we optimize these two lines? 252 | let local_ids: HashSet = 253 | local_emails.iter().map(|(id, _)| id).cloned().collect(); 254 | let destroyed_ids = local_ids.difference(&updated_ids).cloned().collect(); 255 | Ok((state, updated_ids, destroyed_ids)) 256 | }; 257 | 258 | // Create lists of updated and destroyed `Email` IDs. This is done in one of two ways, depending 259 | // on if we have a working JMAP `Email` state. 260 | let (state, updated_ids, destroyed_ids) = latest_state 261 | .jmap_state.clone() 262 | .map(|jmap_state| { 263 | match remote.changed_email_ids(jmap_state) { 264 | Ok((state, created, mut updated, destroyed)) => { 265 | debug!("Remote changes: state={state}, created={created:?}, updated={updated:?}, destroyed={destroyed:?}"); 266 | // If we have something in the updated set that isn't in the local database, 267 | // something must have gone wrong somewhere. Do a full sync instead. 268 | if !updated.iter().all(|x| local_emails.contains_key(x)) { 269 | warn!( 270 | "Server sent an update which references an ID we don't know about, doing a full sync instead"); 271 | full_sync(&mut remote) 272 | } else { 273 | updated.extend(created); 274 | Ok((state, updated, destroyed)) 275 | } 276 | }, 277 | Err(e) => { 278 | // `Email/changes` failed, so fall back to `Email/query`. 279 | warn!( 280 | "Error while attempting to resolve changes, attempting full sync: {e}" 281 | ); 282 | full_sync(&mut remote) 283 | } 284 | } 285 | }) 286 | .unwrap_or_else(|| full_sync(&mut remote))?; 287 | 288 | // Retrieve the updated `Email` objects from the server. 289 | stdout.set_color(&info_color_spec).context(LogSnafu {})?; 290 | write!(stdout, "Retrieving metadata...").context(LogSnafu {})?; 291 | stdout.reset().context(LogSnafu {})?; 292 | writeln!(stdout, " ({} possibly changed)", updated_ids.len()).context(LogSnafu {})?; 293 | stdout.flush().context(LogSnafu {})?; 294 | 295 | let remote_emails = remote 296 | .get_emails(updated_ids.iter(), &mailboxes, &config.tags) 297 | .context(GetRemoteEmailsSnafu {})?; 298 | 299 | // Before merging, download the new files into the cache. 300 | let mut new_emails: HashMap = remote_emails 301 | .values() 302 | .filter(|remote_email| match local_emails.get(&remote_email.id) { 303 | Some(local_email) => local_email.blob_id != remote_email.blob_id, 304 | None => true, 305 | }) 306 | .map(|remote_email| { 307 | ( 308 | remote_email.id.clone(), 309 | NewEmail { 310 | remote_email, 311 | cache_path: cache.cache_path(&remote_email.id, &remote_email.blob_id), 312 | maildir_path: local.new_maildir_path(&remote_email.id, &remote_email.blob_id), 313 | }, 314 | ) 315 | }) 316 | .collect(); 317 | 318 | let new_emails_missing_from_cache: Vec<&NewEmail> = new_emails 319 | .values() 320 | .filter(|x| !x.cache_path.exists() && !local_emails.contains_key(&x.remote_email.id)) 321 | .collect(); 322 | 323 | if !new_emails_missing_from_cache.is_empty() { 324 | stdout.set_color(&info_color_spec).context(LogSnafu {})?; 325 | writeln!(stdout, "Downloading new mail...").context(LogSnafu {})?; 326 | stdout.reset().context(LogSnafu {})?; 327 | stdout.flush().context(LogSnafu {})?; 328 | 329 | let pb = ProgressBar::new(new_emails_missing_from_cache.len() as u64); 330 | let pool = rayon::ThreadPoolBuilder::new() 331 | .num_threads(config.concurrent_downloads) 332 | .build() 333 | .context(CreateDownloadThreadPoolSnafu {})?; 334 | let result: Result, Error> = pool.install(|| { 335 | new_emails_missing_from_cache 336 | .into_par_iter() 337 | .map(|new_email| { 338 | let mut retry_count = 0; 339 | loop { 340 | match download(new_email, &remote, &cache, config.convert_dos_to_unix) { 341 | Ok(_) => { 342 | pb.inc(1); 343 | return Ok(()); 344 | } 345 | Err(e) => { 346 | // Try again. 347 | retry_count += 1; 348 | if config.retries > 0 && retry_count >= config.retries { 349 | return Err(e); 350 | } 351 | warn!("Download error on try {}, retrying: {}", retry_count, e); 352 | } 353 | }; 354 | } 355 | }) 356 | .collect() 357 | }); 358 | result?; 359 | pb.finish_with_message("done"); 360 | } 361 | 362 | // Merge locally. 363 | // 364 | // 1. Symlink the cached messages that were previously downloaded into the maildir. We will 365 | // replace these symlinks with the actual files once the atomic sync is complete. 366 | // 367 | // 2. Add new messages to the database by indexing these symlinks. This is also done for 368 | // existing messages which have new blob IDs. 369 | // 370 | // 3. Update the tags of all local messages *except* the ones which had been modified locally 371 | // since mujmap was last run. Neither JMAP nor notmuch support looking at message history, so if 372 | // both the local message and the remote message have been flagged as "updated" since the last 373 | // sync, we prefer to overwrite remote tags with notmuch's tags. 374 | // 375 | // 4. Remove messages with destroyed IDs or updated blob IDs. 376 | // 377 | // 5. Overwrite the symlinks we made earlier with the actual files from the cache. 378 | let notmuch_revision = get_notmuch_revision( 379 | local_emails.is_empty(), 380 | &local, 381 | latest_state.notmuch_revision, 382 | args.dry_run, 383 | )?; 384 | let updated_local_emails: HashMap = local 385 | .all_emails_since(notmuch_revision) 386 | .context(IndexLocalUpdatedEmailsSnafu {})? 387 | .into_iter() 388 | // Filter out emails that were destroyed on the server. 389 | .filter(|(id, _)| !destroyed_ids.contains(&id)) 390 | .collect(); 391 | 392 | if pull { 393 | stdout.set_color(&info_color_spec).context(LogSnafu {})?; 394 | write!(stdout, "Applying changes to notmuch database...").context(LogSnafu {})?; 395 | stdout.reset().context(LogSnafu {})?; 396 | writeln!( 397 | stdout, 398 | " ({} new, {} changed, {} destroyed)", 399 | new_emails.len(), 400 | remote_emails.len(), 401 | destroyed_ids.len() 402 | ) 403 | .context(LogSnafu {})?; 404 | stdout.flush().context(LogSnafu {})?; 405 | 406 | // Update local messages. 407 | if !args.dry_run { 408 | // Collect the local messages which will be destroyed. We will add to this list any 409 | // messages with new blob IDs. 410 | let mut destroyed_local_emails: Vec<&local::Email> = destroyed_ids 411 | .into_iter() 412 | .flat_map(|x| local_emails.get(&x)) 413 | .collect(); 414 | 415 | // Symlink the new mail files into the maildir... 416 | for new_email in new_emails.values() { 417 | debug!( 418 | "Making symlink from `{}' to `{}'", 419 | &new_email.cache_path.to_string_lossy(), 420 | &new_email.maildir_path.to_string_lossy(), 421 | ); 422 | if new_email.maildir_path.exists() { 423 | warn!( 424 | "File `{}' already existed in maildir but was not indexed. Replacing...", 425 | &new_email.maildir_path.to_string_lossy(), 426 | ); 427 | fs::remove_file(&new_email.maildir_path).context( 428 | RemoveUnindexedMailFileSnafu { 429 | path: &new_email.maildir_path, 430 | }, 431 | )?; 432 | } 433 | symlink_file(&new_email.cache_path, &new_email.maildir_path).context( 434 | MakeMaildirSymlinkSnafu { 435 | from: &new_email.cache_path, 436 | to: &new_email.maildir_path, 437 | }, 438 | )?; 439 | } 440 | 441 | let mut commit_changes = || -> Result<()> { 442 | local.begin_atomic().context(BeginAtomicSnafu {})?; 443 | 444 | // ...and add them to the database. 445 | let new_local_emails = new_emails 446 | .values() 447 | .map(|new_email| { 448 | let local_email = 449 | local 450 | .add_new_email(&new_email) 451 | .context(AddLocalEmailSnafu { 452 | filename: &new_email.cache_path, 453 | })?; 454 | if let Some(e) = local_emails.get(&new_email.remote_email.id) { 455 | // Move the old message to the destroyed emails set. 456 | destroyed_local_emails.push(e); 457 | } 458 | Ok((local_email.id.clone(), local_email)) 459 | }) 460 | .collect::>>()?; 461 | 462 | // Update local emails with remote tags. 463 | // 464 | // XXX: If the server contains two or more of a message which notmuch considers a 465 | // duplicate, it will be updated *for each duplicate* in a non-deterministic order. 466 | // This may cause surprises. 467 | for remote_email in remote_emails.values() { 468 | // Skip email which has been updated offline. 469 | if updated_local_emails.contains_key(&remote_email.id) { 470 | continue; 471 | } 472 | 473 | // Do it! 474 | let local_email = [ 475 | new_local_emails.get(&remote_email.id), 476 | local_emails.get(&remote_email.id), 477 | ] 478 | .into_iter() 479 | .flatten() 480 | .next() 481 | .ok_or_else(|| { 482 | error!( 483 | "Could not find local email for updated remote ID {}", 484 | remote_email.id 485 | ); 486 | Error::ProgrammerError {} 487 | })?; 488 | 489 | // Add mailbox tags 490 | let mut tags: HashSet<&str> = 491 | remote_email.tags.iter().map(|s| s.as_str()).collect(); 492 | for id in &remote_email.mailbox_ids { 493 | if let Some(mailbox) = mailboxes.mailboxes_by_id.get(id) { 494 | tags.insert(&mailbox.tag); 495 | } 496 | } 497 | 498 | local 499 | .update_email_tags(local_email, tags) 500 | .context(UpdateLocalEmailSnafu {})?; 501 | 502 | // In `update' notmuch may have renamed the file on disk when setting maildir 503 | // flags, so we need to update our idea of the filename to match so that, for 504 | // new messages, we can reliably replace the symlink later. 505 | // 506 | // The `Message' might have multiple paths though (if more than one message has 507 | // the same id) so we have to get all the filenames and then find the one that 508 | // matches ours. Fortunately, our generated name (the raw JMAP mailbox.message 509 | // id) will always be a substring of notmuch's version (same name with flags 510 | // attached), so a starts-with test is enough. 511 | if let Some(mut new_email) = new_emails.get_mut(&remote_email.id) { 512 | if let Some(our_filename) = new_email 513 | .maildir_path 514 | .file_name() 515 | .map(|p| p.to_string_lossy()) 516 | { 517 | if let Some(message) = local 518 | .get_message(&local_email.message_id) 519 | .context(GetNotmuchMessageSnafu {})? 520 | { 521 | if let Some(new_maildir_path) = message 522 | .filenames() 523 | .into_iter() 524 | .filter(|f| { 525 | f.file_name().map_or(false, |p| { 526 | p.to_string_lossy().starts_with(&*our_filename) 527 | }) 528 | }) 529 | .next() 530 | { 531 | new_email.maildir_path = new_maildir_path; 532 | } 533 | } 534 | } 535 | } 536 | } 537 | 538 | // Finally, remove the old messages from the database. 539 | for destroyed_local_email in &destroyed_local_emails { 540 | local 541 | .remove_email(*destroyed_local_email) 542 | .context(RemoveLocalEmailSnafu {})?; 543 | } 544 | 545 | local.end_atomic().context(EndAtomicSnafu {})?; 546 | Ok(()) 547 | }; 548 | 549 | if let Err(e) = commit_changes() { 550 | // Remove all the symlinks. 551 | for new_email in new_emails.values() { 552 | debug!( 553 | "Removing symlink `{}'", 554 | &new_email.maildir_path.to_string_lossy(), 555 | ); 556 | if let Err(e) = fs::remove_file(&new_email.maildir_path) { 557 | warn!( 558 | "Could not remove symlink `{}': {e}", 559 | &new_email.maildir_path.to_string_lossy(), 560 | ); 561 | } 562 | } 563 | // Fail as normal. 564 | return Err(e); 565 | } 566 | 567 | // Now that the atomic database operation has been completed, do the actual file 568 | // operations. 569 | 570 | // Replace the symlinks with the real files. 571 | for new_email in new_emails.values() { 572 | debug!( 573 | "Moving mail from `{}' to `{}'", 574 | &new_email.cache_path.to_string_lossy(), 575 | &new_email.maildir_path.to_string_lossy(), 576 | ); 577 | fs::rename(&new_email.cache_path, &new_email.maildir_path).context( 578 | RenameMailFileSnafu { 579 | from: &new_email.cache_path, 580 | to: &new_email.maildir_path, 581 | }, 582 | )?; 583 | } 584 | 585 | // Delete the destroyed email files. 586 | for destroyed_local_email in &destroyed_local_emails { 587 | fs::remove_file(&destroyed_local_email.path).context(RemoveMailFileSnafu { 588 | path: &destroyed_local_email.path, 589 | })?; 590 | } 591 | } 592 | } 593 | 594 | if !args.dry_run { 595 | // Ensure that for every tag, there exists a corresponding mailbox. 596 | let tags_with_missing_mailboxes: Vec = local 597 | .all_tags() 598 | .context(IndexTagsSnafu {})? 599 | .filter(|tag| { 600 | let tag = tag.as_str(); 601 | // Any tags which *can* be mapped to a keyword do not require a mailbox. 602 | // Additionally, automatic tags are never mapped to mailboxes. 603 | if [ 604 | "draft", 605 | "flagged", 606 | "passed", 607 | "replied", 608 | "unread", 609 | &config.tags.spam, 610 | &config.tags.important, 611 | &config.tags.phishing, 612 | ] 613 | .contains(&tag) 614 | || local::AUTOMATIC_TAGS.contains(tag) 615 | { 616 | false 617 | } else { 618 | !mailboxes.ids_by_tag.contains_key(tag) 619 | } 620 | }) 621 | .collect(); 622 | if !tags_with_missing_mailboxes.is_empty() { 623 | if !config.auto_create_new_mailboxes { 624 | return Err(Error::MissingMailboxes { 625 | tags: tags_with_missing_mailboxes, 626 | }); 627 | } 628 | remote 629 | .create_mailboxes(&mut mailboxes, &tags_with_missing_mailboxes, &config.tags) 630 | .context(CreateMailboxesSnafu { 631 | tags: tags_with_missing_mailboxes, 632 | })?; 633 | } 634 | } 635 | 636 | // Update remote messages. 637 | stdout.set_color(&info_color_spec).context(LogSnafu {})?; 638 | write!(stdout, "Applying changes to JMAP server...").context(LogSnafu {})?; 639 | stdout.reset().context(LogSnafu {})?; 640 | writeln!(stdout, " ({} changed)", updated_local_emails.len()).context(LogSnafu {})?; 641 | stdout.flush().context(LogSnafu {})?; 642 | 643 | if !args.dry_run { 644 | remote 645 | .update(&updated_local_emails, &mailboxes, &config.tags) 646 | .context(PushChangesSnafu {})?; 647 | } 648 | 649 | if !args.dry_run { 650 | // Record the final state for the next invocation. 651 | LatestState { 652 | notmuch_revision: Some(local.revision() + 1), 653 | jmap_state: if pull { 654 | Some(state) 655 | } else { 656 | latest_state.jmap_state 657 | }, 658 | } 659 | .save(latest_state_filename)?; 660 | } 661 | 662 | Ok(()) 663 | } 664 | 665 | fn download( 666 | new_email: &NewEmail, 667 | remote: &Remote, 668 | cache: &Cache, 669 | convert_dos_to_unix: bool, 670 | ) -> Result<()> { 671 | let remote_email = new_email.remote_email; 672 | let reader = remote 673 | .read_email_blob(&remote_email.blob_id) 674 | .context(DownloadRemoteEmailSnafu {})?; 675 | cache 676 | .download_into_cache(&new_email, reader, convert_dos_to_unix) 677 | .context(CacheNewEmailSnafu {})?; 678 | Ok(()) 679 | } 680 | 681 | fn get_notmuch_revision( 682 | has_no_local_emails: bool, 683 | local: &Local, 684 | notmuch_revision: Option, 685 | dry_run: bool, 686 | ) -> Result { 687 | match notmuch_revision { 688 | Some(x) => Ok(x), 689 | None => { 690 | if has_no_local_emails { 691 | Ok(local.revision()) 692 | } else { 693 | if dry_run { 694 | println!( 695 | "\ 696 | THIS IS A DRY RUN, SO NO CHANGES WILL BE MADE NO MATTER THE CHOICE. HOWEVER, 697 | HEED THE WARNING FOR THE REAL DEAL. 698 | " 699 | ); 700 | } 701 | println!( 702 | "\ 703 | mujmap was unable to read the notmuch database revision (stored in 704 | mujmap.state.json) since the last time it was run. As a result, it cannot 705 | determine the changes made in the local database since the last time a 706 | synchronization was performed. 707 | " 708 | ); 709 | if atty::is(Stream::Stdout) { 710 | println!( 711 | "\ 712 | If you continue, any potential changes made to your database since your last 713 | sync will not be pushed to the JMAP server, and some may be overwritten by the 714 | JMAP server's state. Depending on your situation, this has potential to create 715 | inconsistencies between the state of your database and the state of the server. 716 | 717 | If this is the first time you are synchronizing mujmap in a pre-existing 718 | database, this is not an issue. If the mail files in your database are different 719 | from your mail on the JMAP server, you can proceed and mujmap will perform the 720 | initial setup with no issues. This would be the case if you had multiple email 721 | accounts in the same database, for example. 722 | 723 | If this is the first time you are synchronizing, and you are attempting to 724 | migrate your existing notmuch database tags to mailboxes on a JMAP server, 725 | DO NOT continue. Instead, follow the relevant instructions in mujmap's README. 726 | 727 | If this is the first time you are synchronizing, but you do not care about your 728 | notmuch tags and would like them to be replaced with the JMAP server's state, 729 | you may continue. 730 | 731 | If this is NOT the first time you are synchronizing, you should quit and force 732 | a full sync by deleting the `mujmap.state.json' file and invoking mujmap again. 733 | This will overwrite all of your local JMAP state with the JMAP server's state. 734 | You will encounter this message again, but at that point you can safely proceed. 735 | 736 | Continue? (y/N) 737 | " 738 | ); 739 | let mut response = String::new(); 740 | io::stdin().read_line(&mut response).ok(); 741 | let trimmed = response.trim(); 742 | ensure!( 743 | trimmed == "y" || trimmed == "Y", 744 | MissingNotmuchDatabaseRevisionSnafu {} 745 | ); 746 | Ok(local.revision()) 747 | } else { 748 | println!( 749 | "\ 750 | Please run mujmap again in an interactive terminal to resolve. 751 | " 752 | ); 753 | return Err(Error::MissingNotmuchDatabaseRevision {}); 754 | } 755 | } 756 | } 757 | } 758 | } 759 | --------------------------------------------------------------------------------