├── .formatter.exs ├── .gitignore ├── LICENSE ├── README.md ├── assets ├── css │ └── index.css ├── js │ └── app.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── snowpack.config.js ├── static │ ├── compose.jpg │ ├── favicon.ico │ ├── images │ │ └── liv_logo.png │ ├── inbox.jpg │ ├── phone_screen.jpg │ ├── robots.txt │ └── search_dialog.jpg └── tailwind.config.js ├── config ├── config.exs ├── dev.exs ├── prod.exs ├── runtime.exs └── test.exs ├── lib ├── liv.ex ├── liv │ ├── address_vault.ex │ ├── application.ex │ ├── configer.ex │ ├── delay_marker.ex │ ├── draft_server.ex │ ├── mail_client.ex │ ├── mailer.ex │ ├── orbit.ex │ ├── parser.ex │ └── sanitizer.ex ├── liv_web.ex └── liv_web │ ├── channels │ └── user_socket.ex │ ├── components │ ├── address_book.ex │ ├── address_book.sface │ ├── attachment.ex │ ├── attachment.sface │ ├── boomerang.ex │ ├── boomerang.sface │ ├── button.ex │ ├── button.hooks.js │ ├── button.sface │ ├── config.ex │ ├── config.sface │ ├── draft.ex │ ├── draft.sface │ ├── find.ex │ ├── find.sface │ ├── login.ex │ ├── login.sface │ ├── mail_node.ex │ ├── mail_node.sface │ ├── main.ex │ ├── main.hooks.js │ ├── main.sface │ ├── print.ex │ ├── print.sface │ ├── recipient.ex │ ├── recipient.sface │ ├── remote_mail_box.ex │ ├── remote_mail_box.sface │ ├── search.ex │ ├── search.sface │ ├── view.ex │ ├── view.hooks.js │ ├── view.sface │ ├── write.ex │ └── write.sface │ ├── endpoint.ex │ ├── gettext.ex │ ├── guardian.ex │ ├── live │ ├── mail_live.ex │ ├── mail_live.sface │ ├── page_live.ex │ └── page_live.html.leex │ ├── router.ex │ ├── telemetry.ex │ ├── templates │ └── layout │ │ └── root.html.leex │ └── views │ ├── error_helpers.ex │ ├── error_view.ex │ └── layout_view.ex ├── mix.exs ├── mix.lock ├── priv └── gettext │ ├── en │ └── LC_MESSAGES │ │ └── errors.po │ └── errors.pot ├── rebar.lock ├── release.sh └── test ├── liv_web ├── live │ └── page_live_test.exs └── views │ ├── error_view_test.exs │ └── layout_view_test.exs ├── support ├── channel_case.ex └── conn_case.ex └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:phoenix, :surface], 3 | inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"], 4 | surface_inputs: ["{lib,test}/**/*.{ex,exs,sface}", "priv/catalogue/**/*.{ex,exs,sface}"] 5 | ] 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | \#* 2 | *~ 3 | .snowpack 4 | 5 | # The directory Mix will write compiled artifacts to. 6 | /_build/ 7 | 8 | # If you run "mix test --cover", coverage assets end up here. 9 | /cover/ 10 | 11 | # The directory Mix downloads your dependencies sources to. 12 | /deps/ 13 | 14 | # Where 3rd-party dependencies like ExDoc output generated docs. 15 | /doc/ 16 | 17 | # Ignore .fetch files in case you like to edit your project deps locally. 18 | /.fetch 19 | 20 | # If the VM crashes, it generates a dump, let's ignore it too. 21 | erl_crash.dump 22 | 23 | # Also ignore archive artifacts (built via "mix archive.build"). 24 | *.ez 25 | 26 | # Ignore package tarball (built via "mix hex.build"). 27 | liv-*.tar 28 | 29 | # If NPM crashes, it generates a log, let's ignore it too. 30 | npm-debug.log 31 | 32 | # The directory NPM downloads your dependencies sources to. 33 | /assets/node_modules/ 34 | 35 | # Since we are building assets from assets/, 36 | # we ignore priv/static. You may want to comment 37 | # this depending on your deployment strategy. 38 | /priv/static/ 39 | 40 | # Ignore auto-generated surface files 41 | /assets/js/_hooks/ 42 | /assets/css/_components.css 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 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 Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LIV - Live Inbox View 2 | 3 | LIV is a webmail front-end for your personal email server. 4 | 5 | ## Why LIV 6 | 7 | All open source webmail sucks. Most I have seen are layered on top of IMAP, and IMAP sucks. The reason is you have to have search capability to deal with the high volume of emails nowadays, and it is very hard to do that across IMAP. On the other hand, some other email clients don't suck: 8 | 9 | * Commercial "free" email providers, such as Gmail or Outlook.com don't suck. However, they are basically ads delivery vehicles targeted to you with all the privacy leaks and annoyances that you want to break out from. 10 | * Terminal email clients (mutt, mu4e, etc) don't suck. This is what I use before LIV. However, I need to view HTML mails in a browser window and click some links and it is not convenient for those occasions. 11 | 12 | LIV is a highly opinionated, minimal implemented webmail front-end that: 13 | 14 | * Has a integrated search engine thanks to [mu](https://github.com/djcb/mu) 15 | * Use browser native functionalities such as bookmarks. You can bookmark any queries or any emails 16 | * Let you compose your emails in markdown with instant preview 17 | 18 | LIV is designed to be self hosted; It is not a SaaS. You run LIV on your own email server with or without an IMAP server. If you don't want to run your own email server please stop right here. If you don't know how to run your email server please do some research first; there are many excellent tutorials out there and this page is not one of them. 19 | 20 | Although LIV is designed to run on your own email server, it can run without one running on the same machine. The experience may not be as good though. There are preliminary POP3 support (SSL on port 995 only) to get your emails, and non-local SMTP support (STARTTLS on port 587 only) to send your emails. Also, you could send emails through [Sendgrid](https://sendgrid.com/). You will also need to setup Maildir on the machine yourself: 21 | 22 | ``` 23 | $ mkdir ~/Maildir 24 | $ cd ~/Maildir 25 | $ mkdir .Archive cur new tmp 26 | $ cd .Archive 27 | $mkdir cur new tmp 28 | ``` 29 | 30 | A few screenshots below: 31 | 32 | ![inbox view](assets/static/inbox.jpg) 33 | 34 | ![inbox view on a phone](assets/static/phone_screen.jpg) 35 | 36 | ![sample search query](assets/static/search_dialog.jpg) 37 | 38 | ![compose in markdown](assets/static/compose.jpg) 39 | 40 | ## Your personal webmail 41 | 42 | LIV is designed for personal usage instead of organisational usage. It works best on your own email server on your own VPS and your own domain name, serving yourself and maybe a few family members and close friends. To do that, you need to have the following setup: 43 | 44 | * An internet facing VPS with a valid domain name and MX records 45 | * A working SMTP server. I recommend [exim](https://exim.org/) but others should work. It should have a open relay listening at localhost at port 25. 46 | * The emails are delivered to system users and are stored in [Maildir](https://cr.yp.to/proto/maildir.html) format. 47 | 48 | You don't have to have a IMAP server but it may be useful. If you use a IMAP server, you need to disable the automatic moving from `new/` to `cur/` directory by the IMAP server. This is because LIV need to be notified by email arrival and update the index. LIV will do the moving itself. If you are using `exim` and `dovecot` like me, you will need to make sure the exim's config has: 49 | ``` 50 | LOCAL_DELIVERY = maildir_home 51 | ``` 52 | instead of: 53 | ``` 54 | LOCAL_DELIVERY = dovecot_delivery 55 | ``` 56 | 57 | Also please turn off the auto movement of new mails in your IMAP server. In dovecot, it is controlled in `/etc/dovecot/conf.d/10-mail.conf` with a line as: 58 | 59 | ``` 60 | maildir_empty_new = no 61 | ``` 62 | 63 | I understand that running your own email server is a tall task and uphill battle with email deliverability. LIV can also run without one, by getting your emails with POP3 and sending your emails with authenticated SMTP or a Sendgrid account. It can even run on your home computer. 64 | 65 | Once you have a working email setup and you verified you can receive and send email via terminal tools such as mutt you can proceed to the next section. 66 | 67 | ## Installing LIV and its prerequisites 68 | 69 | LIV is written in [Elixir](https://elixir-lang.org) so you need to install the tool chains for Erlang and Elixir. You will also need the basic tool chain for node.js to build the js and css. The Phoenix's [installation guide](https://hexdocs.pm/phoenix/installation.html) contains everything you need. LIV does not use a database, so the part of PostgreSQL is irrelevant. 70 | 71 | LIV also need a couple of commandline tools to function. They are: 72 | 73 | * inotify-tools, to watch the new mail dir 74 | * socat, for local machine automation 75 | 76 | They can be installed in most Linux distributions. 77 | 78 | LIV uses the [mu email search engine](https://github.com/djcb/mu) so you will also need to install that. Please install the 1.6.x branch. Once installed please verify that mu is indeed working by building the index `mu init && mu index` 79 | 80 | Now you are ready to install LIV itself. LIV is a standard [Phoenix LiveView](https://www.phoenixframework.org/) web application, so just clone it from here and do: 81 | ``` 82 | mix deps.get 83 | mix compile 84 | npm install --prefix ./assets 85 | mix phx.server 86 | ``` 87 | 88 | And LIV is up (port 4000). To run it in production you will need to do a standard OTP release: 89 | ``` 90 | export MIX_ENV=prod 91 | mix compile 92 | npm run deploy --prefix ./assets 93 | mix phx.digest 94 | mix release 95 | ``` 96 | 97 | ## Deploying LIV 98 | 99 | It is critically important to run LIV over https. **Do not run LIV over plain http except in debug situation**. Also, **Each user has to run their own LIV instance**, however, all users can share the same OTP release, the same reverse proxy and the SSL cert. LIV is smart enough not to step on each others tow and to deduce the username and per-user configuration at the run time. 100 | 101 | Please set a few environment variables before running the release: 102 | 103 | ``` 104 | export SECRET_KEY_BASE=YOUR_SECRET_KEY_BASE 105 | export PORT=4001 106 | export MAIL_HOST=YOUR_MAIL_SERVER 107 | _build/prod/rel/liv/bin/liv start 108 | ``` 109 | 110 | The `SECRET_KEY_BASE` is a random string you should generate yourself once, and keep them secret. Each user must pick a different port. 111 | 112 | The entry point of is at: `https://YOUR_MAIL_SERVER/YOUR_USER_NAME`. Your reverse proxy needs to provide 2 proxy paths for each instance, one for regular route and one for websocket, as in the following Nginx example: 113 | 114 | ``` 115 | location /derek { 116 | proxy_set_header Host $host; 117 | proxy_set_header X-Real-IP $remote_addr; 118 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 119 | proxy_set_header X-Forwarded-Proto $scheme; 120 | proxy_set_header X-Forwarded-Host $host; 121 | proxy_set_header X-Forwarded-Port $server_port; 122 | proxy_pass http://localhost:4001/; 123 | proxy_redirect off; 124 | } 125 | 126 | location /derek/live { 127 | proxy_http_version 1.1; 128 | proxy_set_header Upgrade $http_upgrade; 129 | proxy_set_header Connection "upgrade"; 130 | proxy_set_header Host $host; 131 | proxy_set_header X-Real-IP $remote_addr; 132 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 133 | proxy_set_header X-Forwarded-Proto $scheme; 134 | proxy_set_header X-Forwarded-Host $host; 135 | proxy_set_header X-Forwarded-Port $server_port; 136 | proxy_pass http://localhost:4001/live; 137 | # raise the proxy timeout for the websocket 138 | proxy_read_timeout 6000s; 139 | } 140 | ``` 141 | 142 | You would need to substitude `derek` with your usernames of course. Each user need a pair of stanzas like above. 143 | 144 | ## Using LIV 145 | 146 | The first time you run LIV it will ask you to setup a password. This password is not your system password, which LIV has no access to anyway. Just pick any password you like. LIV will store the hash of this password in `~/.config/self_configer/liv.config` so should you lose the password you can edit it out and restart LIV. There are a few configuration you should enter at the config screen of the application: 147 | 148 | * Your name and preferred email address. This is the default `From:` address and `Bcc:` address 149 | * All your email addresses, incliding the preferred one. LIV will remove them from the `Cc:` so you only receive one copy of an email 150 | * The email lists that you belong to. LIV will remove yourself from the `Bcc:` if you are replying to a mailing list. 151 | 152 | The query syntax is from `mu`, so you should familiar yourself with `man mu-query` 153 | 154 | If you want to run `mu4e` at the same time with LIV, you must configure `mu4e` to use the alternative `mu` binary. A simple wrapper script is provided [here (mc)](https://github.com/derek-zhou/maildir_commander/blob/main/scripts/mc). Please note `mc` is an incomplete wrapper of `mu`; it only does enough to mimic `mu server`, to satisfy `mu4e`. 155 | 156 | LIV has a fairly spartan user interface. You can search your email database, go though your emails one by one, write or reply email, and that's it. You won't find the following functionalities: 157 | 158 | * Sort mails in another way. They are always sorted by date starting from the latest and they are always threaded. 159 | * Delete mails. I don't delete emails by hand, but instead I archive emails. More on it later 160 | 161 | On the other hand, LIV is unique in that: 162 | 163 | * Every query, every message etc. are all bookmark-able. 164 | * All messages are threaded, usable even on a very narrow phone screen. 165 | * You write your emails in markdown, with instant html preview. 166 | * Automatically download or upload attachments in the bakground, at the same time when you write or read emails. 167 | 168 | LIV is not designed to cover 100% of the usercases. Although I use LIV 99% of the time, I still use terminal email clients (mu4e and mutt are both fine) for corners cases such as lloking at the raw message, or handling patches. 169 | 170 | ### Using LIV to handle mailto: links 171 | 172 | It is also possible to use LIV to handle mailto: links. All you need to do is to configure your browser to point the mailto: links to the following URL: 173 | 174 | ``` 175 | https://YOUR_MAIL_HOST/YOUR_USER_NAME/write/THE_ADDR_OR_MAILTO_LINK 176 | ``` 177 | 178 | for how to do it in Firefox, see the following article: 179 | 180 | https://support.mozilla.org/en-US/questions/1281202 181 | 182 | ### How do I delete a mail? 183 | 184 | No, you can't manually delete emails, nor can you edit received emails, move mails around, etc. Emails are immutable and can only expire (default 30 days), unless it qualifies to be archived (See below). 185 | 186 | ### How do I reply to a mail? 187 | 188 | Just click on the sender's address. In fact, any email addresses within the headers of the message, including yourself, are clickable. LIV is smart enough to quote the text and prepare a default set of recipients. The address you clicked will be in the `To:` list, anybody else will be in the `Cc:` list, and yourself will be in the `Bcc:` list. There is no seperate `reply all` functionality. You can change the recipients however you like of course. 189 | 190 | ### How do I forward a mail? 191 | 192 | No, you can't forward a mail like what you normally do in other email clients. Most other email clients munge the message to be forwarded, so it is not a strict forward anyway. You can reply to the message, adding the new recipients as needed. For the rare occation that I really need to forword a mail in verbitim I use mutt's bounce function. 193 | 194 | ## Email archiving 195 | 196 | This is something I come up with over the years dealing with huge amount of emails in a lazy mindset. I only have two email folders, the standard inbox, and `.Archive` (The name is a convention from many IMAP clients including Thunderbird). All mails land in the inbox initially. Every once in a while LIV go through all emails in the inbox conversation by conversation. For each conversation: 197 | 198 | * If the latest email of the conversation is within 30 days, or any mail is still unread, don't do anything with the conversation. Otherwise: 199 | * If I (as defined my all my known email addresses) was _not_ involved in the conversation, the whole conversation is deleted. 200 | * If I was involved in the conversation, the whole conversation is moved to the `.Archive` folder for long term storage, with all attachments removed. 201 | 202 | The process works best if you actively reply to important emails. It is a good online curtesy anyway. If for some reason you don't want to reply to the sender but still want to archive the conversation, you can reply to yourself and add a note. You only need to reply once for each conversation. 203 | 204 | The marking of conversations to be archived is done as soon as possible. The marked conversation will be archived once the latest email of the conversation moves out of the 30 days retention window. Every email in a marked conversation have the `replied` flag set and will be displayed with a highlighted background. You can also search for `flag:replied` messages. 205 | 206 | Archived emails are still searchable, just not in the inbox so my inbox stays in constant size. If the archive target is left empty in the config page, archiving would be disabled, however marking and deleting will still be done. 207 | 208 | ## Orbit integration 209 | 210 | The folks at [Orbit](https://orbit.love) are kind enough to provide free services to anyone that has reasonably low volume of traffic. Orbit is a tool to analize activities, so you can find out who among your corespondances are actively engaged with you, and who are drifting away. You need to fill out the Orbit API key and the Orbit workspace in the configuration page. Both can be found from your Orbit profile. 211 | 212 | ## Disclaimer 213 | 214 | LIV is beta quality software, the implementation is incomplete and may never be. I use it everyday though. If you don't see a point of running your email server you do not need LIV. If you run your own email server and want to add webmail functionality to it, you are welcome to try it and give me feedback. 215 | -------------------------------------------------------------------------------- /assets/css/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | 3 | @tailwind components; 4 | 5 | @tailwind utilities; 6 | 7 | body { 8 | @apply mx-1; 9 | } 10 | a { 11 | @apply text-blue-600; 12 | } 13 | a:focus, a:hover { 14 | @apply text-gray-600; 15 | } 16 | span { 17 | @apply whitespace-nowrap; 18 | } 19 | @screen md { 20 | body { 21 | @apply mx-0; 22 | } 23 | } 24 | 25 | /* my own styles, use tailwind as much as possible */ 26 | 27 | /* this is for classless content, eg: from markdown */ 28 | .content { 29 | h1 { 30 | @apply font-bold text-2xl mb-4 leading-relaxed text-center; 31 | } 32 | h2 { 33 | @apply font-bold text-2xl mb-3 leading-relaxed; 34 | } 35 | h3 { 36 | @apply font-bold text-xl mb-2 leading-relaxed; 37 | } 38 | h4 { 39 | @apply font-bold text-lg mb-1 leading-relaxed; 40 | } 41 | h5 { 42 | @apply font-bold uppercase mb-1 leading-relaxed; 43 | } 44 | h6 { 45 | @apply font-bold mb-1 leading-relaxed; 46 | } 47 | p { 48 | @apply mb-4; 49 | } 50 | ol { 51 | @apply mb-4 pl-2 list-inside list-decimal; 52 | } 53 | ul { 54 | @apply mb-4 pl-2 list-inside list-disc; 55 | } 56 | figure { 57 | @apply mb-4; 58 | } 59 | textarea { 60 | @apply p-1 w-full text-sm border rounded border-gray-600; 61 | } 62 | table { 63 | @apply mb-4 border-collapse border-0; 64 | th, td { 65 | @apply p-0 align-top border-0; 66 | } 67 | thead { 68 | th { 69 | @apply align-bottom border-0; 70 | } 71 | } 72 | tr.odd { 73 | @apply bg-gray-50; 74 | } 75 | } 76 | blockquote { 77 | @apply mb-4 border-l-4 border-gray-400 p-2; 78 | p { 79 | @apply mb-0; 80 | } 81 | } 82 | pre { 83 | @apply mb-4 p-2 bg-gray-50; 84 | > code { 85 | @apply whitespace-pre; 86 | } 87 | } 88 | dd, 89 | dt, 90 | li { 91 | @apply mb-1; 92 | } 93 | } 94 | 95 | /* Alerts and form errors */ 96 | .alert { 97 | @apply p-2 rounded mb-0; 98 | } 99 | .alert-info { 100 | @apply bg-yellow-200 text-blue-700; 101 | } 102 | .alert-warning { 103 | @apply bg-yellow-200 text-purple-700; 104 | } 105 | .alert-danger { 106 | @apply bg-yellow-200 text-red-700; 107 | } 108 | .alert-info:empty { 109 | @apply hidden; 110 | } 111 | .alert-warning:empty { 112 | @apply hidden; 113 | } 114 | .alert-danger:empty { 115 | @apply hidden; 116 | } 117 | .name { 118 | @apply capitalize italic; 119 | } 120 | .button { 121 | @apply flex-none px-6 py-1 text-purple-600 inline-block rounded appearance-none 122 | font-bold whitespace-nowrap text-lg text-center uppercase; 123 | } 124 | .button:focus, .button:hover { 125 | @apply text-gray-600; 126 | } 127 | .button[disabled] { 128 | @apply pointer-events-none cursor-default opacity-50; 129 | } 130 | .button.disabled { 131 | @apply pointer-events-none cursor-default opacity-50; 132 | } 133 | /* progress bar */ 134 | .progress-bar { 135 | @apply fixed left-0 top-0 h-1 bg-indigo-700 z-50; 136 | transition: 137 | width 300ms ease-out, 138 | opacity 150ms 150ms ease-in; 139 | transform: translate3d(0, 0, 0); 140 | } 141 | .viewport { 142 | @apply relative min-h-screen flex flex-col; 143 | .phx-connected { 144 | @apply flex-grow flex flex-col; 145 | } 146 | .header { 147 | @apply sticky top-0 bg-gray-100 p-2 flex; 148 | .brand { 149 | img { 150 | @apply inline-block h-8; 151 | } 152 | .title { 153 | @apply text-3xl text-indigo-500 font-bold align-bottom; 154 | } 155 | .info { 156 | @apply text-gray-600 align-bottom; 157 | } 158 | } 159 | .nav { 160 | @apply flex-grow text-right; 161 | .button { 162 | @apply cursor-pointer ml-1 px-1; 163 | } 164 | .attach { 165 | @apply inline-block; 166 | input[type="file"] { 167 | @apply hidden; 168 | } 169 | } 170 | } 171 | } 172 | .content { 173 | @apply flex-grow flex flex-col; 174 | form { 175 | @apply p-2 bg-gray-50 flex flex-col; 176 | .line { 177 | @apply w-full flex flex-row mb-2 space-x-2; 178 | input[type="text"] { 179 | @apply flex-grow; 180 | } 181 | input[type="email"] { 182 | @apply flex-grow; 183 | } 184 | } 185 | .field { 186 | @apply mb-2 flex flex-row flex-wrap justify-center gap-x-1; 187 | } 188 | .hide { 189 | @apply hidden; 190 | } 191 | .long { 192 | @apply w-full; 193 | } 194 | .center { 195 | @apply w-full text-center; 196 | } 197 | hr { 198 | @apply mb-2 border-gray-200; 199 | } 200 | .button[type="reset"] { 201 | @apply flex-none cursor-pointer text-white bg-pink-600; 202 | } 203 | .button[type="submit"] { 204 | @apply flex-none cursor-pointer text-white bg-purple-600; 205 | } 206 | .toolbar { 207 | @apply pt-2 flex flex-row flex-wrap justify-center gap-x-1; 208 | } 209 | section { 210 | @apply pt-2 border-b flex flex-row flex-wrap justify-center gap-x-2; 211 | } 212 | section.twoside { 213 | @apply flex-col; 214 | } 215 | section:last-child { 216 | @apply border-b-0 pb-0; 217 | } 218 | label { 219 | @apply flex-initial mr-2; 220 | .focus { 221 | @apply font-bold italic; 222 | } 223 | } 224 | .footnote { 225 | @apply text-sm text-gray-700 flex justify-center; 226 | } 227 | .error { 228 | @apply text-sm text-red-700 py-2; 229 | } 230 | select { 231 | @apply border rounded border-gray-600; 232 | } 233 | .code { 234 | @apply font-mono; 235 | } 236 | input[type="number"] { 237 | @apply w-16 leading-relaxed border rounded border-gray-600; 238 | } 239 | input[type="text"] { 240 | @apply w-48 leading-relaxed border rounded border-gray-600; 241 | } 242 | input[type="text"].long { 243 | @apply w-80; 244 | } 245 | input[type="email"] { 246 | @apply w-80 leading-relaxed border rounded border-gray-600; 247 | } 248 | input[type="password"] { 249 | @apply w-40 leading-relaxed border rounded border-purple-600; 250 | } 251 | } 252 | .preview { 253 | @apply border rounded bg-yellow-50 p-2 overflow-auto; 254 | } 255 | .email-addr { 256 | @apply text-sm italic flex-grow whitespace-nowrap overflow-hidden; 257 | } 258 | .address-book { 259 | @apply w-full border-0; 260 | .date { 261 | @apply font-bold text-sm whitespace-nowrap overflow-hidden; 262 | } 263 | th { 264 | @apply px-2 py-2 align-top border-0 text-left; 265 | } 266 | td { 267 | @apply px-2 py-1 align-top border-0 text-left; 268 | } 269 | th.address-book-delete { 270 | @apply w-1/12; 271 | } 272 | th.address-book-from { 273 | @apply w-5/12; 274 | } 275 | th.address-book-first { 276 | @apply w-1/6; 277 | } 278 | th.address-book-last { 279 | @apply w-1/6; 280 | } 281 | th.address-book-count { 282 | @apply w-1/6; 283 | } 284 | thead { 285 | @apply bg-yellow-50 text-sm capitalize; 286 | } 287 | tr:nth-child(even) { 288 | @apply bg-gray-50; 289 | } 290 | } 291 | ul.button-examples { 292 | @apply list-none; 293 | li { 294 | @apply inline-block; 295 | button { 296 | @apply text-blue-600 border border-blue-600 rounded mx-1 px-3 py-2 text-sm font-bold; 297 | } 298 | button:focus, button:hover { 299 | @apply text-gray-600 border-gray-600; 300 | } 301 | } 302 | } 303 | ul.attachments { 304 | @apply border rounded mb-0 py-1; 305 | li { 306 | @apply text-sm inline; 307 | .progress-box { 308 | @apply border h-2 w-16 inline-block; 309 | .progress { 310 | @apply bg-indigo-700 h-full; 311 | } 312 | } 313 | } 314 | } 315 | .node-container { 316 | @apply w-full; 317 | .message { 318 | @apply flex flex-row flex-wrap border-b border-gray-100 mx-2; 319 | .subject { 320 | @apply w-full whitespace-nowrap overflow-hidden; 321 | } 322 | .date { 323 | @apply font-bold text-sm whitespace-nowrap w-24 flex-none pl-2; 324 | } 325 | } 326 | .message.message-unread { 327 | .subject { 328 | @apply font-bold; 329 | } 330 | } 331 | .message.message-replied { 332 | @apply bg-yellow-50; 333 | } 334 | .children-container { 335 | @apply border-l-4 border-yellow-600 ml-2; 336 | } 337 | } 338 | .fill { 339 | @apply flex-grow flex flex-col; 340 | } 341 | .fill[hidden] { 342 | @apply hidden; 343 | } 344 | .box { 345 | @apply flex-grow bg-white p-2 flow-root w-full overflow-x-hidden; 346 | .line { 347 | @apply w-full; 348 | .email-addr { 349 | @apply text-sm italic; 350 | } 351 | .date { 352 | @apply font-bold text-sm; 353 | } 354 | .flags { 355 | @apply font-bold text-sm; 356 | } 357 | } 358 | pre { 359 | @apply whitespace-pre-line; 360 | } 361 | .thumbnail { 362 | img { 363 | @apply w-auto max-w-full max-w-xs max-h-48; 364 | } 365 | } 366 | .thumbnail-missing { 367 | @apply float-left mr-2; 368 | img { 369 | @apply w-16 h-16; 370 | } 371 | } 372 | .title { 373 | @apply font-bold my-2 text-lg leading-snug w-full; 374 | } 375 | .tag-line { 376 | @apply text-sm leading-snug; 377 | .site { 378 | @apply text-gray-400 whitespace-nowrap; 379 | } 380 | } 381 | .desc { 382 | @apply font-serif text-gray-800 leading-snug mb-2; 383 | span { 384 | @apply whitespace-normal; 385 | } 386 | } 387 | .button { 388 | @apply border border-blue-600; 389 | } 390 | .toolbar { 391 | @apply flex flex-wrap w-full gap-x-1 justify-center; 392 | .button { 393 | @apply border-0 text-white bg-purple-600; 394 | } 395 | .button-danger { 396 | @apply bg-pink-600; 397 | } 398 | .button:focus, .button:hover { 399 | @apply border-gray-600; 400 | } 401 | } 402 | } 403 | } 404 | .footer { 405 | @apply text-sm text-gray-700 p-2 flex text-center; 406 | .links { 407 | @apply w-1/2 text-left; 408 | } 409 | .copyright { 410 | @apply w-1/2 text-right; 411 | } 412 | } 413 | } 414 | @screen sm { 415 | .content { 416 | h1 { 417 | @apply text-3xl; 418 | } 419 | h2 { 420 | @apply text-3xl; 421 | } 422 | h3 { 423 | @apply text-2xl; 424 | } 425 | h4 { 426 | @apply text-xl; 427 | } 428 | h5 { 429 | @apply text-lg; 430 | } 431 | h6 { 432 | @apply text-lg; 433 | } 434 | p { 435 | @apply text-lg leading-relaxed; 436 | } 437 | ol { 438 | @apply text-lg; 439 | } 440 | ul { 441 | @apply text-lg; 442 | } 443 | blockquote { 444 | @apply text-lg leading-relaxed; 445 | } 446 | } 447 | 448 | .viewport { 449 | .header { 450 | .nav { 451 | .button { 452 | @apply px-4; 453 | } 454 | } 455 | } 456 | .content { 457 | .node-container { 458 | .message { 459 | @apply flex-nowrap border-0; 460 | .subject { 461 | @apply flex-grow; 462 | } 463 | .email-addr { 464 | @apply border-l border-gray-100 w-40 flex-none pl-2; 465 | } 466 | .date { 467 | @apply border-l border-gray-100; 468 | } 469 | } 470 | } 471 | .box { 472 | .title { 473 | @apply text-2xl w-auto; 474 | } 475 | .tag-line { 476 | @apply text-base leading-normal; 477 | } 478 | .thumbnail { 479 | @apply float-right ml-2; 480 | } 481 | .desc { 482 | @apply leading-normal; 483 | } 484 | } 485 | } 486 | } 487 | } 488 | @screen md { 489 | .viewport { 490 | .content { 491 | form { 492 | section.twoside { 493 | @apply grid grid-cols-2; 494 | } 495 | } 496 | .box { 497 | img { 498 | @apply max-w-md; 499 | } 500 | .thumbnail { 501 | img { 502 | @apply max-w-lg max-h-64; 503 | } 504 | } 505 | .url { 506 | @apply w-auto; 507 | } 508 | } 509 | } 510 | } 511 | } 512 | @screen lg { 513 | html { 514 | @apply text-[18px]; 515 | } 516 | .viewport { 517 | @apply max-w-screen-lg mx-auto; 518 | } 519 | } 520 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | import "phoenix_html" 2 | import {Socket} from "phoenix" 3 | import {LiveSocket} from "phoenix_live_view" 4 | import Hooks from "./_hooks"; 5 | 6 | function show_progress_bar() { 7 | var bar = document.querySelector("div#app-progress-bar"); 8 | bar.style.width = "100%"; 9 | bar.style.opacity = "1"; 10 | } 11 | 12 | function hide_progress_bar() { 13 | var bar = document.querySelector("div#app-progress-bar"); 14 | bar.style.width = "0%"; 15 | bar.style.opacity = "0"; 16 | } 17 | 18 | function local_state() { 19 | let ret = new Object(); 20 | ret.timezoneOffset = new Date().getTimezoneOffset(); 21 | ret.language = navigator.language; 22 | // dump everthing from localStorage to the server side 23 | for (let i = 0; i < localStorage.length; i++) { 24 | let key = localStorage.key(i); 25 | let value = localStorage.getItem(key); 26 | let found = key.match(/^liv_(.*)/); 27 | if (found) 28 | ret[found[1]] = value; 29 | } 30 | return ret; 31 | } 32 | 33 | // Show progress bar on live navigation and form submits 34 | window.addEventListener("phx:page-loading-start", info => show_progress_bar()) 35 | window.addEventListener("phx:page-loading-stop", info => hide_progress_bar()) 36 | 37 | document.addEventListener("DOMContentLoaded", () => { 38 | let appRoot = document.querySelector("body").getAttribute("data-app-root"); 39 | let liveSocket = new LiveSocket(appRoot + "live", Socket, 40 | {hooks: Hooks, params: local_state}); 41 | // connect if there are any LiveViews on the page 42 | liveSocket.connect(); 43 | 44 | // expose liveSocket on window for web console debug logs and latency simulation: 45 | // >> liveSocket.enableDebug() 46 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 47 | // >> liveSocket.disableLatencySim() 48 | window.liveSocket = liveSocket 49 | }); 50 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "deploy": "snowpack build", 4 | "watch": "snowpack build --watch" 5 | }, 6 | "dependencies": { 7 | "base64-js": "^1.5.1", 8 | "morphdom": "^2.6.1", 9 | "phoenix": "^1.6.13", 10 | "phoenix_html": "^3.2", 11 | "phoenix_live_view": "^0.18.1" 12 | }, 13 | "devDependencies": { 14 | "@snowpack/plugin-postcss": "^1.4.3", 15 | "autoprefixer": "^10.4.2", 16 | "postcss": "^8.4.5", 17 | "postcss-cli": "^8.3.1", 18 | "postcss-nested": "^5.0.3", 19 | "snowpack": "^3.8.8", 20 | "tailwindcss": "^3.0.15" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /assets/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require('tailwindcss/nesting'), 4 | require('tailwindcss'), 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /assets/snowpack.config.js: -------------------------------------------------------------------------------- 1 | // Snowpack Configuration File 2 | // See all supported options: https://www.snowpack.dev/#configuration 3 | 4 | /** @type {import("snowpack").SnowpackUserConfig } */ 5 | module.exports = { 6 | exclude: ['**/node_modules/**/*', '**/\#*'], 7 | mount: { 8 | "static": {url: "/", static: true, resolve: false}, 9 | "css": "/css", 10 | "js": "/js" 11 | }, 12 | plugins: [ 13 | "@snowpack/plugin-postcss" 14 | ], 15 | // installOptions: {}, 16 | // devOptions: {}, 17 | buildOptions: { 18 | out: "../priv/static" 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /assets/static/compose.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/derek-zhou/liv/be148051a4caa11621f2e169859efab8e4fb842b/assets/static/compose.jpg -------------------------------------------------------------------------------- /assets/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/derek-zhou/liv/be148051a4caa11621f2e169859efab8e4fb842b/assets/static/favicon.ico -------------------------------------------------------------------------------- /assets/static/images/liv_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/derek-zhou/liv/be148051a4caa11621f2e169859efab8e4fb842b/assets/static/images/liv_logo.png -------------------------------------------------------------------------------- /assets/static/inbox.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/derek-zhou/liv/be148051a4caa11621f2e169859efab8e4fb842b/assets/static/inbox.jpg -------------------------------------------------------------------------------- /assets/static/phone_screen.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/derek-zhou/liv/be148051a4caa11621f2e169859efab8e4fb842b/assets/static/phone_screen.jpg -------------------------------------------------------------------------------- /assets/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /assets/static/search_dialog.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/derek-zhou/liv/be148051a4caa11621f2e169859efab8e4fb842b/assets/static/search_dialog.jpg -------------------------------------------------------------------------------- /assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | theme: { 3 | screens: { 4 | sm: '512px', 5 | md: '768px', 6 | lg: '1024px', 7 | xl: '1280px', 8 | }, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | # Configures the endpoint 11 | config :liv, LivWeb.Endpoint, 12 | url: [host: "localhost"], 13 | secret_key_base: "4D1+yC63Qxp6AP6f8I54SGPhzvCCe0W0RCQCxsflI20MvGIgP7+KSEKJZp8u1W52", 14 | render_errors: [view: LivWeb.ErrorView, accepts: ~w(html json), layout: false], 15 | pubsub_server: Liv.PubSub, 16 | live_view: [signing_salt: "D/E1V/7c"] 17 | 18 | # Configures Elixir's Logger 19 | config :logger, :console, 20 | format: "$time $metadata[$level] $message\n", 21 | metadata: [:request_id] 22 | 23 | # Use Jason for JSON parsing in Phoenix 24 | config :phoenix, :json_library, Jason 25 | 26 | # put the database at the user's home 27 | config :mnesia, dir: '#{System.get_env("HOME")}/.mnesia' 28 | 29 | # go easier for argon 30 | config :argon2_elixir, 31 | m_cost: 14, 32 | parallelism: 1 33 | 34 | # Import environment specific config. This must remain at the bottom 35 | # of this file so it overrides the configuration defined above. 36 | import_config "#{config_env()}.exs" 37 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For development, we disable any cache and enable 4 | # debugging and code reloading. 5 | # 6 | # The watchers configuration can be used to run external 7 | # watchers to your application. For example, we use it 8 | # with webpack to recompile .js and .css sources. 9 | config :liv, LivWeb.Endpoint, 10 | http: [port: 4000], 11 | debug_errors: true, 12 | code_reloader: true, 13 | check_origin: false, 14 | watchers: [ 15 | npm: [ 16 | "run", 17 | "watch", 18 | cd: Path.expand("../assets", __DIR__) 19 | ] 20 | ] 21 | 22 | # Watch static and templates for browser reloading. 23 | config :liv, LivWeb.Endpoint, 24 | reloadable_compilers: [:phoenix, :elixir, :surface], 25 | live_reload: [ 26 | patterns: [ 27 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 28 | ~r"priv/gettext/.*(po)$", 29 | ~r"lib/gara_web/(live|views)/.*(ex)$", 30 | ~r"lib/gara_web/templates/.*(eex)$", 31 | ~r"lib/gara_web/(live|components)/.*(ex|js|sface)$" 32 | ] 33 | ] 34 | 35 | # mailer 36 | config :liv, Liv.Mailer, adapter: Swoosh.Adapters.Local 37 | 38 | # Do not include metadata nor timestamps in development logs 39 | config :logger, 40 | :console, 41 | format: "[$level] $message\n" 42 | 43 | # Set a higher stacktrace during development. Avoid configuring such 44 | # in production as building large stacktraces may be expensive. 45 | config :phoenix, :stacktrace_depth, 20 46 | 47 | # Initialize plugs at runtime for faster development compilation 48 | config :phoenix, :plug_init_mode, :runtime 49 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Do not print debug messages in production 4 | config :logger, level: :info 5 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | if config_env() == :prod do 4 | # In this file, we load production configuration and secrets 5 | # from environment variables. You can also hardcode secrets, 6 | # although such is generally not recommended and you have to 7 | # remember to add this file to your .gitignore. 8 | 9 | secret_key_base = 10 | System.get_env("SECRET_KEY_BASE") || 11 | raise """ 12 | environment variable SECRET_KEY_BASE is missing. 13 | You can generate one by calling: mix phx.gen.secret 14 | """ 15 | 16 | hostname = 17 | case System.get_env("MAIL_HOST") do 18 | nil -> 19 | {str, _} = System.cmd("hostname", ["-f"]) 20 | String.trim(str) 21 | 22 | str -> 23 | str 24 | end 25 | 26 | # the local SMTP is the way that make the most sense 27 | config :liv, Liv.Mailer, 28 | adapter: Swoosh.Adapters.SMTP, 29 | relay: "localhost" 30 | 31 | config :liv, LivWeb.Endpoint, 32 | url: [host: hostname, scheme: "https", port: 443, path: "/#{System.get_env("USER")}"], 33 | cache_static_manifest: "priv/static/cache_manifest.json", 34 | http: [ 35 | ip: {127, 0, 0, 1}, 36 | port: String.to_integer(System.get_env("PORT") || "4001") 37 | ], 38 | secret_key_base: secret_key_base, 39 | server: true 40 | end 41 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # We don't run a server during test. If one is required, 4 | # you can enable the server option below. 5 | config :liv, LivWeb.Endpoint, 6 | http: [port: 4002], 7 | server: false 8 | 9 | # Print only warnings and errors during test 10 | config :logger, level: :warn 11 | -------------------------------------------------------------------------------- /lib/liv.ex: -------------------------------------------------------------------------------- 1 | defmodule Liv do 2 | @moduledoc """ 3 | Liv keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /lib/liv/address_vault.ex: -------------------------------------------------------------------------------- 1 | defmodule Liv.Correspondent do 2 | use Memento.Table, attributes: [:addr, :name, :mails] 3 | end 4 | 5 | defmodule Liv.AddressBookEntry do 6 | defstruct addr: nil, name: nil, first: nil, last: nil, count: 0 7 | end 8 | 9 | defmodule Liv.AddressVault do 10 | @moduledoc """ 11 | I keep track of addresses used in the system 12 | """ 13 | 14 | require Logger 15 | alias Liv.{Configer, Correspondent, AddressBookEntry, MailClient} 16 | 17 | @doc """ 18 | add an email address to the database 19 | """ 20 | def add(name, addr) do 21 | Memento.transaction!(fn -> 22 | case Memento.Query.read(Correspondent, addr) do 23 | nil -> 24 | Memento.Query.write(%Correspondent{name: name, addr: addr, mails: []}) 25 | 26 | _ -> 27 | :ok 28 | end 29 | end) 30 | 31 | :ok 32 | end 33 | 34 | @doc """ 35 | remove an email addr from the database 36 | """ 37 | def remove(addr) do 38 | Memento.transaction!(fn -> 39 | Memento.Query.delete(Correspondent, addr) 40 | end) 41 | 42 | :ok 43 | end 44 | 45 | @doc """ 46 | return a list of email addresses that contains the string 47 | """ 48 | def start_with(str) do 49 | list = 50 | Memento.transaction!(fn -> 51 | Memento.Query.all(Correspondent) 52 | end) 53 | 54 | list 55 | |> Enum.map(fn %Correspondent{addr: addr, name: name} -> [name | addr] end) 56 | |> Enum.filter(fn [name | addr] -> 57 | cond do 58 | String.starts_with?(addr, str) -> true 59 | name == nil -> false 60 | String.starts_with?(name, str) -> true 61 | true -> false 62 | end 63 | end) 64 | end 65 | 66 | @doc """ 67 | return a list of correspondents from the address book 68 | """ 69 | def all_entries() do 70 | my_addresses = MapSet.new(Configer.default(:my_addresses)) 71 | 72 | Memento.transaction!(fn -> 73 | Memento.Query.all(Correspondent) 74 | end) 75 | |> Enum.reject(&MapSet.member?(my_addresses, &1.addr)) 76 | |> Enum.map(fn %Correspondent{addr: addr, name: name} -> 77 | dates = MailClient.dates_from(addr) 78 | 79 | %AddressBookEntry{ 80 | addr: addr, 81 | name: name, 82 | last: List.last(dates), 83 | first: List.first(dates), 84 | count: length(dates) 85 | } 86 | end) 87 | end 88 | 89 | @doc """ 90 | install mnesia in the node 91 | """ 92 | def install!() do 93 | nodes = [node()] 94 | 95 | Memento.stop() 96 | Memento.Schema.create(nodes) 97 | Memento.start() 98 | Memento.Table.create!(Liv.Correspondent, disc_copies: nodes) 99 | end 100 | 101 | @doc """ 102 | migrate old data from the configer 103 | """ 104 | def migrate!() do 105 | table = Configer.default(:saved_addresses) 106 | 107 | Memento.Transaction.execute_sync!(fn -> 108 | Enum.each(table, fn [name | addr] -> 109 | Logger.info("Saving \"#{name}\" <#{addr}>") 110 | Memento.Query.write(%Correspondent{name: name, addr: addr, mails: []}) 111 | end) 112 | end) 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/liv/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Liv.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | require Logger 8 | 9 | def start(_type, _args) do 10 | children = [ 11 | # Start the Telemetry supervisor 12 | LivWeb.Telemetry, 13 | # Start the PubSub system 14 | {Phoenix.PubSub, name: Liv.PubSub}, 15 | # configer to persist configuration 16 | {:self_configer, name: Liv.Configer}, 17 | # draft server 18 | Liv.DraftServer, 19 | # the delay marker 20 | Liv.DelayMarker, 21 | # the orbit gen server 22 | Liv.Orbit, 23 | # Start the Endpoint (http/https) 24 | LivWeb.Endpoint 25 | # Start a worker by calling: Liv.Worker.start_link(arg) 26 | # {Liv.Worker, arg} 27 | ] 28 | 29 | Application.put_env(:maildir_commander, :housekeeper, {Liv.MailClient, :archive_job, []}) 30 | 31 | Application.put_env( 32 | :maildir_commander, 33 | :put_pasteboard, 34 | {Liv.DraftServer, :put_pasteboard, []} 35 | ) 36 | 37 | Application.put_env( 38 | :maildir_commander, 39 | :get_pasteboard, 40 | {Liv.DraftServer, :get_pasteboard, []} 41 | ) 42 | 43 | Application.put_env( 44 | :maildir_commander, 45 | :send_draft, 46 | {Liv.MailClient, :send_draft, []} 47 | ) 48 | 49 | Application.put_env( 50 | :maildir_commander, 51 | :notify_new_mail, 52 | {Liv.MailClient, :notify_new_mail, []} 53 | ) 54 | 55 | try do 56 | Liv.AddressVault.install!() 57 | rescue 58 | _e in Memento.AlreadyExistsError -> 59 | Logger.info("Tables in Mnesia already created") 60 | end 61 | 62 | LivWeb.Guardian.init() 63 | # See https://hexdocs.pm/elixir/Supervisor.html 64 | # for other strategies and supported options 65 | opts = [strategy: :one_for_one, name: Liv.Supervisor] 66 | Supervisor.start_link(children, opts) 67 | end 68 | 69 | # Tell Phoenix to update the endpoint configuration 70 | # whenever the application is updated. 71 | def config_change(changed, _new, removed) do 72 | LivWeb.Endpoint.config_change(changed, removed) 73 | :ok 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/liv/configer.ex: -------------------------------------------------------------------------------- 1 | defmodule Liv.Configer do 2 | @app :liv 3 | 4 | alias :self_configer, as: SelfConfiger 5 | 6 | @doc """ 7 | load configuration into user data format 8 | """ 9 | def default(:my_address), do: default_value(:my_address, [nil | "you@example.com"]) 10 | def default(:my_addresses), do: default_value(:my_addresses, ["you@example.com"]) 11 | def default(:my_email_lists), do: default_value(:my_email_lists, []) 12 | def default(:saved_addresses), do: default_value(:saved_addresses, []) 13 | def default(:archive_days), do: default_value(:archive_days, 30) 14 | def default(:archive_maildir), do: default_value(:archive_maildir, "/.Archive") 15 | def default(:orbit_api_key), do: default_value(:orbit_api_key, "") 16 | def default(:orbit_workspace), do: default_value(:orbit_workspace, "") 17 | def default(:token_ttl), do: default_value(:token_ttl, 30 * 24 * 3600) 18 | 19 | def default(:remote_mail_boxes) do 20 | :remote_mail_boxes 21 | |> default_value([]) 22 | |> Enum.map(&Map.new(&1)) 23 | end 24 | 25 | def default(:sending_method) do 26 | config = Application.get_env(@app, Liv.Mailer) 27 | data = %{username: "", password: "", hostname: "", api_key: ""} 28 | 29 | case config[:adapter] do 30 | Swoosh.Adapters.Sendgrid -> 31 | {:sendgrid, %{data | api_key: config[:api_key]}} 32 | 33 | Swoosh.Adapters.SMTP -> 34 | case config[:relay] do 35 | "localhost" -> 36 | {:local, data} 37 | 38 | hostname -> 39 | {:remote, 40 | %{ 41 | data 42 | | hostname: hostname, 43 | username: config[:username], 44 | password: config[:password] 45 | }} 46 | end 47 | 48 | _ -> 49 | {:local, data} 50 | end 51 | end 52 | 53 | @doc """ 54 | serialize user configration format into application format 55 | """ 56 | def update_sending_method(mod, :local, _data) do 57 | SelfConfiger.set_env(mod, Liv.Mailer, adapter: Swoosh.Adapters.SMTP, relay: "localhost") 58 | end 59 | 60 | def update_sending_method(mod, :remote, %{ 61 | username: username, 62 | password: password, 63 | hostname: hostname 64 | }) do 65 | SelfConfiger.set_env(mod, Liv.Mailer, 66 | adapter: Swoosh.Adapters.SMTP, 67 | relay: hostname, 68 | username: username, 69 | password: password, 70 | ssl: true, 71 | tls: :always, 72 | auth: :always, 73 | port: 587 74 | ) 75 | end 76 | 77 | def update_sending_method(mod, :sendgrid, %{api_key: api_key}) do 78 | SelfConfiger.set_env(mod, Liv.Mailer, 79 | adapter: Swoosh.Adapters.Sendgrid, 80 | api_key: api_key 81 | ) 82 | end 83 | 84 | @doc """ 85 | update the remote mail boxes 86 | """ 87 | def update_remote_mail_boxes(mod, boxes) do 88 | SelfConfiger.set_env(mod, :remote_mail_boxes, Enum.map(boxes, &Keyword.new(&1))) 89 | end 90 | 91 | defp default_value(key, default), do: Application.get_env(@app, key, default) 92 | end 93 | -------------------------------------------------------------------------------- /lib/liv/delay_marker.ex: -------------------------------------------------------------------------------- 1 | defmodule Liv.DelayMarker do 2 | use GenServer 3 | 4 | require Logger 5 | 6 | alias :maildir_commander, as: MaildirCommander 7 | alias Phoenix.PubSub 8 | 9 | @poll_interval 3_600_000 10 | 11 | @doc false 12 | def start_link(args) do 13 | GenServer.start_link(__MODULE__, args, name: __MODULE__) 14 | end 15 | 16 | @doc """ 17 | mark a message of this flag at some time later. 18 | The default flag is -S, which will make the message unread again 19 | """ 20 | def flag(docid, seconds, flag \\ "-S") do 21 | now = System.convert_time_unit(System.monotonic_time(), :native, :second) 22 | GenServer.cast(__MODULE__, {:flag, docid, now + seconds, flag}) 23 | end 24 | 25 | @doc ~S""" 26 | ping the server so all cast finished 27 | """ 28 | def ping() do 29 | GenServer.call(__MODULE__, :ping) 30 | end 31 | 32 | defp flag_it(docid, flag) do 33 | case MaildirCommander.flag(docid, flag) do 34 | {:ok, m} -> 35 | # broadcast the event 36 | PubSub.local_broadcast(Liv.PubSub, "messages", {:seen_message, docid, m}) 37 | 38 | {:error, msg} -> 39 | Logger.warn("docid: #{docid} #{msg}") 40 | end 41 | end 42 | 43 | @impl true 44 | def init(_) do 45 | Process.send_after(self(), :poll, @poll_interval) 46 | {:ok, []} 47 | end 48 | 49 | @impl true 50 | def terminate(_reason, queue) do 51 | # flag everthing regardless time 52 | Enum.each(queue, fn {docid, _at, flag} -> flag_it(docid, flag) end) 53 | end 54 | 55 | @impl true 56 | def handle_cast({:flag, docid, at, flag}, queue) do 57 | {:noreply, insert_queue(queue, docid, at, flag)} 58 | end 59 | 60 | @impl true 61 | def handle_call(:ping, _from, state) do 62 | {:reply, :ok, state} 63 | end 64 | 65 | @impl true 66 | def handle_info(:poll, []) do 67 | Process.send_after(self(), :poll, @poll_interval) 68 | {:noreply, []} 69 | end 70 | 71 | @impl true 72 | def handle_info(:poll, [{docid, at, flag} | tail] = queue) do 73 | now = System.convert_time_unit(System.monotonic_time(), :native, :second) 74 | 75 | cond do 76 | at < now -> 77 | flag_it(docid, flag) 78 | send(self(), :poll) 79 | {:noreply, tail} 80 | 81 | true -> 82 | limit = (at + 1 - now) * 1000 83 | limit = if limit > @poll_interval, do: @poll_interval, else: limit 84 | Process.send_after(self(), :poll, limit) 85 | {:noreply, queue} 86 | end 87 | end 88 | 89 | defp insert_queue([], docid, at, flag), do: [{docid, at, flag}] 90 | 91 | defp insert_queue([{_, h_at, _} = head | tail], docid, at, flag) do 92 | cond do 93 | h_at < at -> [head | insert_queue(tail, docid, at, flag)] 94 | true -> [{docid, at, flag}, head | tail] 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/liv/draft_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Liv.DraftServer do 2 | use GenServer 3 | alias Phoenix.PubSub 4 | alias :bbmustache, as: BBMustache 5 | alias Liv.{Sanitizer, Parser} 6 | alias HtmlSanitizeEx.Scrubber 7 | 8 | defstruct [:subject, :body, :msgid, recipients: [], references: []] 9 | 10 | @doc """ 11 | get draft 12 | """ 13 | def get_draft() do 14 | GenServer.call(__MODULE__, :get_draft) 15 | end 16 | 17 | @doc """ 18 | get the body text as a pasteboard 19 | """ 20 | def get_pasteboard() do 21 | {_, _, body, _, _} = GenServer.call(__MODULE__, :get_draft) 22 | body || "" 23 | end 24 | 25 | @doc """ 26 | put the draft 27 | """ 28 | def put_draft(subject, recipients, body, msgid \\ nil, refs \\ []) do 29 | # broadcast the event 30 | PubSub.local_broadcast_from( 31 | Liv.PubSub, 32 | self(), 33 | "messages", 34 | {:draft_update, subject, recipients, body} 35 | ) 36 | 37 | GenServer.cast(__MODULE__, {:put_draft, subject, recipients, body, msgid, refs}) 38 | end 39 | 40 | @doc """ 41 | clear the draft 42 | """ 43 | def clear_draft(), do: put_draft(nil, [], nil, nil, []) 44 | 45 | @doc """ 46 | put the text into the body of draft 47 | """ 48 | def put_pasteboard(subject, text), do: put_draft(subject, [], text, nil, []) 49 | 50 | @doc """ 51 | return the text of the draft, draft can be html or markdown, depends on the first char 52 | for html draft, there is no text. for markdown draft, the text is markdown itself 53 | """ 54 | def text(<<"<", _::binary>>), do: "" 55 | def text(t), do: t 56 | 57 | @doc """ 58 | return the html of the draft, draft can be html or markdown, depends on the first char. 59 | Second argument is optional, a map of variable substitution in case of html draft 60 | html draft is a mustache template, to be render with the map 61 | """ 62 | def html(draft, map \\ %{}) 63 | def html(<<"<", _::binary>> = draft, map), do: BBMustache.render(draft, map, key_type: :atom) 64 | 65 | def html(draft, _map) do 66 | try do 67 | Md.generate(draft, Parser, format: :none) 68 | rescue 69 | _e -> 70 | "illegal Markdown syntax" 71 | end 72 | end 73 | 74 | @doc """ 75 | return a version html that is safe to be embedded in our page. 76 | """ 77 | def safe_html(html), do: Scrubber.scrub(html, Sanitizer) 78 | 79 | @doc false 80 | def start_link(args) do 81 | GenServer.start_link(__MODULE__, args, name: __MODULE__) 82 | end 83 | 84 | @impl true 85 | def init(_) do 86 | {:ok, %__MODULE__{}} 87 | end 88 | 89 | @impl true 90 | def handle_cast({:put_draft, subject, recipients, body, msgid, refs}, state) do 91 | {:noreply, 92 | %{ 93 | state 94 | | subject: subject, 95 | recipients: recipients, 96 | body: body, 97 | msgid: msgid, 98 | references: refs 99 | }} 100 | end 101 | 102 | @impl true 103 | def handle_call( 104 | :get_draft, 105 | _from, 106 | %__MODULE__{ 107 | subject: subject, 108 | recipients: recipients, 109 | body: body, 110 | msgid: msgid, 111 | references: refs 112 | } = state 113 | ) do 114 | {:reply, {subject, recipients, body, msgid, refs}, state} 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /lib/liv/mail_client.ex: -------------------------------------------------------------------------------- 1 | defmodule Liv.MailClient do 2 | require Logger 3 | alias Liv.{Configer, Mailer, AddressVault, DraftServer} 4 | alias Phoenix.PubSub 5 | 6 | @moduledoc """ 7 | The core MailClient state mamangment abstracted from the UI. 8 | """ 9 | 10 | alias :maildir_commander, as: MaildirCommander 11 | alias :mc_tree, as: MCTree 12 | 13 | defstruct [ 14 | :tree, 15 | mails: %{}, 16 | docid: 0, 17 | ref: nil 18 | ] 19 | 20 | @doc """ 21 | reindex in the background 22 | """ 23 | def reindex() do 24 | spawn_link(MaildirCommander, :index, []) 25 | end 26 | 27 | @doc """ 28 | wake up the mc process. It is an optimization 29 | """ 30 | def snooze(), do: MaildirCommander.snooze() 31 | 32 | @doc """ 33 | run a query. query can be a query string or a docid integer 34 | """ 35 | def new_search(query) do 36 | # we piggy back pop to new mail query 37 | if String.match?(query, ~r/flag:unread/), do: pop_all() 38 | 39 | case MaildirCommander.find( 40 | query, 41 | true, 42 | :":date", 43 | true, 44 | false, 45 | String.match?(query, ~r/^msgid:/) 46 | ) do 47 | {:error, msg} -> 48 | raise(msg) 49 | 50 | {:ok, tree, mails} -> 51 | %__MODULE__{tree: MCTree.collapse(tree), mails: mails} 52 | end 53 | end 54 | 55 | @doc """ 56 | return the replied mails from an addr 57 | """ 58 | def dates_from(addr) do 59 | case MaildirCommander.find( 60 | "from:#{addr} flag:replied", 61 | false, 62 | :":date", 63 | false, 64 | true, 65 | false 66 | ) do 67 | {:error, msg} -> 68 | raise(msg) 69 | 70 | {:ok, tree, mails} -> 71 | tree 72 | |> MCTree.root_list() 73 | |> Enum.map(fn docid -> mails |> Map.fetch!(docid) |> Map.fetch!(:date) end) 74 | end 75 | end 76 | 77 | @doc """ 78 | close a message by setting docid to 0 and mark the old message seen 79 | """ 80 | def close(nil), do: nil 81 | def close(%__MODULE__{docid: 0} = mc), do: mc 82 | 83 | def close(%__MODULE__{mails: mails, docid: docid} = mc) do 84 | case Map.get(mails, docid) do 85 | nil -> 86 | %{mc | docid: 0} 87 | 88 | %{flags: flags} -> 89 | case Enum.member?(flags, :seen) do 90 | true -> 91 | %{mc | docid: 0} 92 | 93 | false -> 94 | case MaildirCommander.flag(docid, "+S") do 95 | {:ok, m} -> 96 | # broadcast the event 97 | PubSub.local_broadcast_from( 98 | Liv.PubSub, 99 | self(), 100 | "messages", 101 | {:seen_message, docid, m} 102 | ) 103 | 104 | %{mc | mails: %{mails | docid => m}, docid: 0} 105 | 106 | {:error, msg} -> 107 | Logger.warn("docid: #{docid} #{msg}") 108 | reindex() 109 | %{mc | docid: 0} 110 | end 111 | end 112 | end 113 | end 114 | 115 | @doc """ 116 | open a message. if the mc is nil, make a minimum mc first. 117 | then stream the content of the mail 118 | """ 119 | def open(nil, docid) do 120 | case MaildirCommander.view(docid) do 121 | {:error, msg} -> 122 | Logger.warn("docid: #{docid} not found: #{msg}") 123 | reindex() 124 | nil 125 | 126 | {:ok, %{path: path} = meta} -> 127 | Logger.debug("streaming #{path}") 128 | 129 | case MaildirCommander.stream_mail(path) do 130 | {:error, reason} -> 131 | Logger.warn("docid: #{docid} path: #{path} not found: #{reason}") 132 | reindex() 133 | nil 134 | 135 | {:ok, ref} -> 136 | %__MODULE__{ 137 | tree: MCTree.single(docid), 138 | mails: %{docid => meta}, 139 | docid: docid, 140 | ref: ref 141 | } 142 | end 143 | end 144 | end 145 | 146 | def open(mc, docid) do 147 | %__MODULE__{mails: mails} = mc = close(mc) 148 | 149 | case Map.get(mails, docid) do 150 | nil -> 151 | mc 152 | 153 | %{path: path} -> 154 | Logger.debug("streaming #{path}") 155 | 156 | case MaildirCommander.stream_mail(path) do 157 | {:error, reason} -> 158 | Logger.warn("docid: #{docid} path: #{path} not found: #{reason}") 159 | reindex() 160 | %{mc | docid: docid, ref: nil} 161 | 162 | {:ok, ref} -> 163 | %{mc | docid: docid, ref: ref} 164 | end 165 | end 166 | end 167 | 168 | @doc """ 169 | getter of a specific mail metadata 170 | """ 171 | def mail_meta(nil, _docid), do: nil 172 | def mail_meta(%__MODULE__{mails: mails}, docid), do: Map.get(mails, docid) 173 | 174 | @doc """ 175 | setter of a specific mail metadata. nil is delete 176 | """ 177 | def set_meta(nil, _docid, _meta), do: nil 178 | 179 | def set_meta(%__MODULE__{mails: mails} = mc, docid, nil) do 180 | %{mc | mails: Map.delete(mails, docid)} 181 | end 182 | 183 | def set_meta(%__MODULE__{mails: mails} = mc, docid, meta) do 184 | %{mc | mails: Map.replace(mails, docid, meta)} 185 | end 186 | 187 | @doc """ 188 | the query that get one mail 189 | """ 190 | def solo_query(%__MODULE__{mails: mails, docid: docid}), do: "msgid:#{mails[docid].msgid}" 191 | 192 | @doc """ 193 | the query that get all mails from sender 194 | """ 195 | def from_query(%__MODULE__{mails: mails, docid: docid}), do: "from:#{tl(mails[docid].from)}" 196 | 197 | @doc """ 198 | getter of the text content in quote 199 | """ 200 | def quoted_text(_, nil), do: nil 201 | def quoted_text(_, ""), do: "" 202 | def quoted_text(nil, text), do: "> #{text}\n" 203 | 204 | def quoted_text(%__MODULE__{mails: mails, docid: docid}, text) do 205 | case Map.get(mails, docid) do 206 | nil -> 207 | "" 208 | 209 | meta -> 210 | {:ok, date} = DateTime.from_unix(meta.date) 211 | 212 | IO.chardata_to_string([ 213 | "On #{date}, #{hd(meta.from)} wrote:\n", 214 | text 215 | |> String.split(~r/\n/) 216 | |> Enum.map(fn str -> "> #{str}\n" end) 217 | ]) 218 | end 219 | end 220 | 221 | @doc """ 222 | getter of mail counts 223 | """ 224 | def mail_count(%__MODULE__{tree: tree}) do 225 | MCTree.traverse(fn _ -> :ok end, tree) 226 | end 227 | 228 | @doc """ 229 | getter of unread mail counts 230 | """ 231 | def unread_count(%__MODULE__{tree: tree, mails: mails}) do 232 | MCTree.traverse( 233 | fn docid -> 234 | case Map.get(mails, docid) do 235 | %{flags: flags} -> 236 | case Enum.member?(flags, :seen) do 237 | true -> nil 238 | false -> :ok 239 | end 240 | 241 | _ -> 242 | nil 243 | end 244 | end, 245 | tree 246 | ) 247 | end 248 | 249 | @doc """ 250 | test if a docid is in the client 251 | """ 252 | def contains?(%__MODULE__{mails: mails}, docid) do 253 | Map.has_key?(mails, docid) 254 | end 255 | 256 | @doc """ 257 | predicate of first 258 | """ 259 | def is_first(%__MODULE__{tree: tree}, docid), do: docid == MCTree.first(tree) 260 | 261 | @doc """ 262 | predicate of last 263 | """ 264 | def is_last(%__MODULE__{tree: tree}, docid), do: docid == MCTree.last(tree) 265 | 266 | @doc """ 267 | getter of the next docid 268 | """ 269 | def next(%__MODULE__{tree: tree}, docid) do 270 | case MCTree.next(docid, tree) do 271 | :undefined -> nil 272 | id -> id 273 | end 274 | end 275 | 276 | @doc """ 277 | getter of the previous docid 278 | """ 279 | def previous(%__MODULE__{tree: tree}, docid) do 280 | case MCTree.prev(docid, tree) do 281 | :undefined -> nil 282 | id -> id 283 | end 284 | end 285 | 286 | @doc """ 287 | getter of all children of docid. pass nil get the root list 288 | """ 289 | def children_of(nil, _), do: [] 290 | def children_of(tree, docid), do: MCTree.children(docid || :undefined, tree) 291 | 292 | @doc """ 293 | getter of the tree 294 | """ 295 | def tree_of(nil), do: nil 296 | def tree_of(%__MODULE__{tree: tree}), do: tree 297 | 298 | @doc """ 299 | getter of mails 300 | """ 301 | def mails_of(nil), do: %{} 302 | def mails_of(%__MODULE__{mails: mails}), do: mails 303 | 304 | @doc """ 305 | getter of default to and subject from mailto: link. everything else are ignored for now 306 | """ 307 | def parse_mailto(mailto) do 308 | case URI.parse(mailto) do 309 | %URI{scheme: "mailto", path: tos, query: nil} -> 310 | {get_recipients(tos), nil, nil} 311 | 312 | %URI{scheme: "mailto", path: tos, query: query} -> 313 | query = query |> URI.query_decoder() |> Enum.to_list() 314 | 315 | {get_recipients(tos), :proplists.get_value("subject", query, nil), 316 | :proplists.get_value("body", query, nil)} 317 | 318 | %URI{path: nil} -> 319 | {[], nil, nil} 320 | 321 | %URI{path: to_addr} -> 322 | {get_recipients([to_addr]), nil, nil} 323 | end 324 | end 325 | 326 | defp get_recipients(nil), do: [] 327 | defp get_recipients([]), do: [] 328 | 329 | defp get_recipients(str) when is_binary(str) do 330 | str |> String.split(~r/\s*,\s*/) |> get_recipients() 331 | end 332 | 333 | defp get_recipients(tos) do 334 | [name | addr] = Configer.default(:my_address) 335 | 336 | bccs = 337 | case Enum.member?(tos, addr) do 338 | true -> [] 339 | false -> [{:bcc, [name | addr]}] 340 | end 341 | 342 | tos = Enum.map(tos, fn addr -> {:to, [nil | addr]} end) 343 | 344 | tos ++ bccs 345 | end 346 | 347 | @doc """ 348 | default recipients 349 | """ 350 | def default_recipients(), do: [{:to, [nil | ""]}, {:bcc, Configer.default(:my_address)}] 351 | 352 | @doc """ 353 | getter of default to, cc and bcc for this email 354 | """ 355 | def default_recipients(mc, to_addr) do 356 | addr_map = addresses_map(mc) 357 | 358 | List.flatten([ 359 | {:to, default_to(addr_map, to_addr)}, 360 | Enum.map(default_cc(addr_map, to_addr), &{:cc, &1}), 361 | Enum.map( 362 | default_bcc(addr_map, to_addr, Configer.default(:my_address)), 363 | &{:bcc, &1} 364 | ) 365 | ]) 366 | end 367 | 368 | @doc """ 369 | normalize recipients, in the orfer of to, cc, bcc 370 | """ 371 | def normalize_recipients(recipients) do 372 | List.flatten([ 373 | Enum.filter(recipients, fn {type, _} -> type == :to end), 374 | Enum.filter(recipients, fn {type, _} -> type == :cc end), 375 | Enum.filter(recipients, fn {type, _} -> type == :bcc end) 376 | ]) 377 | end 378 | 379 | @doc """ 380 | parse an addr to a {type, [name | addr]} tuple 381 | """ 382 | def parse_recipient("to", addr), do: {:to, parse_addr(addr)} 383 | def parse_recipient("cc", addr), do: {:cc, parse_addr(addr)} 384 | def parse_recipient("bcc", addr), do: {:bcc, parse_addr(addr)} 385 | def parse_recipient("", _), do: {nil, [nil | ""]} 386 | 387 | @doc """ 388 | send a mail 389 | """ 390 | def send_mail(subject, recipients, text, msgid \\ nil, references \\ [], atts \\ []) 391 | 392 | def send_mail("", _, _, _, _, _), do: {:error, "no subject"} 393 | def send_mail(_, _, "", _, _, _), do: {:error, "no text"} 394 | 395 | def send_mail(subject, [{:to, _} | _] = recipients, text, msgid, references, atts) do 396 | import Swoosh.Email 397 | 398 | try do 399 | new() 400 | |> from(addr_to_swoosh(Configer.default(:my_address))) 401 | |> subject(subject) 402 | |> add_recipients(recipients) 403 | |> add_references(msgid, references) 404 | |> header("X-Mailer", "LivMail 0.1.0") 405 | |> text_body(DraftServer.text(text)) 406 | |> html_body(DraftServer.html(text)) 407 | |> add_attachments(atts) 408 | |> Mailer.deliver() 409 | 410 | DraftServer.clear_draft() 411 | rescue 412 | RuntimeError -> {:error, "deliver failed"} 413 | end 414 | end 415 | 416 | def send_mail(_, _, _, _, _, _), do: {:error, "no To: recipient"} 417 | 418 | @doc """ 419 | Send the html draft from draft server, draft is kept for future usage 420 | """ 421 | def send_draft(name, addr) do 422 | import Swoosh.Email 423 | 424 | case DraftServer.get_draft() do 425 | {nil, _, _, _, _} -> 426 | {:error, "no subject"} 427 | 428 | {_, _, nil, _, _} -> 429 | {:error, "no draft"} 430 | 431 | {subject, _recipients, draft, msgid, references} -> 432 | try do 433 | new() 434 | |> from(addr_to_swoosh(Configer.default(:my_address))) 435 | |> subject(subject) 436 | |> to({name, addr}) 437 | |> add_references(msgid, references) 438 | |> header("X-Mailer", "LivMail 0.1.0") 439 | |> text_body(DraftServer.text(draft)) 440 | |> html_body(DraftServer.html(draft, %{name: name, addr: addr})) 441 | |> Mailer.deliver() 442 | 443 | DraftServer.put_draft(subject, [to: [name | addr]], draft, msgid, references) 444 | rescue 445 | RuntimeError -> {:error, "deliver failed"} 446 | end 447 | end 448 | end 449 | 450 | @doc """ 451 | getter of the default reply subject 452 | """ 453 | def reply_subject(%__MODULE__{docid: docid, mails: mails}) when docid > 0 do 454 | case mails[docid].subject do 455 | "" -> 456 | "" 457 | 458 | sub -> 459 | case Regex.run(~r/^re:\s*(.*)/i, sub) do 460 | nil -> "Re: " <> sub 461 | [_, ""] -> "" 462 | [_, str] -> "Re: " <> str 463 | end 464 | end 465 | end 466 | 467 | def reply_subject(_), do: "" 468 | 469 | @doc """ 470 | getter of to name from relevent addresses 471 | """ 472 | def find_address(mc, to_addr) do 473 | [Map.get(addresses_map(mc), to_addr) | to_addr] 474 | end 475 | 476 | @doc """ 477 | receive parts into data structure. 478 | """ 479 | def receive_part(%__MODULE__{ref: ref}, ref, :eof), do: :eof 480 | 481 | def receive_part(%__MODULE__{ref: ref}, ref, %{content_type: "text/plain", body: body}) do 482 | {:text, body} 483 | end 484 | 485 | def receive_part(%__MODULE__{ref: ref}, ref, %{content_type: "text/html", body: body}) do 486 | {:html, body} 487 | end 488 | 489 | def receive_part(%__MODULE__{ref: ref}, ref, %{ 490 | content_type: type, 491 | disposition_params: %{"filename" => filename}, 492 | body: body 493 | }) do 494 | {:attachment, filename, type, body} 495 | end 496 | 497 | def receive_part(%__MODULE__{ref: ref}, ref, %{ 498 | content_type: type, 499 | content_type_params: %{"name" => filename}, 500 | disposition: "attachment", 501 | body: body 502 | }) do 503 | {:attachment, filename, type, body} 504 | end 505 | 506 | def receive_part(_, _, _), do: nil 507 | 508 | @doc """ 509 | archiving job. Always return :ok. will log and do side effects 510 | """ 511 | def archive_job() do 512 | case MaildirCommander.find_all("maildir:/", true, :":date", false, false, false) do 513 | {:error, reason} -> 514 | Logger.warn("query error: #{reason}") 515 | 516 | {:ok, tree, messages} -> 517 | horizon = System.system_time(:second) - Configer.default(:archive_days) * 86400 518 | archive = String.to_charlist(Configer.default(:archive_maildir)) 519 | my_addresses = MapSet.new(Configer.default(:my_addresses)) 520 | is_recent = &is_recent(Map.get(messages, &1), horizon) 521 | is_important = &is_important(Map.get(messages, &1), my_addresses) 522 | 523 | {mark_list, unmark_list} = 524 | tree 525 | |> MCTree.root_list() 526 | |> Enum.split_with(&MCTree.any(is_important, &1, tree)) 527 | 528 | marked = mark_conversations(mark_list, tree, messages) 529 | unmarked = unmark_conversations(unmark_list, tree, messages) 530 | Logger.notice("#{marked} mails marked, #{unmarked} mails unmarked") 531 | archive_list = Enum.reject(mark_list, &MCTree.any(is_recent, &1, tree)) 532 | junk_list = Enum.reject(unmark_list, &MCTree.any(is_recent, &1, tree)) 533 | 534 | Logger.notice( 535 | "#{length(archive_list)} conversations to be archived, #{length(junk_list)} conversation to be deleted" 536 | ) 537 | 538 | case archive do 539 | "" -> 540 | Logger.notice("Archiving disabled") 541 | 542 | _ -> 543 | deleted = delete_conversations(junk_list, tree, messages) 544 | archived = archive_conversations(archive_list, archive, tree, messages) 545 | Logger.notice("Done, #{archived} mails archived, #{deleted} mails deleted") 546 | end 547 | end 548 | 549 | :ok 550 | end 551 | 552 | @doc """ 553 | broadcast new mail arrival 554 | """ 555 | def notify_new_mail(), do: PubSub.local_broadcast(Liv.PubSub, "world", :new_mail) 556 | 557 | defp pop_all() do 558 | :remote_mail_boxes 559 | |> Configer.default() 560 | |> Enum.each(fn %{method: "pop3", username: user, password: pass, hostname: host} -> 561 | MaildirCommander.pop_all(user, pass, host) 562 | end) 563 | end 564 | 565 | defp addresses_map(%__MODULE__{docid: docid, mails: mails}) when docid > 0 do 566 | %{from: from, to: to, cc: cc} = Map.fetch!(mails, docid) 567 | 568 | map = %{tl(from) => hd(from)} 569 | map = Enum.reduce(to, map, fn [n | a], m -> Map.put_new(m, a, n) end) 570 | map = Enum.reduce(cc, map, fn [n | a], m -> Map.put_new(m, a, n) end) 571 | 572 | map 573 | end 574 | 575 | defp addresses_map(_), do: %{} 576 | 577 | # to is whatever I can find from the map 578 | defp default_to(_, "#"), do: [nil | ""] 579 | defp default_to(addr_map, to_addr), do: [Map.get(addr_map, to_addr) | to_addr] 580 | 581 | # cc is addr_map sans to and sans my addresses 582 | defp default_cc(addr_map, to_addr) do 583 | my_addresses = default_set(:my_addresses) 584 | 585 | addr_map 586 | |> Enum.reject(fn {a, _n} -> 587 | a == to_addr || MapSet.member?(my_addresses, a) 588 | end) 589 | |> Enum.map(fn {a, n} -> [n | a] end) 590 | end 591 | 592 | # bcc is my address, unless to is one of my addresses, or a list is invloved 593 | defp default_bcc(_, to_addr, [_ | to_addr]), do: [] 594 | 595 | defp default_bcc(addr_map, _, my_address) do 596 | my_lists = default_set(:my_email_lists) 597 | 598 | if Enum.any?(addr_map, fn {a, _n} -> 599 | MapSet.member?(my_lists, a) 600 | end), 601 | do: [], 602 | else: [my_address] 603 | end 604 | 605 | defp default_set(atom) do 606 | atom 607 | |> Configer.default() 608 | |> MapSet.new() 609 | end 610 | 611 | defp parse_addr(str) do 612 | case Regex.run(~r/(.*)\s+<(.*)>$/, str) do 613 | [_, name, addr] -> [String.trim(name, "\"") | addr] 614 | _ -> [nil | str] 615 | end 616 | end 617 | 618 | defp addr_to_swoosh([nil | addr]), do: addr 619 | defp addr_to_swoosh([name | addr]), do: {name, addr} 620 | 621 | defp add_recipients(email, recipients) do 622 | import Swoosh.Email 623 | 624 | Enum.reduce(recipients, email, fn {type, recipient}, email -> 625 | AddressVault.add(hd(recipient), tl(recipient)) 626 | 627 | case type do 628 | :to -> to(email, addr_to_swoosh(recipient)) 629 | :cc -> cc(email, addr_to_swoosh(recipient)) 630 | :bcc -> bcc(email, addr_to_swoosh(recipient)) 631 | end 632 | end) 633 | end 634 | 635 | defp add_attachments(email, atts) do 636 | import Swoosh.Email 637 | 638 | atts 639 | |> Enum.reverse() 640 | |> Enum.reduce(email, fn {name, _size, data}, mail -> 641 | attachment( 642 | mail, 643 | Swoosh.Attachment.new( 644 | {:data, IO.iodata_to_binary(data)}, 645 | filename: name, 646 | content_type: MIME.from_path(name), 647 | type: :attachment 648 | ) 649 | ) 650 | end) 651 | end 652 | 653 | defp add_references(email, nil, _), do: email 654 | 655 | defp add_references(email, msgid, references) do 656 | import Swoosh.Email 657 | # prevent very long reference chain 658 | references = 659 | references 660 | |> Enum.take(9) 661 | |> Kernel.++([msgid]) 662 | |> Enum.map(fn str -> "<#{str}>" end) 663 | |> Enum.join(" ") 664 | 665 | email 666 | |> header("In-Reply-To", "<#{msgid}>") 667 | |> header("References", references) 668 | end 669 | 670 | defp is_recent(%{date: date}, horizon) when date > horizon, do: true 671 | defp is_recent(%{flags: flags}, _horizon), do: Enum.member?(flags, :unread) 672 | 673 | defp is_important(%{from: [_name | addr]}, my_addresses) do 674 | MapSet.member?(my_addresses, addr) 675 | end 676 | 677 | defp delete_conversations(list, tree, messages) do 678 | MCTree.traverse( 679 | fn docid -> 680 | %{path: path} = Map.get(messages, docid) 681 | Logger.notice("deleting mail (#{docid}) #{path}") 682 | MaildirCommander.delete(docid) 683 | # broadcast the event 684 | PubSub.local_broadcast(Liv.PubSub, "messages", {:delete_message, docid}) 685 | end, 686 | list, 687 | tree 688 | ) 689 | end 690 | 691 | defp archive_conversations(list, archive, tree, messages) do 692 | MCTree.traverse( 693 | fn docid -> 694 | %{path: path} = Map.get(messages, docid) 695 | Logger.notice("archiving mail (#{docid}) #{path}") 696 | MaildirCommander.scrub(path) 697 | {:ok, mail} = MaildirCommander.move(docid, archive) 698 | # broadcast the event 699 | PubSub.local_broadcast(Liv.PubSub, "messages", {:archive_message, docid, mail}) 700 | end, 701 | list, 702 | tree 703 | ) 704 | end 705 | 706 | # the flag replied is used to mark messages for archiving 707 | defp mark_conversations(list, tree, messages) do 708 | MCTree.traverse( 709 | fn docid -> 710 | %{flags: flags} = Map.get(messages, docid) 711 | 712 | unless Enum.member?(flags, :replied) do 713 | Logger.notice("marking mail (#{docid})") 714 | {:ok, mail} = MaildirCommander.flag(docid, "+R") 715 | # broadcast the event 716 | PubSub.local_broadcast(Liv.PubSub, "messages", {:mark_message, docid, mail}) 717 | end 718 | end, 719 | list, 720 | tree 721 | ) 722 | end 723 | 724 | defp unmark_conversations(list, tree, messages) do 725 | MCTree.traverse( 726 | fn docid -> 727 | %{flags: flags} = Map.get(messages, docid) 728 | 729 | if Enum.member?(flags, :replied) do 730 | Logger.notice("unmarking mail (#{docid})") 731 | {:ok, mail} = MaildirCommander.flag(docid, "-R") 732 | # broadcast the event 733 | PubSub.local_broadcast(Liv.PubSub, "messages", {:unmark_message, docid, mail}) 734 | end 735 | end, 736 | list, 737 | tree 738 | ) 739 | end 740 | end 741 | -------------------------------------------------------------------------------- /lib/liv/mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule Liv.Mailer do 2 | use Swoosh.Mailer, otp_app: :liv 3 | end 4 | -------------------------------------------------------------------------------- /lib/liv/orbit.ex: -------------------------------------------------------------------------------- 1 | defmodule Liv.Orbit do 2 | @moduledoc """ 3 | The Orbit gen server 4 | """ 5 | 6 | alias LivWeb.Endpoint 7 | alias LivWeb.Router.Helpers, as: Routes 8 | alias Phoenix.PubSub 9 | alias Liv.Configer 10 | 11 | require Logger 12 | use GenServer 13 | 14 | # client 15 | 16 | @doc false 17 | def start_link(default) when is_list(default) do 18 | GenServer.start_link(__MODULE__, default, name: __MODULE__) 19 | end 20 | 21 | # server 22 | @impl true 23 | def init(_) do 24 | PubSub.subscribe(Liv.PubSub, "messages") 25 | {:ok, []} 26 | end 27 | 28 | @impl true 29 | def handle_info({:mark_message, docid, mail}, state) do 30 | case {Configer.default(:orbit_api_key), Configer.default(:orbit_workspace)} do 31 | {"", _} -> :ok 32 | {_, ""} -> :ok 33 | {api_key, workspace} -> mark_orbit(docid, mail, api_key, workspace) 34 | end 35 | 36 | {:noreply, state} 37 | end 38 | 39 | def handle_info(_, state), do: {:noreply, state} 40 | 41 | defp mark_orbit(docid, mail, api_key, workspace) do 42 | key = to_string(docid) 43 | url = Routes.mail_url(Endpoint, :view, key) 44 | [_name | from] = mail.from 45 | 46 | date = 47 | ~U[1970-01-01 00:00:00Z] 48 | |> DateTime.add(mail.date) 49 | |> DateTime.to_iso8601() 50 | 51 | json_api_post( 52 | "https://app.orbit.love/api/v1/#{workspace}/activities", 53 | %{ 54 | "title" => "new mail", 55 | "description" => mail.subject, 56 | "link" => url, 57 | "link_text" => "mail", 58 | "key" => key, 59 | "activity_type" => "post:created", 60 | "occurred_at" => date, 61 | "identity" => %{ 62 | "source" => "email", 63 | "email" => from 64 | } 65 | }, 66 | api_key, 67 | 1000 68 | ) 69 | end 70 | 71 | defp json_api_post(url, data, api_key, timeout) do 72 | case HTTPoison.post(url, Jason.encode!(data), [ 73 | {"Accept", "application/json"}, 74 | {"Authorization", "Bearer #{api_key}"}, 75 | {"Content-Type", "application/json"} 76 | ]) do 77 | {:error, %HTTPoison.Error{reason: :timeout}} -> 78 | Logger.notice("api call to #{url} timeout. Will retry") 79 | json_api_post(url, data, api_key, next_timeout(timeout)) 80 | 81 | {:error, %HTTPoison.Error{reason: reason}} -> 82 | raise("api call to #{url} failed: #{reason}") 83 | 84 | {:ok, %HTTPoison.Response{status_code: 429}} -> 85 | Logger.notice("api call to #{url} busy. Will retry") 86 | json_api_post(url, data, api_key, next_timeout(timeout)) 87 | 88 | {:ok, %HTTPoison.Response{status_code: code}} when code < 300 -> 89 | Logger.debug("api call to #{url} succeeded with response code: #{code}") 90 | :ok 91 | 92 | {:ok, %HTTPoison.Response{status_code: code, body: body}} -> 93 | Logger.warn("api call #{inspect(data)} failed with response code: #{code}") 94 | Logger.warn("response #{inspect(Jason.decode!(body))}") 95 | :ok 96 | end 97 | end 98 | 99 | defp next_timeout(timeout) when timeout < 1_000_000 do 100 | Process.sleep(timeout) 101 | timeout * 2 102 | end 103 | 104 | defp next_timeout(_timeout) do 105 | Process.sleep(1_000_000) 106 | 1_000_000 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/liv/parser.ex: -------------------------------------------------------------------------------- 1 | defmodule Liv.Parser do 2 | use Md.Parser 3 | 4 | alias Md.Parser.Syntax.Void 5 | 6 | @default_syntax Map.put(Void.syntax(), :settings, Void.settings()) 7 | 8 | @syntax @default_syntax 9 | |> Map.merge(%{ 10 | substitute: [ 11 | {"<", %{text: "<"}}, 12 | {"&", %{text: "&"}} 13 | ], 14 | escape: [ 15 | {<<92>>, %{}} 16 | ], 17 | comment: [ 18 | {""}} 19 | ], 20 | flush: [ 21 | {"---", %{tag: :hr, rewind: :flip_flop}}, 22 | {" \n", %{tag: :br}}, 23 | {"  \n", %{tag: :br}}, 24 | {" \r\n", %{tag: :br}}, 25 | {"  \r\n", %{tag: :br}} 26 | ], 27 | block: [ 28 | {"```", %{tag: [:pre, :code], pop: %{code: :class}}} 29 | ], 30 | shift: [ 31 | {" ", %{tag: [:div, :code], attributes: %{class: "pre"}}} 32 | ], 33 | pair: [ 34 | {"![", 35 | %{ 36 | tag: :img, 37 | closing: "]", 38 | inner_opening: "(", 39 | inner_closing: ")", 40 | outer: {:attribute, {:src, :title}} 41 | }}, 42 | {"!![", 43 | %{ 44 | tag: :figure, 45 | closing: "]", 46 | inner_opening: "(", 47 | inner_closing: ")", 48 | inner_tag: :img, 49 | outer: {:tag, {:figcaption, :src}} 50 | }}, 51 | {"?[", 52 | %{ 53 | tag: :abbr, 54 | closing: "]", 55 | inner_opening: "(", 56 | inner_closing: ")", 57 | outer: {:attribute, :title} 58 | }}, 59 | {"[", 60 | %{ 61 | tag: :a, 62 | closing: "]", 63 | inner_opening: "(", 64 | inner_closing: ")", 65 | disclosure_opening: "[", 66 | disclosure_closing: "]", 67 | outer: {:attribute, :href} 68 | }} 69 | ], 70 | paragraph: [ 71 | {"#", %{tag: :h1}}, 72 | {"##", %{tag: :h2}}, 73 | {"###", %{tag: :h3}}, 74 | {"####", %{tag: :h4}}, 75 | {"#####", %{tag: :h5}}, 76 | {"######", %{tag: :h6}}, 77 | # nested 78 | {">", %{tag: [:blockquote, :p]}} 79 | ], 80 | list: 81 | [ 82 | {"- ", %{tag: :li, outer: :ul}}, 83 | {"* ", %{tag: :li, outer: :ul}}, 84 | {"+ ", %{tag: :li, outer: :ul}} 85 | ] ++ Enum.map(0..9, &{"#{&1}. ", %{tag: :li, outer: :ol}}), 86 | brace: [ 87 | {"*", %{tag: :b}}, 88 | {"_", %{tag: :i}}, 89 | {"**", %{tag: :strong, attributes: %{class: "red"}}}, 90 | {"__", %{tag: :em}}, 91 | {"~", %{tag: :s}}, 92 | {"~~", %{tag: :del}}, 93 | {"``", %{tag: :span, mode: :raw, attributes: %{class: "code-inline"}}}, 94 | {"`", %{tag: :code, mode: :raw, attributes: %{class: "code-inline"}}}, 95 | {"[^", %{closing: "]", tag: :b, mode: :raw}} 96 | ] 97 | }) 98 | end 99 | -------------------------------------------------------------------------------- /lib/liv/sanitizer.ex: -------------------------------------------------------------------------------- 1 | defmodule Liv.Sanitizer do 2 | @moduledoc """ 3 | tweaked version of markdown_html 4 | 5 | Allows basic HTML tags to support user input for writing relatively 6 | plain text with Markdown (GitHub flavoured Markdown supported). 7 | 8 | Technically this is a more relaxed version of the BasicHTML scrubber. 9 | 10 | Does not allow any mailto-links, styling, HTML5 tags, video embeds etc. 11 | """ 12 | 13 | require HtmlSanitizeEx.Scrubber.Meta 14 | alias HtmlSanitizeEx.Scrubber.Meta 15 | 16 | @valid_schemes ["http", "https", "mailto"] 17 | 18 | # Removes any CDATA tags before the traverser/scrubber runs. 19 | Meta.remove_cdata_sections_before_scrub() 20 | 21 | Meta.strip_comments() 22 | 23 | # remove style and script 24 | def scrub({"style", _}), do: "" 25 | def scrub({"script", _}), do: "" 26 | def scrub({"style", _, _}), do: "" 27 | def scrub({"script", _, _}), do: "" 28 | 29 | Meta.allow_tag_with_uri_attributes("a", ["href"], @valid_schemes) 30 | Meta.allow_tag_with_these_attributes("a", ["name", "title"]) 31 | 32 | Meta.allow_tag_with_this_attribute_values("a", "target", ["_blank"]) 33 | 34 | Meta.allow_tag_with_this_attribute_values("a", "rel", [ 35 | "noopener", 36 | "noreferrer" 37 | ]) 38 | 39 | Meta.allow_tag_with_these_attributes("b", []) 40 | Meta.allow_tag_with_these_attributes("blockquote", []) 41 | Meta.allow_tag_with_these_attributes("br", []) 42 | Meta.allow_tag_with_these_attributes("code", ["class"]) 43 | Meta.allow_tag_with_these_attributes("del", []) 44 | Meta.allow_tag_with_these_attributes("em", []) 45 | Meta.allow_tag_with_these_attributes("h1", []) 46 | Meta.allow_tag_with_these_attributes("h2", []) 47 | Meta.allow_tag_with_these_attributes("h3", []) 48 | Meta.allow_tag_with_these_attributes("h4", []) 49 | Meta.allow_tag_with_these_attributes("h5", []) 50 | Meta.allow_tag_with_these_attributes("h6", []) 51 | Meta.allow_tag_with_these_attributes("hr", []) 52 | Meta.allow_tag_with_these_attributes("i", []) 53 | 54 | Meta.allow_tag_with_uri_attributes("img", ["src"], @valid_schemes) 55 | 56 | Meta.allow_tag_with_these_attributes("img", [ 57 | "width", 58 | "height", 59 | "title", 60 | "alt" 61 | ]) 62 | 63 | Meta.allow_tag_with_these_attributes("li", []) 64 | Meta.allow_tag_with_these_attributes("ol", []) 65 | Meta.allow_tag_with_these_attributes("p", []) 66 | Meta.allow_tag_with_these_attributes("pre", []) 67 | Meta.allow_tag_with_these_attributes("span", []) 68 | Meta.allow_tag_with_these_attributes("strong", []) 69 | Meta.allow_tag_with_these_attributes("table", []) 70 | Meta.allow_tag_with_these_attributes("tbody", []) 71 | Meta.allow_tag_with_these_attributes("td", []) 72 | Meta.allow_tag_with_these_attributes("th", []) 73 | Meta.allow_tag_with_these_attributes("thead", []) 74 | Meta.allow_tag_with_these_attributes("tr", []) 75 | Meta.allow_tag_with_these_attributes("u", []) 76 | Meta.allow_tag_with_these_attributes("ul", []) 77 | 78 | Meta.strip_everything_not_covered() 79 | end 80 | -------------------------------------------------------------------------------- /lib/liv_web.ex: -------------------------------------------------------------------------------- 1 | defmodule LivWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, views, channels and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use LivWeb, :controller 9 | use LivWeb, :view 10 | 11 | The definitions below will be executed for every view, 12 | controller, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define any helper function in modules 17 | and import those modules here. 18 | """ 19 | 20 | def controller do 21 | quote do 22 | use Phoenix.Controller, namespace: LivWeb 23 | 24 | import Plug.Conn 25 | import LivWeb.Gettext 26 | alias LivWeb.Router.Helpers, as: Routes 27 | end 28 | end 29 | 30 | def view do 31 | quote do 32 | use Phoenix.View, 33 | root: "lib/liv_web/templates", 34 | namespace: LivWeb 35 | 36 | import Surface 37 | # Import convenience functions from controllers 38 | import Phoenix.Controller, 39 | only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] 40 | 41 | # Include shared imports and aliases for views 42 | unquote(view_helpers()) 43 | end 44 | end 45 | 46 | def live_view do 47 | quote do 48 | use Phoenix.LiveView, 49 | layout: {LivWeb.LayoutView, "live.html"} 50 | 51 | unquote(view_helpers()) 52 | end 53 | end 54 | 55 | def live_component do 56 | quote do 57 | use Phoenix.LiveComponent 58 | 59 | unquote(view_helpers()) 60 | end 61 | end 62 | 63 | def router do 64 | quote do 65 | use Phoenix.Router 66 | 67 | import Plug.Conn 68 | import Phoenix.Controller 69 | import Phoenix.LiveView.Router 70 | end 71 | end 72 | 73 | def channel do 74 | quote do 75 | use Phoenix.Channel 76 | import LivWeb.Gettext 77 | end 78 | end 79 | 80 | defp view_helpers do 81 | quote do 82 | # Use all HTML functionality (forms, tags, etc) 83 | use Phoenix.HTML 84 | 85 | # Import LiveView helpers (live_render, live_component, live_patch, etc) 86 | import Phoenix.LiveView.Helpers 87 | 88 | # Import basic rendering functionality (render, render_layout, etc) 89 | import Phoenix.View 90 | 91 | import LivWeb.ErrorHelpers 92 | import LivWeb.Gettext 93 | alias LivWeb.Router.Helpers, as: Routes 94 | end 95 | end 96 | 97 | @doc """ 98 | When used, dispatch to the appropriate controller/view/etc. 99 | """ 100 | defmacro __using__(which) when is_atom(which) do 101 | apply(__MODULE__, which, []) 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/liv_web/channels/user_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule LivWeb.UserSocket do 2 | use Phoenix.Socket 3 | 4 | ## Channels 5 | # channel "room:*", LivWeb.RoomChannel 6 | 7 | # Socket params are passed from the client and can 8 | # be used to verify and authenticate a user. After 9 | # verification, you can put default assigns into 10 | # the socket that will be set for all channels, ie 11 | # 12 | # {:ok, assign(socket, :user_id, verified_user_id)} 13 | # 14 | # To deny connection, return `:error`. 15 | # 16 | # See `Phoenix.Token` documentation for examples in 17 | # performing token verification on connect. 18 | @impl true 19 | def connect(_params, socket, _connect_info) do 20 | {:ok, socket} 21 | end 22 | 23 | # Socket id's are topics that allow you to identify all sockets for a given user: 24 | # 25 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}" 26 | # 27 | # Would allow you to broadcast a "disconnect" event and terminate 28 | # all active sockets and channels for a given user: 29 | # 30 | # LivWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{}) 31 | # 32 | # Returning `nil` makes this socket anonymous. 33 | @impl true 34 | def id(_socket), do: nil 35 | end 36 | -------------------------------------------------------------------------------- /lib/liv_web/components/address_book.ex: -------------------------------------------------------------------------------- 1 | defmodule LivWeb.AddressBook do 2 | use Surface.Component 3 | 4 | alias LivWeb.Router.Helpers, as: Routes 5 | alias LivWeb.Endpoint 6 | alias Surface.Components.LivePatch 7 | 8 | prop book, :list, default: [] 9 | prop sorted_by, :atom, default: :from 10 | prop desc, :boolean, default: false 11 | prop tz_offset, :integer, default: 0 12 | prop delete, :string, required: true 13 | 14 | defp from(nil, addr), do: addr 15 | defp from(name, _addr), do: name 16 | 17 | def query_for(addr), do: "from:#{addr} flag:replied" 18 | 19 | defp date_string(nil, _tz_offset), do: "" 20 | 21 | defp date_string(datei, tz_offset) do 22 | utc = NaiveDateTime.add(~N[1970-01-01 00:00:00], datei) 23 | now = NaiveDateTime.utc_now() 24 | local = NaiveDateTime.add(utc, 0 - tz_offset * 60) 25 | 26 | case NaiveDateTime.diff(now, utc) do 27 | diff when diff < 86400 -> 28 | local 29 | |> NaiveDateTime.to_time() 30 | |> Time.to_string() 31 | 32 | _ -> 33 | local 34 | |> NaiveDateTime.to_date() 35 | |> Date.to_string() 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/liv_web/components/address_book.sface: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {#for field <- [:from, :first, :last, :count]} 6 | 21 | {/for} 22 | 23 | 24 | 25 | {#for %{name: name, addr: addr, first: first, last: last, count: count} <- @book} 26 | 27 | 30 | 36 | 39 | 42 | 47 | 48 | {/for} 49 | 50 |
delete 7 | 11 | {field} 12 | {#if @sorted_by == field} 13 | {#if @desc} 14 | ▴ 15 | {#else} 16 | ▾ 17 | {/if} 18 | {/if} 19 | 20 |
28 | 29 | 31 | 35 | 37 |
{date_string(first, @tz_offset)}
38 |
40 |
{date_string(last, @tz_offset)}
41 |
43 | 44 | {count} 45 | 46 |
51 | -------------------------------------------------------------------------------- /lib/liv_web/components/attachment.ex: -------------------------------------------------------------------------------- 1 | defmodule LivWeb.Attachment do 2 | use Surface.Component 3 | 4 | prop name, :string, required: true 5 | prop size, :integer, required: true 6 | prop offset, :integer, required: true 7 | prop url, :string, default: "" 8 | 9 | defp percentage(_offset, 0), do: 100 10 | defp percentage(offset, size), do: floor(offset / size * 100) 11 | end 12 | -------------------------------------------------------------------------------- /lib/liv_web/components/attachment.sface: -------------------------------------------------------------------------------- 1 |
  • 2 |
    4 |
    6 |
    7 | 10 | {@name} 11 | 12 |
  • 13 | -------------------------------------------------------------------------------- /lib/liv_web/components/boomerang.ex: -------------------------------------------------------------------------------- 1 | defmodule LivWeb.Boomerang do 2 | use Surface.Component 3 | 4 | alias Surface.Components.Form 5 | 6 | alias Surface.Components.Form.{ 7 | Field, 8 | Label, 9 | RadioButton 10 | } 11 | 12 | prop submit, :event, required: true 13 | end 14 | -------------------------------------------------------------------------------- /lib/liv_web/components/boomerang.sface: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | 4 | 7 | 10 | 13 | 14 |
    15 |
    16 | 17 |
    18 |
    19 | -------------------------------------------------------------------------------- /lib/liv_web/components/button.ex: -------------------------------------------------------------------------------- 1 | defmodule LivWeb.Button do 2 | use Surface.Component 3 | alias Surface.Components.LivePatch 4 | alias Surface.Components.Form.FileInput 5 | 6 | prop text, :string, required: true 7 | prop type, :atom, required: true 8 | prop path_or_msg, :string, required: true 9 | prop disabled, :boolean, default: false 10 | end 11 | -------------------------------------------------------------------------------- /lib/liv_web/components/button.hooks.js: -------------------------------------------------------------------------------- 1 | import {fromByteArray} from "base64-js" 2 | 3 | export default { 4 | chunkSize: 16384, 5 | uploads: [], 6 | 7 | mounted() { 8 | this.el 9 | .querySelector("input#write-attach") 10 | .addEventListener("change", (e) => this.add_attachment(e.target.files)); 11 | this.handleEvent("read_attachment", ({name, offset}) => { 12 | this.upload_attachment(name, offset); 13 | }); 14 | }, 15 | 16 | async add_attachment(files) { 17 | for (let i = 0; i < files.length; i++) { 18 | let file = files[i]; 19 | let buffer = await new Promise((resolve) => { 20 | const reader = new FileReader(); 21 | reader.onload = (e) => resolve(e.target.result); 22 | reader.readAsArrayBuffer(file); 23 | }); 24 | this.uploads.push(buffer); 25 | this.pushEvent("write_attach", {name: file.name, size: file.size}); 26 | } 27 | }, 28 | 29 | upload_attachment(name, offset) { 30 | let data = this.uploads[0]; 31 | let dlen = data.byteLength; 32 | let slen = dlen > offset + this.chunkSize ? this.chunkSize : dlen - offset; 33 | let slice = new Uint8Array(data, offset, slen); 34 | let chunk = fromByteArray(slice); 35 | this.pushEvent("attachment_chunk", {chunk: chunk}); 36 | if (offset + this.chunkSize >= dlen) 37 | this.uploads.shift(); 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /lib/liv_web/components/button.sface: -------------------------------------------------------------------------------- 1 | {@text} 3 | 5 |
    6 | 7 | 8 |
    9 | -------------------------------------------------------------------------------- /lib/liv_web/components/config.ex: -------------------------------------------------------------------------------- 1 | defmodule LivWeb.Config do 2 | use Surface.Component 3 | 4 | alias LivWeb.RemoteMailBox 5 | alias Surface.Components.Form 6 | 7 | alias Surface.Components.Form.{ 8 | Field, 9 | TextInput, 10 | NumberInput, 11 | PasswordInput, 12 | Select, 13 | Label, 14 | TextArea 15 | } 16 | 17 | prop change, :event, required: true 18 | prop submit, :event, required: true 19 | prop my_addr, :list, required: true 20 | prop my_addrs, :list, required: true 21 | prop my_lists, :list, required: true 22 | prop days, :integer, required: true 23 | prop maildir, :string, required: true 24 | prop orbit_api_key, :string, required: true 25 | prop orbit_workspace, :string, required: true 26 | prop sending_method, :atom, required: true 27 | prop sending_data, :map, required: true 28 | prop reset_password, :string, default: "" 29 | prop remote_mail_boxes, :list, default: [] 30 | 31 | defp ui_boxes(boxes) do 32 | boxes ++ [%{method: "", username: "", password: "", hostname: ""}] 33 | end 34 | 35 | defp field_class(true), do: "field" 36 | defp field_class(false), do: "hide" 37 | end 38 | -------------------------------------------------------------------------------- /lib/liv_web/components/config.sface: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | 4 | 5 | 7 | 8 | 9 | 10 | 12 | 13 |
    14 |
    15 | 16 | 17 |