commit d18cdbecd8404ca30fbba16d08ecb24dbf9b5054 Author: ghostersk Date: Sat Mar 7 06:20:39 2026 +0000 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..230d90f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +data/envs +data/*.db +data/gomail.conf +data/*.txt \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d1d90e2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM golang:1.22-alpine AS builder + +RUN apk add --no-cache gcc musl-dev sqlite-dev + +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o gomail ./cmd/server + +# ---- Runtime ---- +FROM alpine:3.19 +RUN apk add --no-cache sqlite-libs ca-certificates tzdata + +WORKDIR /app +COPY --from=builder /app/gomail . +COPY --from=builder /app/web ./web + +RUN mkdir -p /data && addgroup -S gomail && adduser -S gomail -G gomail +RUN chown -R gomail:gomail /app /data +USER gomail + +VOLUME ["/data"] +EXPOSE 8080 + +ENV DB_PATH=/data/gomail.db +ENV LISTEN_ADDR=:8080 + +CMD ["./gomail"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e72bfdd --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8ef4b4c --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ +# GoMail + +A self-hosted, encrypted web email client written entirely in Go. Supports Gmail and Outlook via OAuth2, plus any standard IMAP/SMTP provider. + +## Features + +- **Unified inbox** — view emails from all connected accounts in one stream +- **Gmail & Outlook OAuth2** — modern, token-based auth (no storing raw passwords for these providers) +- **IMAP/SMTP** — connect any provider (ProtonMail Bridge, Fastmail, iCloud, etc.) +- **AES-256-GCM encryption** — all email content encrypted at rest in SQLite +- **bcrypt password hashing** — GoMail account passwords hashed with cost=12 +- **Send / Reply / Forward** — full compose workflow +- **Folder navigation** — per-account folder/label browsing +- **Full-text search** — across all accounts locally +- **Dark-themed web UI** — clean, keyboard-shortcut-friendly interface + +## Architecture + +``` +cmd/server/main.go Entry point, HTTP server setup +config/config.go Environment-based config +internal/ + auth/oauth.go OAuth2 flows (Google + Microsoft) + crypto/crypto.go AES-256-GCM encryption + bcrypt + db/db.go SQLite database with field-level encryption + email/imap.go IMAP fetch + SMTP send via XOAUTH2 + handlers/ HTTP handlers (auth, app, api) + middleware/middleware.go Logger, auth guard, security headers + models/models.go Data models +web/static/ + login.html Sign-in page + register.html Registration page + app.html Single-page app (email client UI) +``` + +## Quick Start + +### Option 1: Docker Compose (recommended) + +```bash +# 1. Clone / copy the project +git clone https://github.com/yourname/gomail && cd gomail + +# 2. Generate secrets +export ENCRYPTION_KEY=$(openssl rand -hex 32) +export SESSION_SECRET=$(openssl rand -hex 32) +echo "ENCRYPTION_KEY=$ENCRYPTION_KEY" # SAVE THIS — losing it means losing your email cache + +# 3. Add your OAuth2 credentials to docker-compose.yml (see below) +# 4. Run +ENCRYPTION_KEY=$ENCRYPTION_KEY SESSION_SECRET=$SESSION_SECRET docker compose up +``` + +Visit http://localhost:8080, register an account, then connect your email. + +### Option 2: Run directly + +```bash +go build -o gomail ./cmd/server +export ENCRYPTION_KEY=$(openssl rand -hex 32) +export SESSION_SECRET=$(openssl rand -hex 32) +./gomail +``` + +## Setting up OAuth2 + +### Gmail + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) → New project +2. Enable **Gmail API** +3. Create **OAuth 2.0 Client ID** (Web application) +4. Add Authorized redirect URI: `http://localhost:8080/auth/gmail/callback` +5. Set env vars: `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` + +> **Important:** In the Google Cloud Console, add the scope `https://mail.google.com/` to allow IMAP access. You'll also need to add test users while in "Testing" mode. + +### Outlook / Microsoft 365 + +1. Go to [Azure portal](https://portal.azure.com/) → App registrations → New registration +2. Set redirect URI: `http://localhost:8080/auth/outlook/callback` +3. Under API permissions, add: + - `https://outlook.office.com/IMAP.AccessAsUser.All` + - `https://outlook.office.com/SMTP.Send` + - `offline_access`, `openid`, `profile`, `email` +4. Create a Client secret +5. Set env vars: `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_TENANT_ID` + +## Environment Variables + +| Variable | Required | Description | +|---|---|---| +| `ENCRYPTION_KEY` | **Yes** | 64-char hex string (32 bytes). Auto-generated on first run but must be persisted. | +| `SESSION_SECRET` | **Yes** | Random string for session signing. | +| `LISTEN_ADDR` | No | Default `:8080` | +| `DB_PATH` | No | Default `./data/gomail.db` | +| `BASE_URL` | No | Default `http://localhost:8080` | +| `GOOGLE_CLIENT_ID` | For Gmail | Google OAuth2 client ID | +| `GOOGLE_CLIENT_SECRET` | For Gmail | Google OAuth2 client secret | +| `GOOGLE_REDIRECT_URL` | No | Default `{BASE_URL}/auth/gmail/callback` | +| `MICROSOFT_CLIENT_ID` | For Outlook | Azure AD app client ID | +| `MICROSOFT_CLIENT_SECRET` | For Outlook | Azure AD app client secret | +| `MICROSOFT_TENANT_ID` | No | Default `common` (multi-tenant) | +| `SECURE_COOKIE` | No | Set `true` in production (HTTPS only) | + +## Security Notes + +- **ENCRYPTION_KEY** is critical — back it up. Without it, the encrypted SQLite database is unreadable. +- Email content (subject, from, to, body) is encrypted at rest using AES-256-GCM. +- OAuth2 tokens are stored encrypted in the database. +- Passwords for GoMail accounts are bcrypt hashed (cost=12). +- All HTTP responses include security headers (CSP, X-Frame-Options, etc.). +- In production, run behind HTTPS (nginx/Caddy) and set `SECURE_COOKIE=true`. + +## Keyboard Shortcuts + +| Shortcut | Action | +|---|---| +| `Ctrl/Cmd + N` | Compose new message | +| `Ctrl/Cmd + K` | Focus search | +| `Escape` | Close compose / modal | + +## Dependencies + +``` +github.com/emersion/go-imap IMAP client +github.com/emersion/go-smtp SMTP client +github.com/emersion/go-message MIME parsing +github.com/gorilla/mux HTTP routing +github.com/mattn/go-sqlite3 SQLite driver (CGO) +golang.org/x/crypto bcrypt +golang.org/x/oauth2 OAuth2 + Google/Microsoft endpoints +``` + +## Building for Production + +```bash +CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o gomail ./cmd/server +``` + +CGO is required by `go-sqlite3`. Cross-compilation requires a C cross-compiler. + +## License + +MIT diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..82afaa9 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,180 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/yourusername/gomail/config" + "github.com/yourusername/gomail/internal/db" + "github.com/yourusername/gomail/internal/handlers" + "github.com/yourusername/gomail/internal/middleware" + "github.com/yourusername/gomail/internal/syncer" + + "github.com/gorilla/mux" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("config load: %v", err) + } + + database, err := db.New(cfg.DBPath, cfg.EncryptionKey) + if err != nil { + log.Fatalf("database init: %v", err) + } + defer database.Close() + + if err := database.Migrate(); err != nil { + log.Fatalf("migrations: %v", err) + } + + sc := syncer.New(database) + sc.Start() + defer sc.Stop() + + r := mux.NewRouter() + h := handlers.New(database, cfg, sc) + + r.Use(middleware.Logger) + r.Use(middleware.SecurityHeaders) + r.Use(middleware.CORS) + r.Use(cfg.HostCheckMiddleware) + + // Static files + r.PathPrefix("/static/").Handler( + http.StripPrefix("/static/", http.FileServer(http.Dir("./web/static/"))), + ) + + // Public auth routes + auth := r.PathPrefix("/auth").Subrouter() + auth.HandleFunc("/login", h.Auth.ShowLogin).Methods("GET") + auth.HandleFunc("/login", h.Auth.Login).Methods("POST") + auth.HandleFunc("/logout", h.Auth.Logout).Methods("POST") + + // MFA (session exists but mfa_verified=0) + mfaR := r.PathPrefix("/auth/mfa").Subrouter() + mfaR.Use(middleware.RequireAuth(database, cfg)) + mfaR.HandleFunc("", h.Auth.ShowMFA).Methods("GET") + mfaR.HandleFunc("/verify", h.Auth.VerifyMFA).Methods("POST") + + // OAuth callbacks (require auth to associate with user) + oauthR := r.PathPrefix("/auth").Subrouter() + oauthR.Use(middleware.RequireAuth(database, cfg)) + oauthR.HandleFunc("/gmail/connect", h.Auth.GmailConnect).Methods("GET") + oauthR.HandleFunc("/gmail/callback", h.Auth.GmailCallback).Methods("GET") + oauthR.HandleFunc("/outlook/connect", h.Auth.OutlookConnect).Methods("GET") + oauthR.HandleFunc("/outlook/callback", h.Auth.OutlookCallback).Methods("GET") + + // App + app := r.PathPrefix("").Subrouter() + app.Use(middleware.RequireAuth(database, cfg)) + app.HandleFunc("/", h.App.Index).Methods("GET") + + // Admin UI + adminUI := r.PathPrefix("/admin").Subrouter() + adminUI.Use(middleware.RequireAuth(database, cfg)) + adminUI.Use(middleware.RequireAdmin) + adminUI.HandleFunc("", h.Admin.ShowAdmin).Methods("GET") + adminUI.HandleFunc("/", h.Admin.ShowAdmin).Methods("GET") + adminUI.HandleFunc("/settings", h.Admin.ShowAdmin).Methods("GET") + adminUI.HandleFunc("/audit", h.Admin.ShowAdmin).Methods("GET") + + // API + api := r.PathPrefix("/api").Subrouter() + api.Use(middleware.RequireAuth(database, cfg)) + api.Use(middleware.JSONContentType) + + // Profile / auth + api.HandleFunc("/me", h.Auth.Me).Methods("GET") + api.HandleFunc("/change-password", h.Auth.ChangePassword).Methods("POST") + api.HandleFunc("/mfa/setup", h.Auth.MFASetupBegin).Methods("POST") + api.HandleFunc("/mfa/confirm", h.Auth.MFASetupConfirm).Methods("POST") + api.HandleFunc("/mfa/disable", h.Auth.MFADisable).Methods("POST") + + // Providers (which OAuth providers are configured) + api.HandleFunc("/providers", h.API.GetProviders).Methods("GET") + + // Accounts + api.HandleFunc("/accounts", h.API.ListAccounts).Methods("GET") + api.HandleFunc("/accounts", h.API.AddAccount).Methods("POST") + api.HandleFunc("/accounts/test", h.API.TestConnection).Methods("POST") + api.HandleFunc("/accounts/{id:[0-9]+}", h.API.GetAccount).Methods("GET") + api.HandleFunc("/accounts/{id:[0-9]+}", h.API.UpdateAccount).Methods("PUT") + api.HandleFunc("/accounts/{id:[0-9]+}", h.API.DeleteAccount).Methods("DELETE") + api.HandleFunc("/accounts/{id:[0-9]+}/sync", h.API.SyncAccount).Methods("POST") + api.HandleFunc("/accounts/{id:[0-9]+}/sync-settings", h.API.SetAccountSyncSettings).Methods("PUT") + + // Messages + api.HandleFunc("/messages", h.API.ListMessages).Methods("GET") + api.HandleFunc("/messages/unified", h.API.UnifiedInbox).Methods("GET") + api.HandleFunc("/messages/{id:[0-9]+}", h.API.GetMessage).Methods("GET") + api.HandleFunc("/messages/{id:[0-9]+}/read", h.API.MarkRead).Methods("PUT") + api.HandleFunc("/messages/{id:[0-9]+}/star", h.API.ToggleStar).Methods("PUT") + api.HandleFunc("/messages/{id:[0-9]+}/move", h.API.MoveMessage).Methods("PUT") + api.HandleFunc("/messages/{id:[0-9]+}/headers", h.API.GetMessageHeaders).Methods("GET") + api.HandleFunc("/messages/{id:[0-9]+}", h.API.DeleteMessage).Methods("DELETE") + + // Remote content whitelist + api.HandleFunc("/remote-content-whitelist", h.API.GetRemoteContentWhitelist).Methods("GET") + api.HandleFunc("/remote-content-whitelist", h.API.AddRemoteContentWhitelist).Methods("POST") + + // Send + api.HandleFunc("/send", h.API.SendMessage).Methods("POST") + api.HandleFunc("/reply", h.API.ReplyMessage).Methods("POST") + api.HandleFunc("/forward", h.API.ForwardMessage).Methods("POST") + + // Folders + api.HandleFunc("/folders", h.API.ListFolders).Methods("GET") + api.HandleFunc("/folders/{account_id:[0-9]+}", h.API.ListAccountFolders).Methods("GET") + api.HandleFunc("/folders/{id:[0-9]+}/sync", h.API.SyncFolder).Methods("POST") + + api.HandleFunc("/sync-interval", h.API.GetSyncInterval).Methods("GET") + api.HandleFunc("/sync-interval", h.API.SetSyncInterval).Methods("PUT") + api.HandleFunc("/compose-popup", h.API.SetComposePopup).Methods("PUT") + + // Search + api.HandleFunc("/search", h.API.Search).Methods("GET") + + // Admin API + adminAPI := r.PathPrefix("/api/admin").Subrouter() + adminAPI.Use(middleware.RequireAuth(database, cfg)) + adminAPI.Use(middleware.JSONContentType) + adminAPI.Use(middleware.RequireAdmin) + adminAPI.HandleFunc("/users", h.Admin.ListUsers).Methods("GET") + adminAPI.HandleFunc("/users", h.Admin.CreateUser).Methods("POST") + adminAPI.HandleFunc("/users/{id:[0-9]+}", h.Admin.UpdateUser).Methods("PUT") + adminAPI.HandleFunc("/users/{id:[0-9]+}", h.Admin.DeleteUser).Methods("DELETE") + adminAPI.HandleFunc("/audit", h.Admin.ListAuditLogs).Methods("GET") + adminAPI.HandleFunc("/settings", h.Admin.GetSettings).Methods("GET") + adminAPI.HandleFunc("/settings", h.Admin.SetSettings).Methods("PUT") + + srv := &http.Server{ + Addr: cfg.ListenAddr, + Handler: r, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 120 * time.Second, + } + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + + go func() { + log.Printf("GoMail listening on %s", cfg.ListenAddr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("server: %v", err) + } + }() + + <-quit + log.Println("Shutting down...") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + srv.Shutdown(ctx) +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..8f248a2 --- /dev/null +++ b/config/config.go @@ -0,0 +1,621 @@ +// Package config loads and persists GoMail configuration from data/gomail.conf +package config + +import ( + "bufio" + "crypto/rand" + "encoding/hex" + "fmt" + "net" + "net/http" + "os" + "strconv" + "strings" +) + +// Config holds all application configuration. +type Config struct { + // Server + ListenAddr string // e.g. ":8080" or "0.0.0.0:8080" + ListenPort string // derived from ListenAddr, e.g. "8080" + Hostname string // e.g. "mail.example.com" — used for BASE_URL and host checks + BaseURL string // auto-built from Hostname + ListenPort, or overridden explicitly + + // Security + EncryptionKey []byte // 32 bytes / AES-256 + SessionSecret []byte + SecureCookie bool + SessionMaxAge int + TrustedProxies []net.IPNet // CIDR ranges allowed to set X-Forwarded-For/Proto headers + + // Storage + DBPath string + + // Google OAuth2 + GoogleClientID string + GoogleClientSecret string + GoogleRedirectURL string // auto-derived from BaseURL if blank + + // Microsoft OAuth2 + MicrosoftClientID string + MicrosoftClientSecret string + MicrosoftTenantID string + MicrosoftRedirectURL string // auto-derived from BaseURL if blank +} + +const configPath = "./data/gomail.conf" + +type configField struct { + key string + defVal string + comments []string +} + +// allFields is the single source of truth for config keys. +// Adding a field here causes it to automatically appear in gomail.conf on next startup. +var allFields = []configField{ + { + key: "HOSTNAME", + defVal: "localhost", + comments: []string{ + "--- Server ---", + "Public hostname of this GoMail instance (no port, no protocol).", + "Examples: localhost | mail.example.com | 192.168.1.10", + "Used to build BASE_URL and OAuth redirect URIs automatically.", + "Also used in security checks to reject requests with unexpected Host headers.", + }, + }, + { + key: "LISTEN_ADDR", + defVal: ":8080", + comments: []string{ + "Address and port to listen on. Format: [host]:port", + " :8080 — all interfaces, port 8080", + " 0.0.0.0:8080 — all interfaces (explicit)", + " 127.0.0.1:8080 — localhost only", + }, + }, + { + key: "BASE_URL", + defVal: "", + comments: []string{ + "Public URL of this instance (no trailing slash). Leave blank to auto-build", + "from HOSTNAME and LISTEN_ADDR port (recommended).", + " Auto-build examples:", + " HOSTNAME=localhost + :8080 → http://localhost:8080", + " HOSTNAME=mail.example.com + :443 → https://mail.example.com", + " HOSTNAME=mail.example.com + :8080 → http://mail.example.com:8080", + "Override here only if you need a custom path prefix or your proxy rewrites the URL.", + }, + }, + { + key: "SECURE_COOKIE", + defVal: "false", + comments: []string{ + "Set to true when GoMail is served over HTTPS (directly or via proxy).", + "Marks session cookies as Secure so browsers only send them over TLS.", + }, + }, + { + key: "SESSION_MAX_AGE", + defVal: "604800", + comments: []string{ + "How long a login session lasts, in seconds. Default: 604800 (7 days).", + }, + }, + { + key: "TRUSTED_PROXIES", + defVal: "", + comments: []string{ + "Comma-separated list of IP addresses or CIDR ranges of trusted reverse proxies.", + "Requests from these IPs may set X-Forwarded-For and X-Forwarded-Proto headers,", + "which GoMail uses to determine the real client IP and whether TLS is in use.", + " Examples:", + " 127.0.0.1 (loopback only — Nginx/Traefik on same host)", + " 10.0.0.0/8,172.16.0.0/12 (private networks)", + " 192.168.1.50,192.168.1.51 (specific IPs)", + " Leave blank to disable proxy trust (requests are taken at face value).", + " NOTE: Do not add untrusted IPs — clients could spoof their source address.", + }, + }, + { + key: "DB_PATH", + defVal: "./data/gomail.db", + comments: []string{ + "--- Storage ---", + "Path to the SQLite database file.", + }, + }, + { + key: "ENCRYPTION_KEY", + defVal: "", + comments: []string{ + "AES-256 key protecting all sensitive data at rest (emails, tokens, MFA secrets).", + "Must be exactly 64 hex characters (= 32 bytes). Auto-generated on first run.", + "NOTE: Back this up. Losing it makes the entire database permanently unreadable.", + }, + }, + { + key: "SESSION_SECRET", + defVal: "", + comments: []string{ + "Secret used to sign session cookies. Auto-generated on first run.", + "Changing this invalidates all active sessions (everyone gets logged out).", + }, + }, + { + key: "GOOGLE_CLIENT_ID", + defVal: "", + comments: []string{ + "--- Gmail / Google OAuth2 ---", + "Create at: https://console.cloud.google.com/apis/credentials", + " Application type : Web application", + " Required scope : https://mail.google.com/", + " Redirect URI : /auth/gmail/callback", + }, + }, + { + key: "GOOGLE_CLIENT_SECRET", + defVal: "", + comments: []string{}, + }, + { + key: "GOOGLE_REDIRECT_URL", + defVal: "", + comments: []string{ + "Override the Gmail OAuth redirect URL. Leave blank to auto-derive from BASE_URL.", + "Must exactly match what is registered in Google Cloud Console.", + }, + }, + { + key: "MICROSOFT_CLIENT_ID", + defVal: "", + comments: []string{ + "--- Outlook / Microsoft 365 OAuth2 ---", + "Register at: https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps", + " Required API permissions : IMAP.AccessAsUser.All, SMTP.Send, offline_access, openid, email", + " Redirect URI : /auth/outlook/callback", + }, + }, + { + key: "MICROSOFT_CLIENT_SECRET", + defVal: "", + comments: []string{}, + }, + { + key: "MICROSOFT_TENANT_ID", + defVal: "common", + comments: []string{ + "Use 'common' to allow any Microsoft account,", + "or your Azure tenant ID to restrict to one organisation.", + }, + }, + { + key: "MICROSOFT_REDIRECT_URL", + defVal: "", + comments: []string{ + "Override the Outlook OAuth redirect URL. Leave blank to auto-derive from BASE_URL.", + "Must exactly match what is registered in Azure.", + }, + }, +} + +// Load reads/creates data/gomail.conf, fills in missing keys, then returns Config. +// Environment variables override file values when set. +func Load() (*Config, error) { + if err := os.MkdirAll("./data", 0700); err != nil { + return nil, fmt.Errorf("create data dir: %w", err) + } + + existing, err := readConfigFile(configPath) + if err != nil { + return nil, err + } + + // Auto-generate secrets if missing + if existing["ENCRYPTION_KEY"] == "" { + existing["ENCRYPTION_KEY"] = mustHex(32) + fmt.Println("WARNING: Generated new ENCRYPTION_KEY — it is saved in data/gomail.conf — back it up!") + } + if existing["SESSION_SECRET"] == "" { + existing["SESSION_SECRET"] = mustHex(32) + } + + // Write back (preserves existing, adds any new fields from allFields) + if err := writeConfigFile(configPath, existing); err != nil { + return nil, fmt.Errorf("write config: %w", err) + } + + // get returns env var if set, else file value, else "" + get := func(key string) string { + // Only check env vars that are explicitly GoMail-namespaced or well-known. + // We deliberately do NOT fall back to generic vars like PORT to avoid + // picking up cloud-platform env vars unintentionally. + if v := os.Getenv("GOMAIL_" + key); v != "" { + return v + } + if v := os.Getenv(key); v != "" { + return v + } + return existing[key] + } + + // ---- Resolve listen address ---- + listenAddr := get("LISTEN_ADDR") + if listenAddr == "" { + listenAddr = ":8080" + } + // Ensure it has a port + if !strings.Contains(listenAddr, ":") { + listenAddr = ":" + listenAddr + } + _, listenPort, err := net.SplitHostPort(listenAddr) + if err != nil { + return nil, fmt.Errorf("invalid LISTEN_ADDR %q: %w", listenAddr, err) + } + + // ---- Resolve hostname ---- + hostname := get("HOSTNAME") + if hostname == "" { + hostname = "localhost" + } + // Strip any accidental protocol or port from hostname + hostname = strings.TrimPrefix(hostname, "http://") + hostname = strings.TrimPrefix(hostname, "https://") + hostname = strings.Split(hostname, ":")[0] + hostname = strings.TrimRight(hostname, "/") + + // ---- Build BASE_URL ---- + baseURL := get("BASE_URL") + if baseURL == "" { + baseURL = buildBaseURL(hostname, listenPort) + } + // Strip trailing slash + baseURL = strings.TrimRight(baseURL, "/") + + // ---- OAuth redirect URLs (auto-derive if blank) ---- + googleRedirect := get("GOOGLE_REDIRECT_URL") + if googleRedirect == "" { + googleRedirect = baseURL + "/auth/gmail/callback" + } + outlookRedirect := get("MICROSOFT_REDIRECT_URL") + if outlookRedirect == "" { + outlookRedirect = baseURL + "/auth/outlook/callback" + } + + // ---- Decode secrets ---- + encHex := get("ENCRYPTION_KEY") + encKey, err := hex.DecodeString(encHex) + if err != nil || len(encKey) != 32 { + return nil, fmt.Errorf("ENCRYPTION_KEY must be 64 hex chars (32 bytes), got %d chars", len(encHex)) + } + + sessSecret := get("SESSION_SECRET") + if sessSecret == "" { + return nil, fmt.Errorf("SESSION_SECRET is empty — this should not happen") + } + + // ---- Trusted proxies ---- + trustedProxies, err := parseCIDRList(get("TRUSTED_PROXIES")) + if err != nil { + return nil, fmt.Errorf("invalid TRUSTED_PROXIES: %w", err) + } + + cfg := &Config{ + ListenAddr: listenAddr, + ListenPort: listenPort, + Hostname: hostname, + BaseURL: baseURL, + DBPath: get("DB_PATH"), + EncryptionKey: encKey, + SessionSecret: []byte(sessSecret), + SecureCookie: atobool(get("SECURE_COOKIE"), false), + SessionMaxAge: atoi(get("SESSION_MAX_AGE"), 604800), + TrustedProxies: trustedProxies, + + GoogleClientID: get("GOOGLE_CLIENT_ID"), + GoogleClientSecret: get("GOOGLE_CLIENT_SECRET"), + GoogleRedirectURL: googleRedirect, + MicrosoftClientID: get("MICROSOFT_CLIENT_ID"), + MicrosoftClientSecret: get("MICROSOFT_CLIENT_SECRET"), + MicrosoftTenantID: orDefault(get("MICROSOFT_TENANT_ID"), "common"), + MicrosoftRedirectURL: outlookRedirect, + } + + // Derive SECURE_COOKIE automatically if BASE_URL uses https + if strings.HasPrefix(baseURL, "https://") && !cfg.SecureCookie { + cfg.SecureCookie = true + } + + logStartupInfo(cfg) + return cfg, nil +} + +// buildBaseURL constructs the public URL from hostname and port. +// Port 443 → https://, port 80 → http://, +// anything else → http://: +func buildBaseURL(hostname, port string) string { + switch port { + case "443": + return "https://" + hostname + case "80": + return "http://" + hostname + default: + return "http://" + hostname + ":" + port + } +} + +// IsAllowedHost returns true if the request Host header matches our expected hostname. +// Accepts exact match, hostname:port, or any value if hostname is "localhost" (dev mode). +func (c *Config) IsAllowedHost(requestHost string) bool { + if c.Hostname == "localhost" { + return true // dev mode — permissive + } + // Strip port from request Host header + h := requestHost + if host, _, err := net.SplitHostPort(requestHost); err == nil { + h = host + } + return strings.EqualFold(h, c.Hostname) +} + +// RealIP extracts the genuine client IP from the request, honouring X-Forwarded-For +// only when the request comes from a trusted proxy. +func (c *Config) RealIP(remoteAddr string, xForwardedFor string) string { + // Parse remote addr + remoteIP, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + remoteIP = remoteAddr + } + + if xForwardedFor == "" || !c.isTrustedProxy(remoteIP) { + return remoteIP + } + + // Take the left-most (client) IP from X-Forwarded-For + parts := strings.Split(xForwardedFor, ",") + if len(parts) > 0 { + ip := strings.TrimSpace(parts[0]) + if net.ParseIP(ip) != nil { + return ip + } + } + return remoteIP +} + +// IsHTTPS returns true if the request arrived over TLS, either directly +// or as indicated by X-Forwarded-Proto from a trusted proxy. +func (c *Config) IsHTTPS(remoteAddr string, xForwardedProto string) bool { + if xForwardedProto != "" { + remoteIP, _, err := net.SplitHostPort(remoteAddr) + if err != nil { + remoteIP = remoteAddr + } + if c.isTrustedProxy(remoteIP) { + return strings.EqualFold(xForwardedProto, "https") + } + } + return strings.HasPrefix(c.BaseURL, "https://") +} + +func (c *Config) isTrustedProxy(ipStr string) bool { + ip := net.ParseIP(ipStr) + if ip == nil { + return false + } + for _, cidr := range c.TrustedProxies { + if cidr.Contains(ip) { + return true + } + } + return false +} + +// ---- Config file I/O ---- + +func readConfigFile(path string) (map[string]string, error) { + values := make(map[string]string) + f, err := os.Open(path) + if os.IsNotExist(err) { + return values, nil + } + if err != nil { + return nil, fmt.Errorf("open config: %w", err) + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + idx := strings.IndexByte(line, '=') + if idx < 0 { + continue + } + key := strings.TrimSpace(line[:idx]) + val := strings.TrimSpace(line[idx+1:]) + values[key] = val + } + return values, scanner.Err() +} + +func writeConfigFile(path string, values map[string]string) error { + var sb strings.Builder + sb.WriteString("# GoMail Configuration\n") + sb.WriteString("# =====================\n") + sb.WriteString("# Auto-generated and updated on each startup.\n") + sb.WriteString("# Edit freely — your values are always preserved.\n") + sb.WriteString("# Environment variables (or GOMAIL_) override values here.\n") + sb.WriteString("#\n\n") + + for _, field := range allFields { + for _, c := range field.comments { + if c == "" { + sb.WriteString("#\n") + } else { + sb.WriteString("# " + c + "\n") + } + } + val := values[field.key] + if val == "" { + val = field.defVal + } + sb.WriteString(field.key + " = " + val + "\n\n") + } + return os.WriteFile(path, []byte(sb.String()), 0600) +} + +// ---- Admin settings API ---- + +// EditableKeys lists config keys that may be changed via the admin UI. +// SESSION_SECRET and ENCRYPTION_KEY are intentionally excluded. +var EditableKeys = func() map[string]bool { + excluded := map[string]bool{ + "SESSION_SECRET": true, + "ENCRYPTION_KEY": true, + } + m := map[string]bool{} + for _, f := range allFields { + if !excluded[f.key] { + m[f.key] = true + } + } + return m +}() + +// GetSettings returns the current raw config file values for all editable keys. +func GetSettings() (map[string]string, error) { + raw, err := readConfigFile(configPath) + if err != nil { + return nil, err + } + result := make(map[string]string, len(allFields)) + for _, f := range allFields { + if EditableKeys[f.key] { + if v, ok := raw[f.key]; ok { + result[f.key] = v + } else { + result[f.key] = f.defVal + } + } + } + return result, nil +} + +// SetSettings merges the provided map into the config file. +// Only EditableKeys are accepted; unknown or protected keys are silently ignored. +// Returns the list of keys that were actually changed. +func SetSettings(updates map[string]string) ([]string, error) { + raw, err := readConfigFile(configPath) + if err != nil { + return nil, err + } + var changed []string + for k, v := range updates { + if !EditableKeys[k] { + continue + } + if raw[k] != v { + raw[k] = v + changed = append(changed, k) + } + } + if len(changed) == 0 { + return nil, nil + } + return changed, writeConfigFile(configPath, raw) +} + +// ---- Host validation middleware helper ---- + +// HostCheck returns an HTTP middleware that rejects requests with unexpected Host headers. +// Skipped in dev mode (hostname == "localhost"). +func (c *Config) HostCheckMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !c.IsAllowedHost(r.Host) { + http.Error(w, "Invalid host header", http.StatusBadRequest) + return + } + next.ServeHTTP(w, r) + }) +} + +// ---- Helpers ---- + +func parseCIDRList(s string) ([]net.IPNet, error) { + var nets []net.IPNet + if s == "" { + return nets, nil + } + for _, raw := range strings.Split(s, ",") { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + // Allow bare IPs (treat as /32 or /128) + if !strings.Contains(raw, "/") { + ip := net.ParseIP(raw) + if ip == nil { + return nil, fmt.Errorf("invalid IP %q", raw) + } + bits := 32 + if ip.To4() == nil { + bits = 128 + } + raw = fmt.Sprintf("%s/%d", ip.String(), bits) + } + _, ipNet, err := net.ParseCIDR(raw) + if err != nil { + return nil, fmt.Errorf("invalid CIDR %q: %w", raw, err) + } + nets = append(nets, *ipNet) + } + return nets, nil +} + +func logStartupInfo(cfg *Config) { + fmt.Printf("GoMail starting:\n") + fmt.Printf(" Listen : %s\n", cfg.ListenAddr) + fmt.Printf(" Base URL: %s\n", cfg.BaseURL) + fmt.Printf(" Hostname: %s\n", cfg.Hostname) + if len(cfg.TrustedProxies) > 0 { + cidrs := make([]string, len(cfg.TrustedProxies)) + for i, n := range cfg.TrustedProxies { + cidrs[i] = n.String() + } + fmt.Printf(" Proxies : %s\n", strings.Join(cidrs, ", ")) + } +} + +func mustHex(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + panic("crypto/rand unavailable: " + err.Error()) + } + return hex.EncodeToString(b) +} + +func orDefault(s, def string) string { + if s == "" { + return def + } + return s +} + +func atoi(s string, fallback int) int { + if v, err := strconv.Atoi(s); err == nil { + return v + } + return fallback +} + +func atobool(s string, fallback bool) bool { + if v, err := strconv.ParseBool(s); err == nil { + return v + } + return fallback +} + +// Needed for HostCheckMiddleware diff --git a/data/gomail.conf.example b/data/gomail.conf.example new file mode 100644 index 0000000..bfa8769 --- /dev/null +++ b/data/gomail.conf.example @@ -0,0 +1,90 @@ +# GoMail Configuration +# ===================== +# Auto-generated and updated on each startup. +# Edit freely — your values are always preserved. +# Environment variables (or GOMAIL_) override values here. +# + +# --- Server --- +# Public hostname of this GoMail instance (no port, no protocol). +# Examples: localhost | mail.example.com | 192.168.1.10 +# Used to build BASE_URL and OAuth redirect URIs automatically. +# Also used in security checks to reject requests with unexpected Host headers. +HOSTNAME = localhost + +# Address and port to listen on. Format: [host]:port +# :8080 — all interfaces, port 8080 +# 0.0.0.0:8080 — all interfaces (explicit) +# 127.0.0.1:8080 — localhost only +LISTEN_ADDR = :8080 + +# Public URL of this instance (no trailing slash). Leave blank to auto-build +# from HOSTNAME and LISTEN_ADDR port (recommended). +# Auto-build examples: +# HOSTNAME=localhost + :8080 → http://localhost:8080 +# HOSTNAME=mail.example.com + :443 → https://mail.example.com +# HOSTNAME=mail.example.com + :8080 → http://mail.example.com:8080 +# Override here only if you need a custom path prefix or your proxy rewrites the URL. +BASE_URL = + +# Set to true when GoMail is served over HTTPS (directly or via proxy). +# Marks session cookies as Secure so browsers only send them over TLS. +SECURE_COOKIE = false + +# How long a login session lasts, in seconds. Default: 604800 (7 days). +SESSION_MAX_AGE = 604800 + +# Comma-separated list of IP addresses or CIDR ranges of trusted reverse proxies. +# Requests from these IPs may set X-Forwarded-For and X-Forwarded-Proto headers, +# which GoMail uses to determine the real client IP and whether TLS is in use. +# Examples: +# 127.0.0.1 (loopback only — Nginx/Traefik on same host) +# 10.0.0.0/8,172.16.0.0/12 (private networks) +# 192.168.1.50,192.168.1.51 (specific IPs) +# Leave blank to disable proxy trust (requests are taken at face value). +# NOTE: Do not add untrusted IPs — clients could spoof their source address. +TRUSTED_PROXIES = + +# --- Storage --- +# Path to the SQLite database file. +DB_PATH = ./data/gomail.db + +# AES-256 key protecting all sensitive data at rest (emails, tokens, MFA secrets). +# Must be exactly 64 hex characters (= 32 bytes). Auto-generated on first run. +# NOTE: Back this up. Losing it makes the entire database permanently unreadable. +# openssl rand -hex 32 +ENCRYPTION_KEY = 2cf005ce1ed023ad59da92523bc437ec70fb0d2520f977711216fbb5f356fa97 + +# Secret used to sign session cookies. Auto-generated on first run. +# Changing this invalidates all active sessions (everyone gets logged out). +SESSION_SECRET = c6502e203937358815053f7849e6da8c376253a4f9a38def54d750219c65660e + +# --- Gmail / Google OAuth2 --- +# Create at: https://console.cloud.google.com/apis/credentials +# Application type : Web application +# Required scope : https://mail.google.com/ +# Redirect URI : /auth/gmail/callback +GOOGLE_CLIENT_ID = + +GOOGLE_CLIENT_SECRET = + +# Override the Gmail OAuth redirect URL. Leave blank to auto-derive from BASE_URL. +# Must exactly match what is registered in Google Cloud Console. +GOOGLE_REDIRECT_URL = + +# --- Outlook / Microsoft 365 OAuth2 --- +# Register at: https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps +# Required API permissions : IMAP.AccessAsUser.All, SMTP.Send, offline_access, openid, email +# Redirect URI : /auth/outlook/callback +MICROSOFT_CLIENT_ID = + +MICROSOFT_CLIENT_SECRET = + +# Use 'common' to allow any Microsoft account, +# or your Azure tenant ID to restrict to one organisation. +MICROSOFT_TENANT_ID = common + +# Override the Outlook OAuth redirect URL. Leave blank to auto-derive from BASE_URL. +# Must exactly match what is registered in Azure. +MICROSOFT_REDIRECT_URL = + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6f09851 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +version: '3.9' + +services: + gomail: + build: . + ports: + - "8080:8080" + volumes: + - gomail-data:/data + environment: + # REQUIRED: Generate with: openssl rand -hex 32 + ENCRYPTION_KEY: "" + SESSION_SECRET: "" + BASE_URL: "http://localhost:8080" + + # ---- Gmail OAuth2 ---- + # Create at: https://console.cloud.google.com/ + # Enable: Gmail API + # OAuth2 scopes: https://mail.google.com/ + # Redirect URI: http://localhost:8080/auth/gmail/callback + GOOGLE_CLIENT_ID: "" + GOOGLE_CLIENT_SECRET: "" + + # ---- Microsoft/Outlook OAuth2 ---- + # Create at: https://portal.azure.com/ → App registrations + # Add permission: IMAP.AccessAsUser.All, SMTP.Send + # Redirect URI: http://localhost:8080/auth/outlook/callback + MICROSOFT_CLIENT_ID: "" + MICROSOFT_CLIENT_SECRET: "" + MICROSOFT_TENANT_ID: "common" + + restart: unless-stopped + +volumes: + gomail-data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..64ef620 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module github.com/yourusername/gomail + +go 1.26 + +require ( + github.com/emersion/go-imap v1.2.1 + github.com/gorilla/mux v1.8.1 + github.com/mattn/go-sqlite3 v1.14.22 + golang.org/x/crypto v0.24.0 + golang.org/x/oauth2 v0.21.0 +) + +require ( + cloud.google.com/go/compute/metadata v0.3.0 // indirect + github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect + github.com/google/go-cmp v0.6.0 // indirect + golang.org/x/text v0.16.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0b45931 --- /dev/null +++ b/go.sum @@ -0,0 +1,23 @@ +cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA= +github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY= +github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go new file mode 100644 index 0000000..d31329d --- /dev/null +++ b/internal/auth/oauth.go @@ -0,0 +1,132 @@ +// Package auth handles OAuth2 flows for Gmail and Outlook. +package auth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + "golang.org/x/oauth2/microsoft" +) + +// ---- Gmail OAuth2 ---- + +// GmailScopes are the OAuth2 scopes required for full Gmail access. +var GmailScopes = []string{ + "https://mail.google.com/", // Full IMAP+SMTP access + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", +} + +// NewGmailConfig creates an OAuth2 config for Gmail. +func NewGmailConfig(clientID, clientSecret, redirectURL string) *oauth2.Config { + return &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURL: redirectURL, + Scopes: GmailScopes, + Endpoint: google.Endpoint, + } +} + +// GoogleUserInfo holds the data returned by Google's userinfo endpoint. +type GoogleUserInfo struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` +} + +// GetGoogleUserInfo fetches the authenticated user's email and name. +func GetGoogleUserInfo(ctx context.Context, token *oauth2.Token, cfg *oauth2.Config) (*GoogleUserInfo, error) { + client := cfg.Client(ctx, token) + resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo") + if err != nil { + return nil, fmt.Errorf("userinfo request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("userinfo returned %d", resp.StatusCode) + } + var info GoogleUserInfo + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + return nil, err + } + return &info, nil +} + +// ---- Microsoft / Outlook OAuth2 ---- + +// OutlookScopes are required for Outlook/Microsoft 365 mail access. +var OutlookScopes = []string{ + "https://outlook.office.com/IMAP.AccessAsUser.All", + "https://outlook.office.com/SMTP.Send", + "offline_access", + "openid", + "profile", + "email", +} + +// NewOutlookConfig creates an OAuth2 config for Microsoft/Outlook. +func NewOutlookConfig(clientID, clientSecret, tenantID, redirectURL string) *oauth2.Config { + return &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURL: redirectURL, + Scopes: OutlookScopes, + Endpoint: microsoft.AzureADEndpoint(tenantID), + } +} + +// MicrosoftUserInfo holds data from Microsoft Graph /me endpoint. +type MicrosoftUserInfo struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Mail string `json:"mail"` + UserPrincipalName string `json:"userPrincipalName"` +} + +// Email returns the best available email address. +func (m *MicrosoftUserInfo) Email() string { + if m.Mail != "" { + return m.Mail + } + return m.UserPrincipalName +} + +// GetMicrosoftUserInfo fetches user info from Microsoft Graph. +func GetMicrosoftUserInfo(ctx context.Context, token *oauth2.Token, cfg *oauth2.Config) (*MicrosoftUserInfo, error) { + client := cfg.Client(ctx, token) + resp, err := client.Get("https://graph.microsoft.com/v1.0/me") + if err != nil { + return nil, fmt.Errorf("graph /me request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("graph /me returned %d", resp.StatusCode) + } + var info MicrosoftUserInfo + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + return nil, err + } + return &info, nil +} + +// ---- Token refresh helpers ---- + +// IsTokenExpired reports whether the token expires within a 60-second buffer. +func IsTokenExpired(expiry time.Time) bool { + if expiry.IsZero() { + return false + } + return time.Now().Add(60 * time.Second).After(expiry) +} + +// RefreshToken attempts to exchange a refresh token for a new access token. +func RefreshToken(ctx context.Context, cfg *oauth2.Config, refreshToken string) (*oauth2.Token, error) { + ts := cfg.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken}) + return ts.Token() +} diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go new file mode 100644 index 0000000..ef7058f --- /dev/null +++ b/internal/crypto/crypto.go @@ -0,0 +1,134 @@ +// Package crypto provides AES-256-GCM encryption for sensitive data at rest. +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "io" + + "golang.org/x/crypto/bcrypt" +) + +const ( + // BcryptCost is the bcrypt work factor for password hashing. + BcryptCost = 12 +) + +// Encryptor wraps AES-256-GCM for field-level encryption. +type Encryptor struct { + key []byte // 32 bytes +} + +// New creates an Encryptor from a 32-byte key. +func New(key []byte) (*Encryptor, error) { + if len(key) != 32 { + return nil, errors.New("encryption key must be exactly 32 bytes") + } + k := make([]byte, 32) + copy(k, key) + return &Encryptor{key: k}, nil +} + +// Encrypt encrypts plaintext using AES-256-GCM and returns a base64-encoded ciphertext. +// Format: base64(nonce || ciphertext || tag) +func (e *Encryptor) Encrypt(plaintext string) (string, error) { + if plaintext == "" { + return "", nil + } + + block, err := aes.NewCipher(e.key) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + // Seal appends ciphertext+tag to nonce + ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// Decrypt decrypts a base64-encoded ciphertext produced by Encrypt. +func (e *Encryptor) Decrypt(encoded string) (string, error) { + if encoded == "" { + return "", nil + } + + data, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", err + } + + block, err := aes.NewCipher(e.key) + if err != nil { + return "", err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + + nonceSize := gcm.NonceSize() + if len(data) < nonceSize { + return "", errors.New("ciphertext too short") + } + + nonce, ciphertext := data[:nonceSize], data[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", errors.New("decryption failed: data may be corrupted or key is wrong") + } + + return string(plaintext), nil +} + +// EncryptBytes encrypts raw bytes and returns base64 encoding. +func (e *Encryptor) EncryptBytes(data []byte) (string, error) { + return e.Encrypt(string(data)) +} + +// DecryptBytes decrypts to raw bytes. +func (e *Encryptor) DecryptBytes(encoded string) ([]byte, error) { + s, err := e.Decrypt(encoded) + if err != nil { + return nil, err + } + return []byte(s), nil +} + +// ---- Password hashing ---- + +// HashPassword hashes a password using bcrypt. +func HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), BcryptCost) + if err != nil { + return "", err + } + return string(hash), nil +} + +// CheckPassword compares a plaintext password against a bcrypt hash. +func CheckPassword(password, hash string) error { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) +} + +// GenerateToken generates a cryptographically secure random token of n bytes, base64-encoded. +func GenerateToken(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(b), nil +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..3b229b0 --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,1060 @@ +// Package db provides encrypted SQLite storage for GoMail. +package db + +import ( + "database/sql" + "fmt" + "strings" + "time" + + "github.com/yourusername/gomail/internal/crypto" + "github.com/yourusername/gomail/internal/models" + + _ "github.com/mattn/go-sqlite3" +) + +// DB wraps a SQLite database with field-level AES-256 encryption. +type DB struct { + sql *sql.DB + enc *crypto.Encryptor +} + +// New opens (or creates) a SQLite database at path, using encKey for field encryption. +func New(path string, encKey []byte) (*DB, error) { + enc, err := crypto.New(encKey) + if err != nil { + return nil, fmt.Errorf("encryptor init: %w", err) + } + + // Enable WAL mode and foreign keys for performance and integrity + dsn := fmt.Sprintf("%s?_journal_mode=WAL&_foreign_keys=on&_busy_timeout=5000", path) + sqlDB, err := sql.Open("sqlite3", dsn) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + sqlDB.SetMaxOpenConns(1) // SQLite is single-writer + + return &DB{sql: sqlDB, enc: enc}, nil +} + +// Close closes the underlying database. +func (d *DB) Close() error { + return d.sql.Close() +} + +// ---- Migrations ---- + +// Migrate creates all required tables and bootstraps the admin account. +func (d *DB) Migrate() error { + stmts := []string{ + `CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE COLLATE NOCASE, + username TEXT NOT NULL DEFAULT '', + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + is_active INTEGER NOT NULL DEFAULT 1, + mfa_enabled INTEGER NOT NULL DEFAULT 0, + mfa_secret TEXT NOT NULL DEFAULT '', + mfa_pending TEXT NOT NULL DEFAULT '', + sync_interval INTEGER NOT NULL DEFAULT 15, + last_login_at DATETIME, + created_at DATETIME DEFAULT (datetime('now')), + updated_at DATETIME DEFAULT (datetime('now')) + )`, + `CREATE TABLE IF NOT EXISTS email_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + email_address TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + access_token TEXT NOT NULL DEFAULT '', + refresh_token TEXT NOT NULL DEFAULT '', + token_expiry DATETIME, + imap_host TEXT NOT NULL DEFAULT '', + imap_port INTEGER NOT NULL DEFAULT 0, + smtp_host TEXT NOT NULL DEFAULT '', + smtp_port INTEGER NOT NULL DEFAULT 0, + last_error TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT '#4A90D9', + is_active INTEGER NOT NULL DEFAULT 1, + last_sync DATETIME, + created_at DATETIME DEFAULT (datetime('now')) + )`, + `CREATE INDEX IF NOT EXISTS idx_accounts_user ON email_accounts(user_id)`, + `CREATE TABLE IF NOT EXISTS folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE, + name TEXT NOT NULL, + full_path TEXT NOT NULL, + folder_type TEXT NOT NULL DEFAULT 'custom', + unread_count INTEGER NOT NULL DEFAULT 0, + total_count INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_folders_account_path ON folders(account_id, full_path)`, + `CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL REFERENCES email_accounts(id) ON DELETE CASCADE, + folder_id INTEGER NOT NULL REFERENCES folders(id) ON DELETE CASCADE, + remote_uid TEXT NOT NULL, + thread_id TEXT NOT NULL DEFAULT '', + message_id TEXT NOT NULL DEFAULT '', + subject TEXT NOT NULL DEFAULT '', + from_name TEXT NOT NULL DEFAULT '', + from_email TEXT NOT NULL DEFAULT '', + to_list TEXT NOT NULL DEFAULT '', + cc_list TEXT NOT NULL DEFAULT '', + bcc_list TEXT NOT NULL DEFAULT '', + reply_to TEXT NOT NULL DEFAULT '', + body_text TEXT NOT NULL DEFAULT '', + body_html TEXT NOT NULL DEFAULT '', + date DATETIME NOT NULL, + is_read INTEGER NOT NULL DEFAULT 0, + is_starred INTEGER NOT NULL DEFAULT 0, + is_draft INTEGER NOT NULL DEFAULT 0, + has_attachment INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT (datetime('now')) + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_uid ON messages(account_id, folder_id, remote_uid)`, + `CREATE INDEX IF NOT EXISTS idx_messages_date ON messages(date DESC)`, + `CREATE INDEX IF NOT EXISTS idx_messages_account ON messages(account_id)`, + `CREATE TABLE IF NOT EXISTS attachments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + filename TEXT NOT NULL DEFAULT '', + content_type TEXT NOT NULL DEFAULT '', + size INTEGER NOT NULL DEFAULT 0, + content_id TEXT NOT NULL DEFAULT '', + data BLOB + )`, + `CREATE TABLE IF NOT EXISTS sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + mfa_verified INTEGER NOT NULL DEFAULT 0, + expires_at DATETIME NOT NULL, + created_at DATETIME DEFAULT (datetime('now')) + )`, + `CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)`, + `CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + event TEXT NOT NULL, + detail TEXT NOT NULL DEFAULT '', + ip_address TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + created_at DATETIME DEFAULT (datetime('now')) + )`, + `CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at DESC)`, + `CREATE INDEX IF NOT EXISTS idx_audit_event ON audit_log(event)`, + `CREATE TABLE IF NOT EXISTS remote_content_whitelist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + sender TEXT NOT NULL, + created_at DATETIME DEFAULT (datetime('now')), + UNIQUE(user_id, sender) + )`, + } + + for _, stmt := range stmts { + if _, err := d.sql.Exec(stmt); err != nil { + return fmt.Errorf("migration error (%s...): %w", stmt[:40], err) + } + } + + // Additive ALTER TABLE migrations — safe to re-run (SQLite ignores duplicate column errors) + alterStmts := []string{ + `ALTER TABLE email_accounts ADD COLUMN sync_days INTEGER NOT NULL DEFAULT 30`, + `ALTER TABLE email_accounts ADD COLUMN sync_mode TEXT NOT NULL DEFAULT 'days'`, + `ALTER TABLE users ADD COLUMN compose_popup INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE messages ADD COLUMN folder_path TEXT NOT NULL DEFAULT ''`, + } + for _, stmt := range alterStmts { + d.sql.Exec(stmt) // ignore "duplicate column" errors intentionally + } + + // Bootstrap admin account if no users exist + return d.bootstrapAdmin() +} + +// bootstrapAdmin creates the default admin/admin account on first run. +func (d *DB) bootstrapAdmin() error { + var count int + d.sql.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count) + if count > 0 { + return nil + } + hash, err := crypto.HashPassword("admin") + if err != nil { + return err + } + _, err = d.sql.Exec( + `INSERT INTO users (email, username, password_hash, role, is_active) + VALUES ('admin', 'admin', ?, 'admin', 1)`, hash, + ) + if err != nil { + return fmt.Errorf("bootstrap admin: %w", err) + } + fmt.Println("WARNING: Default admin account created: username=admin password=admin — CHANGE THIS IMMEDIATELY") + return nil +} + +// ---- Users ---- + +func (d *DB) CreateUser(username, email, password string, role models.UserRole) (*models.User, error) { + hash, err := crypto.HashPassword(password) + if err != nil { + return nil, err + } + res, err := d.sql.Exec( + `INSERT INTO users (email, username, password_hash, role) VALUES (?, ?, ?, ?)`, + email, username, hash, role, + ) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE") { + return nil, fmt.Errorf("email already registered") + } + return nil, err + } + id, _ := res.LastInsertId() + return d.GetUserByID(id) +} + +func (d *DB) scanUser(row *sql.Row) (*models.User, error) { + u := &models.User{} + var mfaSecretEnc, mfaPendingEnc string + var lastLogin sql.NullTime + var composePopup int + err := row.Scan( + &u.ID, &u.Email, &u.Username, &u.PasswordHash, &u.Role, &u.IsActive, + &u.MFAEnabled, &mfaSecretEnc, &mfaPendingEnc, &lastLogin, + &u.CreatedAt, &u.UpdatedAt, &u.SyncInterval, &composePopup, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + u.MFASecret, _ = d.enc.Decrypt(mfaSecretEnc) + u.MFAPending, _ = d.enc.Decrypt(mfaPendingEnc) + if lastLogin.Valid { + u.LastLoginAt = &lastLogin.Time + } + u.ComposePopup = composePopup == 1 + return u, nil +} + +const userSelectCols = `SELECT id, email, username, password_hash, role, is_active, + mfa_enabled, mfa_secret, mfa_pending, last_login_at, created_at, updated_at, + COALESCE(sync_interval,15), COALESCE(compose_popup,0) FROM users` + +func (d *DB) GetUserByEmail(email string) (*models.User, error) { + return d.scanUser(d.sql.QueryRow(userSelectCols+` WHERE email=?`, email)) +} + +func (d *DB) GetUserByUsername(username string) (*models.User, error) { + return d.scanUser(d.sql.QueryRow(userSelectCols+` WHERE username=?`, username)) +} + +func (d *DB) GetUserByID(id int64) (*models.User, error) { + return d.scanUser(d.sql.QueryRow(userSelectCols+` WHERE id=?`, id)) +} + +func (d *DB) ListUsers() ([]*models.User, error) { + rows, err := d.sql.Query(userSelectCols + ` ORDER BY created_at`) + if err != nil { + return nil, err + } + defer rows.Close() + var users []*models.User + for rows.Next() { + u := &models.User{} + var mfaSecretEnc, mfaPendingEnc string + var lastLogin sql.NullTime + if err := rows.Scan( + &u.ID, &u.Email, &u.Username, &u.PasswordHash, &u.Role, &u.IsActive, + &u.MFAEnabled, &mfaSecretEnc, &mfaPendingEnc, &lastLogin, + &u.CreatedAt, &u.UpdatedAt, + ); err != nil { + return nil, err + } + u.MFASecret, _ = d.enc.Decrypt(mfaSecretEnc) + u.MFAPending, _ = d.enc.Decrypt(mfaPendingEnc) + if lastLogin.Valid { + u.LastLoginAt = &lastLogin.Time + } + users = append(users, u) + } + return users, rows.Err() +} + +func (d *DB) UpdateUserPassword(userID int64, newPassword string) error { + hash, err := crypto.HashPassword(newPassword) + if err != nil { + return err + } + _, err = d.sql.Exec( + `UPDATE users SET password_hash=?, updated_at=datetime('now') WHERE id=?`, hash, userID, + ) + return err +} + +func (d *DB) SetUserActive(userID int64, active bool) error { + v := 0 + if active { + v = 1 + } + _, err := d.sql.Exec(`UPDATE users SET is_active=?, updated_at=datetime('now') WHERE id=?`, v, userID) + return err +} + +func (d *DB) DeleteUser(userID int64) error { + _, err := d.sql.Exec(`DELETE FROM users WHERE id=? AND role != 'admin'`, userID) + return err +} + +func (d *DB) TouchLastLogin(userID int64) { + d.sql.Exec(`UPDATE users SET last_login_at=datetime('now') WHERE id=?`, userID) +} + +// ---- MFA ---- + +func (d *DB) SetMFAPending(userID int64, secret string) error { + enc, err := d.enc.Encrypt(secret) + if err != nil { + return err + } + _, err = d.sql.Exec(`UPDATE users SET mfa_pending=?, updated_at=datetime('now') WHERE id=?`, enc, userID) + return err +} + +func (d *DB) EnableMFA(userID int64, secret string) error { + enc, err := d.enc.Encrypt(secret) + if err != nil { + return err + } + _, err = d.sql.Exec( + `UPDATE users SET mfa_enabled=1, mfa_secret=?, mfa_pending='', updated_at=datetime('now') WHERE id=?`, + enc, userID, + ) + return err +} + +func (d *DB) DisableMFA(userID int64) error { + _, err := d.sql.Exec( + `UPDATE users SET mfa_enabled=0, mfa_secret='', mfa_pending='', updated_at=datetime('now') WHERE id=?`, + userID, + ) + return err +} + +// ---- Sessions ---- + +func (d *DB) CreateSession(userID int64, ttl time.Duration) (string, error) { + token, err := crypto.GenerateToken(32) + if err != nil { + return "", err + } + expiry := time.Now().Add(ttl) + _, err = d.sql.Exec( + `INSERT INTO sessions (token, user_id, mfa_verified, expires_at) VALUES (?, ?, 0, ?)`, + token, userID, expiry, + ) + return token, err +} + +func (d *DB) SetSessionMFAVerified(token string) error { + _, err := d.sql.Exec(`UPDATE sessions SET mfa_verified=1 WHERE token=?`, token) + return err +} + +func (d *DB) GetSession(token string) (userID int64, mfaVerified bool, err error) { + var expiresAt time.Time + err = d.sql.QueryRow( + `SELECT user_id, mfa_verified, expires_at FROM sessions WHERE token=?`, token, + ).Scan(&userID, &mfaVerified, &expiresAt) + if err == sql.ErrNoRows { + return 0, false, nil + } + if err != nil { + return 0, false, err + } + if time.Now().After(expiresAt) { + d.sql.Exec(`DELETE FROM sessions WHERE token=?`, token) + return 0, false, nil + } + return userID, mfaVerified, nil +} + +func (d *DB) DeleteSession(token string) error { + _, err := d.sql.Exec(`DELETE FROM sessions WHERE token=?`, token) + return err +} + +// ---- Audit Log ---- + +func (d *DB) WriteAudit(userID *int64, event models.AuditEventType, detail, ip, ua string) { + d.sql.Exec( + `INSERT INTO audit_log (user_id, event, detail, ip_address, user_agent) VALUES (?,?,?,?,?)`, + userID, string(event), detail, ip, ua, + ) +} + +func (d *DB) ListAuditLogs(page, pageSize int, eventFilter string) (*models.AuditPage, error) { + offset := (page - 1) * pageSize + where := "" + args := []interface{}{} + if eventFilter != "" { + where = " WHERE a.event=?" + args = append(args, eventFilter) + } + + var total int + d.sql.QueryRow(`SELECT COUNT(*) FROM audit_log a`+where, args...).Scan(&total) + + args = append(args, pageSize, offset) + rows, err := d.sql.Query(` + SELECT a.id, a.user_id, COALESCE(u.email,''), a.event, a.detail, a.ip_address, a.user_agent, a.created_at + FROM audit_log a LEFT JOIN users u ON u.id=a.user_id`+where+` + ORDER BY a.created_at DESC LIMIT ? OFFSET ?`, args..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var logs []models.AuditLog + for rows.Next() { + l := models.AuditLog{} + var uid sql.NullInt64 + if err := rows.Scan(&l.ID, &uid, &l.UserEmail, &l.Event, &l.Detail, &l.IPAddress, &l.UserAgent, &l.CreatedAt); err != nil { + return nil, err + } + if uid.Valid { + l.UserID = &uid.Int64 + } + logs = append(logs, l) + } + return &models.AuditPage{ + Logs: logs, Total: total, Page: page, PageSize: pageSize, + HasMore: offset+len(logs) < total, + }, rows.Err() +} + + +// ---- Email Accounts ---- + +func (d *DB) CreateAccount(a *models.EmailAccount) error { + accessEnc, _ := d.enc.Encrypt(a.AccessToken) + refreshEnc, _ := d.enc.Encrypt(a.RefreshToken) + imapHostEnc, _ := d.enc.Encrypt(a.IMAPHost) + smtpHostEnc, _ := d.enc.Encrypt(a.SMTPHost) + + res, err := d.sql.Exec(` + INSERT INTO email_accounts + (user_id, provider, email_address, display_name, access_token, refresh_token, + token_expiry, imap_host, imap_port, smtp_host, smtp_port, color) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, + a.UserID, a.Provider, a.EmailAddress, a.DisplayName, + accessEnc, refreshEnc, a.TokenExpiry, + imapHostEnc, a.IMAPPort, smtpHostEnc, a.SMTPPort, + a.Color, + ) + if err != nil { + return err + } + id, _ := res.LastInsertId() + a.ID = id + return nil +} + +func (d *DB) UpdateAccountTokens(accountID int64, accessToken, refreshToken string, expiry time.Time) error { + accessEnc, _ := d.enc.Encrypt(accessToken) + refreshEnc, _ := d.enc.Encrypt(refreshToken) + _, err := d.sql.Exec( + `UPDATE email_accounts SET access_token=?, refresh_token=?, token_expiry=? WHERE id=?`, + accessEnc, refreshEnc, expiry, accountID, + ) + return err +} + +func (d *DB) UpdateAccountLastSync(accountID int64) error { + _, err := d.sql.Exec(`UPDATE email_accounts SET last_sync=? WHERE id=?`, time.Now(), accountID) + return err +} + +func (d *DB) GetAccount(accountID int64) (*models.EmailAccount, error) { + a := &models.EmailAccount{} + var accessEnc, refreshEnc, imapHostEnc, smtpHostEnc string + var lastSync sql.NullTime + err := d.sql.QueryRow(` + SELECT id, user_id, provider, email_address, display_name, + access_token, refresh_token, token_expiry, + imap_host, imap_port, smtp_host, smtp_port, + last_error, color, is_active, last_sync, created_at, + COALESCE(sync_days,30), COALESCE(sync_mode,'days') + FROM email_accounts WHERE id=?`, accountID, + ).Scan( + &a.ID, &a.UserID, &a.Provider, &a.EmailAddress, &a.DisplayName, + &accessEnc, &refreshEnc, &a.TokenExpiry, + &imapHostEnc, &a.IMAPPort, &smtpHostEnc, &a.SMTPPort, + &a.LastError, &a.Color, &a.IsActive, &lastSync, &a.CreatedAt, + &a.SyncDays, &a.SyncMode, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + a.AccessToken, _ = d.enc.Decrypt(accessEnc) + a.RefreshToken, _ = d.enc.Decrypt(refreshEnc) + a.IMAPHost, _ = d.enc.Decrypt(imapHostEnc) + a.SMTPHost, _ = d.enc.Decrypt(smtpHostEnc) + if lastSync.Valid { + a.LastSync = lastSync.Time + } + if a.SyncDays == 0 { + a.SyncDays = 30 + } + return a, nil +} + +func (d *DB) GetUserSyncInterval(userID int64) (int, error) { + var interval int + err := d.sql.QueryRow(`SELECT sync_interval FROM users WHERE id=?`, userID).Scan(&interval) + if err != nil { + return 15, err + } + return interval, nil +} + +func (d *DB) SetUserSyncInterval(userID int64, minutes int) error { + _, err := d.sql.Exec(`UPDATE users SET sync_interval=? WHERE id=?`, minutes, userID) + return err +} + +func (d *DB) SetComposePopup(userID int64, popup bool) error { + v := 0 + if popup { + v = 1 + } + _, err := d.sql.Exec(`UPDATE users SET compose_popup=? WHERE id=?`, v, userID) + return err +} + +func (d *DB) SetAccountSyncSettings(accountID, userID int64, syncDays int, syncMode string) error { + if syncMode == "" { + syncMode = "days" + } + _, err := d.sql.Exec(`UPDATE email_accounts SET sync_days=?, sync_mode=? WHERE id=? AND user_id=?`, + syncDays, syncMode, accountID, userID) + return err +} + +func (d *DB) UpdateAccount(a *models.EmailAccount) error { + accessEnc, _ := d.enc.Encrypt(a.AccessToken) + imapHostEnc, _ := d.enc.Encrypt(a.IMAPHost) + smtpHostEnc, _ := d.enc.Encrypt(a.SMTPHost) + syncMode := a.SyncMode + if syncMode == "" { + syncMode = "days" + } + syncDays := a.SyncDays + if syncDays == 0 { + syncDays = 30 + } + _, err := d.sql.Exec(` + UPDATE email_accounts SET + display_name=?, access_token=?, + imap_host=?, imap_port=?, smtp_host=?, smtp_port=?, + color=?, sync_days=?, sync_mode=? + WHERE id=? AND user_id=?`, + a.DisplayName, accessEnc, + imapHostEnc, a.IMAPPort, smtpHostEnc, a.SMTPPort, + a.Color, syncDays, syncMode, a.ID, a.UserID, + ) + return err +} + +func (d *DB) SetAccountError(accountID int64, errMsg string) { + d.sql.Exec(`UPDATE email_accounts SET last_error=? WHERE id=?`, errMsg, accountID) +} + +func (d *DB) ClearAccountError(accountID int64) { + d.sql.Exec(`UPDATE email_accounts SET last_error='' WHERE id=?`, accountID) +} + +// ListAllActiveAccounts returns all active accounts joined with their user's sync_interval. +func (d *DB) ListAllActiveAccounts() ([]*models.EmailAccount, error) { + rows, err := d.sql.Query(` + SELECT a.id, a.user_id, a.provider, a.email_address, a.display_name, + a.access_token, a.refresh_token, a.token_expiry, + a.imap_host, a.imap_port, a.smtp_host, a.smtp_port, + a.last_error, a.color, a.is_active, a.last_sync, a.created_at, + u.sync_interval + FROM email_accounts a + JOIN users u ON u.id = a.user_id + WHERE a.is_active=1 AND u.is_active=1`) + if err != nil { + return nil, err + } + defer rows.Close() + + var accounts []*models.EmailAccount + for rows.Next() { + a := &models.EmailAccount{} + var accessEnc, refreshEnc, imapHostEnc, smtpHostEnc string + var lastSync sql.NullTime + if err := rows.Scan( + &a.ID, &a.UserID, &a.Provider, &a.EmailAddress, &a.DisplayName, + &accessEnc, &refreshEnc, &a.TokenExpiry, + &imapHostEnc, &a.IMAPPort, &smtpHostEnc, &a.SMTPPort, + &a.LastError, &a.Color, &a.IsActive, &lastSync, &a.CreatedAt, + &a.SyncInterval, + ); err != nil { + return nil, err + } + a.AccessToken, _ = d.enc.Decrypt(accessEnc) + a.RefreshToken, _ = d.enc.Decrypt(refreshEnc) + a.IMAPHost, _ = d.enc.Decrypt(imapHostEnc) + a.SMTPHost, _ = d.enc.Decrypt(smtpHostEnc) + if lastSync.Valid { + a.LastSync = lastSync.Time + } + accounts = append(accounts, a) + } + return accounts, rows.Err() +} + +func (d *DB) ListAccountsByUser(userID int64) ([]*models.EmailAccount, error) { + rows, err := d.sql.Query(` + SELECT id, user_id, provider, email_address, display_name, + access_token, refresh_token, token_expiry, + imap_host, imap_port, smtp_host, smtp_port, + last_error, color, is_active, last_sync, created_at + FROM email_accounts WHERE user_id=? AND is_active=1 ORDER BY created_at`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + return d.scanAccounts(rows) +} + +func (d *DB) scanAccounts(rows *sql.Rows) ([]*models.EmailAccount, error) { + var accounts []*models.EmailAccount + for rows.Next() { + a := &models.EmailAccount{} + var accessEnc, refreshEnc, imapHostEnc, smtpHostEnc string + var lastSync sql.NullTime + if err := rows.Scan( + &a.ID, &a.UserID, &a.Provider, &a.EmailAddress, &a.DisplayName, + &accessEnc, &refreshEnc, &a.TokenExpiry, + &imapHostEnc, &a.IMAPPort, &smtpHostEnc, &a.SMTPPort, + &a.LastError, &a.Color, &a.IsActive, &lastSync, &a.CreatedAt, + ); err != nil { + return nil, err + } + a.AccessToken, _ = d.enc.Decrypt(accessEnc) + a.RefreshToken, _ = d.enc.Decrypt(refreshEnc) + a.IMAPHost, _ = d.enc.Decrypt(imapHostEnc) + a.SMTPHost, _ = d.enc.Decrypt(smtpHostEnc) + if lastSync.Valid { + a.LastSync = lastSync.Time + } + accounts = append(accounts, a) + } + return accounts, rows.Err() +} + +func (d *DB) DeleteAccount(accountID, userID int64) error { + _, err := d.sql.Exec( + `DELETE FROM email_accounts WHERE id=? AND user_id=?`, accountID, userID, + ) + return err +} + +func (d *DB) UpdateFolderCounts(folderID int64) { + d.sql.Exec(` + UPDATE folders SET + total_count = (SELECT COUNT(*) FROM messages WHERE folder_id=?), + unread_count = (SELECT COUNT(*) FROM messages WHERE folder_id=? AND is_read=0) + WHERE id=?`, folderID, folderID, folderID) +} + +// ---- Folders ---- + +func (d *DB) UpsertFolder(f *models.Folder) error { + _, err := d.sql.Exec(` + INSERT INTO folders (account_id, name, full_path, folder_type, unread_count, total_count) + VALUES (?,?,?,?,?,?) + ON CONFLICT(account_id, full_path) DO UPDATE SET + name=excluded.name, + folder_type=excluded.folder_type, + unread_count=excluded.unread_count, + total_count=excluded.total_count`, + f.AccountID, f.Name, f.FullPath, f.FolderType, f.UnreadCount, f.TotalCount, + ) + return err +} + +func (d *DB) GetFolderByPath(accountID int64, fullPath string) (*models.Folder, error) { + f := &models.Folder{} + err := d.sql.QueryRow( + `SELECT id, account_id, name, full_path, folder_type, unread_count, total_count + FROM folders WHERE account_id=? AND full_path=?`, accountID, fullPath, + ).Scan(&f.ID, &f.AccountID, &f.Name, &f.FullPath, &f.FolderType, &f.UnreadCount, &f.TotalCount) + if err == sql.ErrNoRows { + return nil, nil + } + return f, err +} + +func (d *DB) ListFoldersByAccount(accountID int64) ([]*models.Folder, error) { + rows, err := d.sql.Query( + `SELECT id, account_id, name, full_path, folder_type, unread_count, total_count + FROM folders WHERE account_id=? ORDER BY folder_type, name`, accountID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var folders []*models.Folder + for rows.Next() { + f := &models.Folder{} + if err := rows.Scan(&f.ID, &f.AccountID, &f.Name, &f.FullPath, &f.FolderType, &f.UnreadCount, &f.TotalCount); err != nil { + return nil, err + } + folders = append(folders, f) + } + return folders, rows.Err() +} + +// ---- Messages ---- + +func (d *DB) UpsertMessage(m *models.Message) error { + subjectEnc, _ := d.enc.Encrypt(m.Subject) + fromNameEnc, _ := d.enc.Encrypt(m.FromName) + fromEmailEnc, _ := d.enc.Encrypt(m.FromEmail) + toEnc, _ := d.enc.Encrypt(m.ToList) + ccEnc, _ := d.enc.Encrypt(m.CCList) + bccEnc, _ := d.enc.Encrypt(m.BCCList) + replyToEnc, _ := d.enc.Encrypt(m.ReplyTo) + bodyTextEnc, _ := d.enc.Encrypt(m.BodyText) + bodyHTMLEnc, _ := d.enc.Encrypt(m.BodyHTML) + + res, err := d.sql.Exec(` + INSERT INTO messages + (account_id, folder_id, remote_uid, thread_id, message_id, + subject, from_name, from_email, to_list, cc_list, bcc_list, reply_to, + body_text, body_html, date, is_read, is_starred, is_draft, has_attachment) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(account_id, folder_id, remote_uid) DO UPDATE SET + is_read=excluded.is_read, + is_starred=excluded.is_starred`, + m.AccountID, m.FolderID, m.RemoteUID, m.ThreadID, m.MessageID, + subjectEnc, fromNameEnc, fromEmailEnc, toEnc, ccEnc, bccEnc, replyToEnc, + bodyTextEnc, bodyHTMLEnc, m.Date, + m.IsRead, m.IsStarred, m.IsDraft, m.HasAttachment, + ) + if err != nil { + return err + } + id, _ := res.LastInsertId() + if m.ID == 0 { + m.ID = id + } + return nil +} + +func (d *DB) GetMessage(messageID, userID int64) (*models.Message, error) { + m := &models.Message{} + var subjectEnc, fromNameEnc, fromEmailEnc, toEnc, ccEnc, bccEnc, replyToEnc, bodyTextEnc, bodyHTMLEnc string + + err := d.sql.QueryRow(` + SELECT m.id, m.account_id, m.folder_id, m.remote_uid, m.thread_id, m.message_id, + m.subject, m.from_name, m.from_email, m.to_list, m.cc_list, m.bcc_list, + m.reply_to, m.body_text, m.body_html, + m.date, m.is_read, m.is_starred, m.is_draft, m.has_attachment, m.created_at + FROM messages m + JOIN email_accounts a ON a.id = m.account_id + WHERE m.id=? AND a.user_id=?`, messageID, userID, + ).Scan( + &m.ID, &m.AccountID, &m.FolderID, &m.RemoteUID, &m.ThreadID, &m.MessageID, + &subjectEnc, &fromNameEnc, &fromEmailEnc, &toEnc, &ccEnc, &bccEnc, + &replyToEnc, &bodyTextEnc, &bodyHTMLEnc, + &m.Date, &m.IsRead, &m.IsStarred, &m.IsDraft, &m.HasAttachment, &m.CreatedAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + m.Subject, _ = d.enc.Decrypt(subjectEnc) + m.FromName, _ = d.enc.Decrypt(fromNameEnc) + m.FromEmail, _ = d.enc.Decrypt(fromEmailEnc) + m.ToList, _ = d.enc.Decrypt(toEnc) + m.CCList, _ = d.enc.Decrypt(ccEnc) + m.BCCList, _ = d.enc.Decrypt(bccEnc) + m.ReplyTo, _ = d.enc.Decrypt(replyToEnc) + m.BodyText, _ = d.enc.Decrypt(bodyTextEnc) + m.BodyHTML, _ = d.enc.Decrypt(bodyHTMLEnc) + + return m, nil +} + +func (d *DB) ListMessages(userID int64, folderIDs []int64, accountID int64, page, pageSize int) (*models.PagedMessages, error) { + offset := (page - 1) * pageSize + args := []interface{}{userID} + + where := "a.user_id=?" + if accountID > 0 { + where += " AND m.account_id=?" + args = append(args, accountID) + } + if len(folderIDs) > 0 { + placeholders := make([]string, len(folderIDs)) + for i, fid := range folderIDs { + placeholders[i] = "?" + args = append(args, fid) + } + where += " AND m.folder_id IN (" + strings.Join(placeholders, ",") + ")" + } + + countArgs := make([]interface{}, len(args)) + copy(countArgs, args) + + var total int + d.sql.QueryRow("SELECT COUNT(*) FROM messages m JOIN email_accounts a ON a.id=m.account_id WHERE "+where, countArgs...).Scan(&total) + + args = append(args, pageSize, offset) + rows, err := d.sql.Query(` + SELECT m.id, m.account_id, a.email_address, a.color, m.folder_id, f.name, + m.subject, m.from_name, m.from_email, m.body_text, + m.date, m.is_read, m.is_starred, m.has_attachment + FROM messages m + JOIN email_accounts a ON a.id = m.account_id + JOIN folders f ON f.id = m.folder_id + WHERE `+where+` + ORDER BY m.date DESC + LIMIT ? OFFSET ?`, args..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var summaries []models.MessageSummary + for rows.Next() { + s := models.MessageSummary{} + var subjectEnc, fromNameEnc, fromEmailEnc, bodyTextEnc string + if err := rows.Scan( + &s.ID, &s.AccountID, &s.AccountEmail, &s.AccountColor, &s.FolderID, &s.FolderName, + &subjectEnc, &fromNameEnc, &fromEmailEnc, &bodyTextEnc, + &s.Date, &s.IsRead, &s.IsStarred, &s.HasAttachment, + ); err != nil { + return nil, err + } + s.Subject, _ = d.enc.Decrypt(subjectEnc) + s.FromName, _ = d.enc.Decrypt(fromNameEnc) + s.FromEmail, _ = d.enc.Decrypt(fromEmailEnc) + bodyText, _ := d.enc.Decrypt(bodyTextEnc) + if len(bodyText) > 120 { + bodyText = bodyText[:120] + "…" + } + s.Preview = bodyText + summaries = append(summaries, s) + } + if err := rows.Err(); err != nil { + return nil, err + } + + return &models.PagedMessages{ + Messages: summaries, + Total: total, + Page: page, + PageSize: pageSize, + HasMore: offset+len(summaries) < total, + }, nil +} + +func (d *DB) SearchMessages(userID int64, q string, page, pageSize int) (*models.PagedMessages, error) { + offset := (page - 1) * pageSize + like := "%" + q + "%" + args := []interface{}{userID, like, like, like, like, pageSize, offset} + + var total int + d.sql.QueryRow(` + SELECT COUNT(*) FROM messages m + JOIN email_accounts a ON a.id=m.account_id + WHERE a.user_id=? AND (m.subject LIKE ? OR m.from_email LIKE ? OR m.from_name LIKE ? OR m.body_text LIKE ?)`, + userID, like, like, like, like, + ).Scan(&total) + + rows, err := d.sql.Query(` + SELECT m.id, m.account_id, a.email_address, a.color, m.folder_id, f.name, + m.subject, m.from_name, m.from_email, m.body_text, + m.date, m.is_read, m.is_starred, m.has_attachment + FROM messages m + JOIN email_accounts a ON a.id=m.account_id + JOIN folders f ON f.id=m.folder_id + WHERE a.user_id=? AND (m.subject LIKE ? OR m.from_email LIKE ? OR m.from_name LIKE ? OR m.body_text LIKE ?) + ORDER BY m.date DESC LIMIT ? OFFSET ?`, args..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var summaries []models.MessageSummary + for rows.Next() { + s := models.MessageSummary{} + var subjectEnc, fromNameEnc, fromEmailEnc, bodyTextEnc string + if err := rows.Scan( + &s.ID, &s.AccountID, &s.AccountEmail, &s.AccountColor, &s.FolderID, &s.FolderName, + &subjectEnc, &fromNameEnc, &fromEmailEnc, &bodyTextEnc, + &s.Date, &s.IsRead, &s.IsStarred, &s.HasAttachment, + ); err != nil { + return nil, err + } + s.Subject, _ = d.enc.Decrypt(subjectEnc) + s.FromName, _ = d.enc.Decrypt(fromNameEnc) + s.FromEmail, _ = d.enc.Decrypt(fromEmailEnc) + bodyText, _ := d.enc.Decrypt(bodyTextEnc) + if len(bodyText) > 120 { + bodyText = bodyText[:120] + "…" + } + s.Preview = bodyText + summaries = append(summaries, s) + } + + return &models.PagedMessages{ + Messages: summaries, Total: total, Page: page, PageSize: pageSize, + HasMore: offset+len(summaries) < total, + }, rows.Err() +} + +func (d *DB) MarkMessageRead(messageID, userID int64, read bool) error { + val := 0 + if read { + val = 1 + } + _, err := d.sql.Exec(` + UPDATE messages SET is_read=? + WHERE id=? AND account_id IN (SELECT id FROM email_accounts WHERE user_id=?)`, + val, messageID, userID, + ) + return err +} + +func (d *DB) ToggleMessageStar(messageID, userID int64) (bool, error) { + var current bool + err := d.sql.QueryRow(` + SELECT is_starred FROM messages + WHERE id=? AND account_id IN (SELECT id FROM email_accounts WHERE user_id=?)`, + messageID, userID, + ).Scan(¤t) + if err != nil { + return false, err + } + newVal := !current + intVal := 0 + if newVal { + intVal = 1 + } + _, err = d.sql.Exec(`UPDATE messages SET is_starred=? WHERE id=?`, intVal, messageID) + return newVal, err +} + +func (d *DB) MoveMessage(messageID, userID, folderID int64) error { + _, err := d.sql.Exec(` + UPDATE messages SET folder_id=? + WHERE id=? AND account_id IN (SELECT id FROM email_accounts WHERE user_id=?)`, + folderID, messageID, userID, + ) + return err +} + +func (d *DB) DeleteMessage(messageID, userID int64) error { + _, err := d.sql.Exec(` + DELETE FROM messages WHERE id=? + AND account_id IN (SELECT id FROM email_accounts WHERE user_id=?)`, + messageID, userID, + ) + return err +} + +func (d *DB) GetFoldersByUser(userID int64) ([]*models.Folder, error) { + rows, err := d.sql.Query(` + SELECT f.id, f.account_id, f.name, f.full_path, f.folder_type, f.unread_count, f.total_count + FROM folders f + JOIN email_accounts a ON a.id=f.account_id + WHERE a.user_id=? + ORDER BY a.created_at, f.folder_type, f.name`, userID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var folders []*models.Folder + for rows.Next() { + f := &models.Folder{} + if err := rows.Scan(&f.ID, &f.AccountID, &f.Name, &f.FullPath, &f.FolderType, &f.UnreadCount, &f.TotalCount); err != nil { + return nil, err + } + folders = append(folders, f) + } + return folders, rows.Err() +} + +// ---- Remote Content Whitelist ---- + +func (d *DB) GetRemoteContentWhitelist(userID int64) ([]string, error) { + rows, err := d.sql.Query( + `SELECT sender FROM remote_content_whitelist WHERE user_id=? ORDER BY sender`, + userID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var list []string + for rows.Next() { + var s string + if err := rows.Scan(&s); err == nil { + list = append(list, s) + } + } + return list, rows.Err() +} + +func (d *DB) AddRemoteContentWhitelist(userID int64, sender string) error { + _, err := d.sql.Exec( + `INSERT OR IGNORE INTO remote_content_whitelist (user_id, sender) VALUES (?, ?)`, + userID, sender, + ) + return err +} + +func (d *DB) IsRemoteContentAllowed(userID int64, sender string) (bool, error) { + var count int + err := d.sql.QueryRow( + `SELECT COUNT(*) FROM remote_content_whitelist WHERE user_id=? AND sender=?`, + userID, sender, + ).Scan(&count) + return count > 0, err +} + +func (d *DB) GetFolderByID(folderID int64) (*models.Folder, error) { + f := &models.Folder{} + err := d.sql.QueryRow( + `SELECT id, account_id, name, full_path, folder_type, unread_count, total_count + FROM folders WHERE id=?`, folderID, + ).Scan(&f.ID, &f.AccountID, &f.Name, &f.FullPath, &f.FolderType, &f.UnreadCount, &f.TotalCount) + if err == sql.ErrNoRows { + return nil, nil + } + return f, err +} diff --git a/internal/email/imap.go b/internal/email/imap.go new file mode 100644 index 0000000..4e2f446 --- /dev/null +++ b/internal/email/imap.go @@ -0,0 +1,759 @@ +// Package email provides IMAP fetch/sync and SMTP send. +package email + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/base64" + "fmt" + "io" + "log" + "mime" + "mime/multipart" + "mime/quotedprintable" + netmail "net/mail" + "net/smtp" + "path/filepath" + "strings" + "time" + + "github.com/emersion/go-imap" + "github.com/emersion/go-imap/client" + + gomailModels "github.com/yourusername/gomail/internal/models" +) + +func imapHostFor(provider gomailModels.AccountProvider) (string, int) { + switch provider { + case gomailModels.ProviderGmail: + return "imap.gmail.com", 993 + case gomailModels.ProviderOutlook: + return "outlook.office365.com", 993 + default: + return "", 993 + } +} + +func smtpHostFor(provider gomailModels.AccountProvider) (string, int) { + switch provider { + case gomailModels.ProviderGmail: + return "smtp.gmail.com", 587 + case gomailModels.ProviderOutlook: + return "smtp.office365.com", 587 + default: + return "", 587 + } +} + +// ---- SASL / OAuth2 Auth ---- + +type xoauth2Client struct{ user, token string } + +func (x *xoauth2Client) Start() (string, []byte, error) { + payload := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", x.user, x.token) + return "XOAUTH2", []byte(payload), nil +} +func (x *xoauth2Client) Next([]byte) ([]byte, error) { return []byte{}, nil } + +type xoauth2SMTP struct{ user, token string } + +func (x *xoauth2SMTP) Start(_ *smtp.ServerInfo) (string, []byte, error) { + payload := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", x.user, x.token) + return "XOAUTH2", []byte(payload), nil +} +func (x *xoauth2SMTP) Next(fromServer []byte, more bool) ([]byte, error) { + if more { + if dec, err := base64.StdEncoding.DecodeString(string(fromServer)); err == nil { + return nil, fmt.Errorf("XOAUTH2 error: %s", dec) + } + return nil, fmt.Errorf("XOAUTH2 error: %s", fromServer) + } + return nil, nil +} + +// ---- IMAP Client ---- + +type Client struct { + imap *client.Client + account *gomailModels.EmailAccount +} + +func Connect(ctx context.Context, account *gomailModels.EmailAccount) (*Client, error) { + host, port := imapHostFor(account.Provider) + if account.IMAPHost != "" { + host = account.IMAPHost + port = account.IMAPPort + } + if host == "" { + return nil, fmt.Errorf("IMAP host not configured for account %s", account.EmailAddress) + } + + addr := fmt.Sprintf("%s:%d", host, port) + var c *client.Client + var err error + + if port == 993 { + c, err = client.DialTLS(addr, &tls.Config{ServerName: host}) + } else { + c, err = client.Dial(addr) + if err == nil { + // Attempt STARTTLS; ignore error if server doesn't support it + _ = c.StartTLS(&tls.Config{ServerName: host}) + } + } + if err != nil { + return nil, fmt.Errorf("IMAP connect %s: %w", addr, err) + } + + switch account.Provider { + case gomailModels.ProviderGmail, gomailModels.ProviderOutlook: + sasl := &xoauth2Client{user: account.EmailAddress, token: account.AccessToken} + if err := c.Authenticate(sasl); err != nil { + c.Logout() + return nil, fmt.Errorf("IMAP OAuth auth failed: %w", err) + } + default: + if err := c.Login(account.EmailAddress, account.AccessToken); err != nil { + c.Logout() + return nil, fmt.Errorf("IMAP login failed for %s: %w", account.EmailAddress, err) + } + } + + return &Client{imap: c, account: account}, nil +} + +func TestConnection(account *gomailModels.EmailAccount) error { + c, err := Connect(context.Background(), account) + if err != nil { + return err + } + c.Close() + return nil +} + +func (c *Client) Close() { c.imap.Logout() } + +func (c *Client) ListMailboxes() ([]*imap.MailboxInfo, error) { + ch := make(chan *imap.MailboxInfo, 64) + done := make(chan error, 1) + go func() { done <- c.imap.List("", "*", ch) }() + var result []*imap.MailboxInfo + for mb := range ch { + result = append(result, mb) + } + return result, <-done +} + +// FetchMessages fetches messages received within the last `days` days. +// Falls back to the most recent 200 if the server does not support SEARCH. +func (c *Client) FetchMessages(mailboxName string, days int) ([]*gomailModels.Message, error) { + mbox, err := c.imap.Select(mailboxName, true) + if err != nil { + return nil, fmt.Errorf("select %s: %w", mailboxName, err) + } + if mbox.Messages == 0 { + return nil, nil + } + + since := time.Now().AddDate(0, 0, -days) + criteria := imap.NewSearchCriteria() + criteria.Since = since + + uids, err := c.imap.Search(criteria) + if err != nil || len(uids) == 0 { + from := uint32(1) + if mbox.Messages > 200 { + from = mbox.Messages - 199 + } + seqSet := new(imap.SeqSet) + seqSet.AddRange(from, mbox.Messages) + return c.fetchBySeqSet(seqSet) + } + + seqSet := new(imap.SeqSet) + for _, uid := range uids { + seqSet.AddNum(uid) + } + return c.fetchBySeqSet(seqSet) +} + +func (c *Client) fetchBySeqSet(seqSet *imap.SeqSet) ([]*gomailModels.Message, error) { + // Fetch FetchRFC822 (full raw message) so we can properly parse MIME + items := []imap.FetchItem{ + imap.FetchUid, imap.FetchEnvelope, + imap.FetchFlags, imap.FetchBodyStructure, + imap.FetchRFC822, // full message including headers – needed for proper MIME parsing + } + + ch := make(chan *imap.Message, 64) + done := make(chan error, 1) + go func() { done <- c.imap.Fetch(seqSet, items, ch) }() + + var results []*gomailModels.Message + for msg := range ch { + m, err := parseIMAPMessage(msg, c.account) + if err != nil { + log.Printf("parse message uid=%d: %v", msg.Uid, err) + continue + } + results = append(results, m) + } + if err := <-done; err != nil { + return results, fmt.Errorf("fetch: %w", err) + } + return results, nil +} + +func parseIMAPMessage(msg *imap.Message, account *gomailModels.EmailAccount) (*gomailModels.Message, error) { + m := &gomailModels.Message{ + AccountID: account.ID, + RemoteUID: fmt.Sprintf("%d", msg.Uid), + } + + if env := msg.Envelope; env != nil { + m.Subject = env.Subject + m.Date = env.Date + m.MessageID = env.MessageId + if len(env.From) > 0 { + m.FromEmail = env.From[0].Address() + m.FromName = env.From[0].PersonalName + } + m.ToList = formatAddressList(env.To) + m.CCList = formatAddressList(env.Cc) + m.BCCList = formatAddressList(env.Bcc) + if len(env.ReplyTo) > 0 { + m.ReplyTo = env.ReplyTo[0].Address() + } + } + + for _, flag := range msg.Flags { + switch flag { + case imap.SeenFlag: + m.IsRead = true + case imap.FlaggedFlag: + m.IsStarred = true + case imap.DraftFlag: + m.IsDraft = true + } + } + + // Parse MIME body from the full raw RFC822 message + for _, literal := range msg.Body { + raw, err := io.ReadAll(literal) + if err != nil { + continue + } + text, html, attachments := parseMIME(raw) + if m.BodyText == "" { + m.BodyText = text + } + if m.BodyHTML == "" { + m.BodyHTML = html + } + if len(attachments) > 0 { + m.Attachments = append(m.Attachments, attachments...) + m.HasAttachment = true + } + break // only need first body literal + } + + if msg.BodyStructure != nil && !m.HasAttachment { + m.HasAttachment = hasAttachment(msg.BodyStructure) + } + if m.Date.IsZero() { + m.Date = time.Now() + } + return m, nil +} + +// parseMIME takes a full RFC822 raw message (with headers) and extracts +// text/plain, text/html and attachment metadata. +func parseMIME(raw []byte) (text, html string, attachments []gomailModels.Attachment) { + msg, err := netmail.ReadMessage(bytes.NewReader(raw)) + if err != nil { + // Last-resort fallback: treat whole thing as plain text, strip obvious headers + return stripMIMEHeaders(string(raw)), "", nil + } + ct := msg.Header.Get("Content-Type") + if ct == "" { + ct = "text/plain" + } + body, _ := io.ReadAll(msg.Body) + text, html, attachments = parsePart(ct, msg.Header.Get("Content-Transfer-Encoding"), body) + return +} + +// parsePart recursively handles a MIME part. +func parsePart(contentType, transferEncoding string, body []byte) (text, html string, attachments []gomailModels.Attachment) { + mediaType, params, err := mime.ParseMediaType(contentType) + if err != nil { + return string(body), "", nil + } + mediaType = strings.ToLower(mediaType) + + decoded := decodeTransfer(transferEncoding, body) + + switch { + case mediaType == "text/plain": + text = decodeCharset(params["charset"], decoded) + case mediaType == "text/html": + html = decodeCharset(params["charset"], decoded) + case strings.HasPrefix(mediaType, "multipart/"): + boundary := params["boundary"] + if boundary == "" { + return string(decoded), "", nil + } + mr := multipart.NewReader(bytes.NewReader(decoded), boundary) + for { + part, err := mr.NextPart() + if err != nil { + break + } + partBody, _ := io.ReadAll(part) + partCT := part.Header.Get("Content-Type") + if partCT == "" { + partCT = "text/plain" + } + partTE := part.Header.Get("Content-Transfer-Encoding") + disposition := part.Header.Get("Content-Disposition") + dispType, dispParams, _ := mime.ParseMediaType(disposition) + + if strings.EqualFold(dispType, "attachment") { + filename := dispParams["filename"] + if filename == "" { + filename = part.FileName() + } + if filename == "" { + filename = "attachment" + } + partMedia, _, _ := mime.ParseMediaType(partCT) + attachments = append(attachments, gomailModels.Attachment{ + Filename: filename, + ContentType: partMedia, + Size: int64(len(partBody)), + }) + continue + } + + t, h, atts := parsePart(partCT, partTE, partBody) + if text == "" && t != "" { + text = t + } + if html == "" && h != "" { + html = h + } + attachments = append(attachments, atts...) + } + default: + // Any other type – treat as attachment if it has a filename + mt, _, _ := mime.ParseMediaType(contentType) + _ = mt + } + return +} + +func decodeTransfer(encoding string, data []byte) []byte { + switch strings.ToLower(strings.TrimSpace(encoding)) { + case "base64": + // Strip whitespace before decoding + cleaned := bytes.ReplaceAll(data, []byte("\r\n"), []byte("")) + cleaned = bytes.ReplaceAll(cleaned, []byte("\n"), []byte("")) + dst := make([]byte, base64.StdEncoding.DecodedLen(len(cleaned))) + n, err := base64.StdEncoding.Decode(dst, cleaned) + if err != nil { + return data + } + return dst[:n] + case "quoted-printable": + decoded, err := io.ReadAll(quotedprintable.NewReader(bytes.NewReader(data))) + if err != nil { + return data + } + return decoded + default: + return data + } +} + +func decodeCharset(charset string, data []byte) string { + // We only handle UTF-8 and ASCII natively; for others return as-is + // (a proper charset library would be needed for full support) + return string(data) +} + +// stripMIMEHeaders removes MIME boundary/header lines from a raw body string +// when proper parsing fails completely. +func stripMIMEHeaders(raw string) string { + lines := strings.Split(raw, "\n") + var out []string + inHeader := false + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "--") && len(trimmed) > 2 { + inHeader = true + continue + } + if inHeader { + if trimmed == "" { + inHeader = false + } + continue + } + out = append(out, line) + } + return strings.Join(out, "\n") +} + +func formatAddressList(addrs []*imap.Address) string { + parts := make([]string, 0, len(addrs)) + for _, a := range addrs { + if a.PersonalName != "" { + parts = append(parts, fmt.Sprintf("%s <%s>", a.PersonalName, a.Address())) + } else { + parts = append(parts, a.Address()) + } + } + return strings.Join(parts, ", ") +} + +func hasAttachment(bs *imap.BodyStructure) bool { + if bs == nil { + return false + } + if strings.EqualFold(bs.Disposition, "attachment") { + return true + } + for _, part := range bs.Parts { + if hasAttachment(part) { + return true + } + } + return false +} + +// InferFolderType returns a canonical folder type from mailbox name/attributes. +func InferFolderType(name string, attributes []string) string { + for _, attr := range attributes { + switch strings.ToLower(attr) { + case `\inbox`: + return "inbox" + case `\sent`: + return "sent" + case `\drafts`: + return "drafts" + case `\trash`, `\deleted`: + return "trash" + case `\junk`, `\spam`: + return "spam" + case `\archive`: + return "archive" + } + } + lower := strings.ToLower(name) + switch { + case lower == "inbox": + return "inbox" + case strings.Contains(lower, "sent"): + return "sent" + case strings.Contains(lower, "draft"): + return "drafts" + case strings.Contains(lower, "trash") || strings.Contains(lower, "deleted"): + return "trash" + case strings.Contains(lower, "spam") || strings.Contains(lower, "junk"): + return "spam" + case strings.Contains(lower, "archive"): + return "archive" + default: + return "custom" + } +} + +// ---- SMTP Send ---- + +func authSMTP(c *smtp.Client, account *gomailModels.EmailAccount, host string) error { + switch account.Provider { + case gomailModels.ProviderGmail, gomailModels.ProviderOutlook: + return c.Auth(&xoauth2SMTP{user: account.EmailAddress, token: account.AccessToken}) + default: + ok, _ := c.Extension("AUTH") + if !ok { + return nil + } + // PlainAuth requires the correct hostname for TLS verification + return c.Auth(smtp.PlainAuth("", account.EmailAddress, account.AccessToken, host)) + } +} + +// SendMessageFull sends an email via SMTP using the account's configured server. +// It also appends the sent message to the IMAP Sent folder. +func SendMessageFull(ctx context.Context, account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest) error { + host, port := smtpHostFor(account.Provider) + if account.SMTPHost != "" { + host = account.SMTPHost + port = account.SMTPPort + } + if host == "" { + return fmt.Errorf("SMTP host not configured") + } + + var buf bytes.Buffer + buildMIMEMessage(&buf, account, req) + rawMsg := buf.Bytes() + + addr := fmt.Sprintf("%s:%d", host, port) + + var c *smtp.Client + var err error + + if port == 465 { + // Implicit TLS (SMTPS) + conn, err2 := tls.Dial("tcp", addr, &tls.Config{ServerName: host}) + if err2 != nil { + return fmt.Errorf("SMTPS dial %s: %w", addr, err2) + } + c, err = smtp.NewClient(conn, host) + } else { + // Plain SMTP then upgrade with STARTTLS (port 587 / 25) + c, err = smtp.Dial(addr) + if err == nil { + if err2 := c.Hello("localhost"); err2 != nil { + c.Close() + return fmt.Errorf("SMTP EHLO: %w", err2) + } + if ok, _ := c.Extension("STARTTLS"); ok { + if err2 := c.StartTLS(&tls.Config{ServerName: host}); err2 != nil { + c.Close() + return fmt.Errorf("STARTTLS %s: %w", host, err2) + } + } + } + } + if err != nil { + return fmt.Errorf("SMTP dial %s: %w", addr, err) + } + defer c.Close() + + if err := authSMTP(c, account, host); err != nil { + return fmt.Errorf("SMTP auth: %w", err) + } + + if err := c.Mail(account.EmailAddress); err != nil { + return fmt.Errorf("MAIL FROM: %w", err) + } + + allRcpt := append(append([]string{}, req.To...), req.CC...) + allRcpt = append(allRcpt, req.BCC...) + for _, rcpt := range allRcpt { + rcpt = strings.TrimSpace(rcpt) + if rcpt == "" { + continue + } + if err := c.Rcpt(rcpt); err != nil { + return fmt.Errorf("RCPT %s: %w", rcpt, err) + } + } + + wc, err := c.Data() + if err != nil { + return fmt.Errorf("SMTP DATA: %w", err) + } + if _, err := wc.Write(rawMsg); err != nil { + wc.Close() + return fmt.Errorf("SMTP write: %w", err) + } + if err := wc.Close(); err != nil { + return fmt.Errorf("SMTP DATA close: %w", err) + } + if err := c.Quit(); err != nil { + log.Printf("SMTP QUIT: %v (ignored)", err) + } + + // Append to Sent folder via IMAP (best-effort, don't fail the send) + go func() { + imapClient, err := Connect(context.Background(), account) + if err != nil { + log.Printf("AppendToSent: IMAP connect: %v", err) + return + } + defer imapClient.Close() + if err := imapClient.AppendToSent(rawMsg); err != nil { + log.Printf("AppendToSent: %v", err) + } + }() + + return nil +} + +func buildMIMEMessage(buf *bytes.Buffer, account *gomailModels.EmailAccount, req *gomailModels.ComposeRequest) string { + from := netmail.Address{Name: account.DisplayName, Address: account.EmailAddress} + boundary := fmt.Sprintf("gomail_%x", time.Now().UnixNano()) + msgID := fmt.Sprintf("<%d.%s@gomail>", time.Now().UnixNano(), strings.ReplaceAll(account.EmailAddress, "@", ".")) + + buf.WriteString("Message-ID: " + msgID + "\r\n") + buf.WriteString("From: " + from.String() + "\r\n") + buf.WriteString("To: " + strings.Join(req.To, ", ") + "\r\n") + if len(req.CC) > 0 { + buf.WriteString("Cc: " + strings.Join(req.CC, ", ") + "\r\n") + } + // Never write BCC to headers — only used for RCPT TO commands + buf.WriteString("Subject: " + encodeMIMEHeader(req.Subject) + "\r\n") + buf.WriteString("Date: " + time.Now().Format("Mon, 02 Jan 2006 15:04:05 -0700") + "\r\n") + buf.WriteString("MIME-Version: 1.0\r\n") + buf.WriteString("Content-Type: multipart/alternative; boundary=\"" + boundary + "\"\r\n") + buf.WriteString("\r\n") + + // Plain text part + buf.WriteString("--" + boundary + "\r\n") + buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n") + buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") + qpw := quotedprintable.NewWriter(buf) + plainText := req.BodyText + if plainText == "" && req.BodyHTML != "" { + plainText = htmlToPlainText(req.BodyHTML) + } + qpw.Write([]byte(plainText)) + qpw.Close() + buf.WriteString("\r\n") + + // HTML part + buf.WriteString("--" + boundary + "\r\n") + buf.WriteString("Content-Type: text/html; charset=utf-8\r\n") + buf.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n") + qpw2 := quotedprintable.NewWriter(buf) + if req.BodyHTML != "" { + qpw2.Write([]byte(req.BodyHTML)) + } else { + qpw2.Write([]byte("
" + htmlEscape(plainText) + "
")) + } + qpw2.Close() + buf.WriteString("\r\n") + + buf.WriteString("--" + boundary + "--\r\n") + return msgID +} + +// encodeMIMEHeader encodes a header value with UTF-8 if it contains non-ASCII chars. +func encodeMIMEHeader(s string) string { + for _, r := range s { + if r > 127 { + return mime.QEncoding.Encode("utf-8", s) + } + } + return s +} + +// htmlToPlainText does a very basic HTML→plain-text strip for the text/plain fallback. +func htmlToPlainText(html string) string { + // Strip tags + var out strings.Builder + inTag := false + for _, r := range html { + switch { + case r == '<': + inTag = true + case r == '>': + inTag = false + out.WriteRune(' ') + case !inTag: + out.WriteRune(r) + } + } + // Collapse excessive whitespace + lines := strings.Split(out.String(), "\n") + var result []string + for _, l := range lines { + l = strings.TrimSpace(l) + if l != "" { + result = append(result, l) + } + } + return strings.Join(result, "\n") +} + +// AppendToSent saves the sent message to the IMAP Sent folder via APPEND command. +func (c *Client) AppendToSent(rawMsg []byte) error { + mailboxes, err := c.ListMailboxes() + if err != nil { + return err + } + // Find the Sent folder + var sentName string + for _, mb := range mailboxes { + ft := InferFolderType(mb.Name, mb.Attributes) + if ft == "sent" { + sentName = mb.Name + break + } + } + if sentName == "" { + return nil // no Sent folder found, skip silently + } + flags := []string{imap.SeenFlag} + now := time.Now() + return c.imap.Append(sentName, flags, now, bytes.NewReader(rawMsg)) +} + +func htmlEscape(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + return s +} + +// FetchAttachmentData fetches the raw bytes for a specific attachment part from IMAP. +// partPath is a dot-separated MIME section path e.g. "2" or "1.2" +func (c *Client) FetchAttachmentData(mailboxName, uid, partPath string) ([]byte, string, error) { + mbox, err := c.imap.Select(mailboxName, true) + if err != nil || mbox == nil { + return nil, "", fmt.Errorf("select %s: %w", mailboxName, err) + } + + seqSet := new(imap.SeqSet) + // Use UID fetch + bodySection := &imap.BodySectionName{ + BodyPartName: imap.BodyPartName{ + Specifier: imap.MIMESpecifier, + Path: partPathToInts(partPath), + }, + } + items := []imap.FetchItem{bodySection.FetchItem()} + + ch := make(chan *imap.Message, 1) + done := make(chan error, 1) + uidNum := uint32(0) + fmt.Sscanf(uid, "%d", &uidNum) + seqSet.AddNum(uidNum) + go func() { done <- c.imap.Fetch(seqSet, items, ch) }() + + var data []byte + for msg := range ch { + for _, literal := range msg.Body { + data, _ = io.ReadAll(literal) + break + } + } + if err := <-done; err != nil { + return nil, "", err + } + ct := "application/octet-stream" + ext := filepath.Ext(partPath) + if ext != "" { + ct = mime.TypeByExtension(ext) + } + return data, ct, nil +} + +func partPathToInts(path string) []int { + if path == "" { + return nil + } + parts := strings.Split(path, ".") + result := make([]int, 0, len(parts)) + for _, p := range parts { + var n int + fmt.Sscanf(p, "%d", &n) + result = append(result, n) + } + return result +} diff --git a/internal/handlers/admin.go b/internal/handlers/admin.go new file mode 100644 index 0000000..2ae9004 --- /dev/null +++ b/internal/handlers/admin.go @@ -0,0 +1,220 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + "github.com/gorilla/mux" + "github.com/yourusername/gomail/config" + "github.com/yourusername/gomail/internal/db" + "github.com/yourusername/gomail/internal/middleware" + "github.com/yourusername/gomail/internal/models" +) + +// AdminHandler handles /admin/* routes (all require admin role). +type AdminHandler struct { + db *db.DB + cfg *config.Config + renderer *Renderer +} + +func (h *AdminHandler) writeJSON(w http.ResponseWriter, v interface{}) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} + +func (h *AdminHandler) writeError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} + +// ShowAdmin serves the admin SPA shell for all /admin/* routes. +func (h *AdminHandler) ShowAdmin(w http.ResponseWriter, r *http.Request) { + h.renderer.Render(w, "admin", nil) +} + +// ---- User Management ---- + +func (h *AdminHandler) ListUsers(w http.ResponseWriter, r *http.Request) { + users, err := h.db.ListUsers() + if err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to list users") + return + } + // Sanitize: strip password hash + type safeUser struct { + ID int64 `json:"id"` + Email string `json:"email"` + Username string `json:"username"` + Role models.UserRole `json:"role"` + IsActive bool `json:"is_active"` + MFAEnabled bool `json:"mfa_enabled"` + LastLoginAt interface{} `json:"last_login_at"` + CreatedAt interface{} `json:"created_at"` + } + result := make([]safeUser, 0, len(users)) + for _, u := range users { + result = append(result, safeUser{ + ID: u.ID, Email: u.Email, Username: u.Username, + Role: u.Role, IsActive: u.IsActive, MFAEnabled: u.MFAEnabled, + LastLoginAt: u.LastLoginAt, CreatedAt: u.CreatedAt, + }) + } + h.writeJSON(w, result) +} + +func (h *AdminHandler) CreateUser(w http.ResponseWriter, r *http.Request) { + var req struct { + Username string `json:"username"` + Email string `json:"email"` + Password string `json:"password"` + Role string `json:"role"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid request") + return + } + if req.Username == "" || req.Email == "" || req.Password == "" { + h.writeError(w, http.StatusBadRequest, "username, email and password required") + return + } + if len(req.Password) < 8 { + h.writeError(w, http.StatusBadRequest, "password must be at least 8 characters") + return + } + role := models.RoleUser + if req.Role == "admin" { + role = models.RoleAdmin + } + + user, err := h.db.CreateUser(req.Username, req.Email, req.Password, role) + if err != nil { + h.writeError(w, http.StatusConflict, err.Error()) + return + } + + adminID := middleware.GetUserID(r) + h.db.WriteAudit(&adminID, models.AuditUserCreate, + "created user: "+req.Email, middleware.ClientIP(r), r.UserAgent()) + + h.writeJSON(w, map[string]interface{}{"id": user.ID, "ok": true}) +} + +func (h *AdminHandler) UpdateUser(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + targetID, _ := strconv.ParseInt(vars["id"], 10, 64) + + var req struct { + IsActive *bool `json:"is_active"` + Password string `json:"password"` + Role string `json:"role"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid request") + return + } + + if req.IsActive != nil { + if err := h.db.SetUserActive(targetID, *req.IsActive); err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to update user") + return + } + } + if req.Password != "" { + if len(req.Password) < 8 { + h.writeError(w, http.StatusBadRequest, "password must be at least 8 characters") + return + } + if err := h.db.UpdateUserPassword(targetID, req.Password); err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to update password") + return + } + } + + adminID := middleware.GetUserID(r) + h.db.WriteAudit(&adminID, models.AuditUserUpdate, + "updated user id:"+vars["id"], middleware.ClientIP(r), r.UserAgent()) + + h.writeJSON(w, map[string]bool{"ok": true}) +} + +func (h *AdminHandler) DeleteUser(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + targetID, _ := strconv.ParseInt(vars["id"], 10, 64) + + adminID := middleware.GetUserID(r) + if targetID == adminID { + h.writeError(w, http.StatusBadRequest, "cannot delete yourself") + return + } + + if err := h.db.DeleteUser(targetID); err != nil { + h.writeError(w, http.StatusInternalServerError, "delete failed") + return + } + + h.db.WriteAudit(&adminID, models.AuditUserDelete, + "deleted user id:"+vars["id"], middleware.ClientIP(r), r.UserAgent()) + h.writeJSON(w, map[string]bool{"ok": true}) +} + +// ---- Audit Log ---- + +func (h *AdminHandler) ListAuditLogs(w http.ResponseWriter, r *http.Request) { + page := 1 + pageSize := 100 + if p := r.URL.Query().Get("page"); p != "" { + if v, err := strconv.Atoi(p); err == nil && v > 0 { + page = v + } + } + eventFilter := r.URL.Query().Get("event") + + result, err := h.db.ListAuditLogs(page, pageSize, eventFilter) + if err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to fetch logs") + return + } + h.writeJSON(w, result) +} + +// ---- App Settings ---- + +func (h *AdminHandler) GetSettings(w http.ResponseWriter, r *http.Request) { + settings, err := config.GetSettings() + if err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to read settings") + return + } + h.writeJSON(w, settings) +} + +func (h *AdminHandler) SetSettings(w http.ResponseWriter, r *http.Request) { + adminID := middleware.GetUserID(r) + + var updates map[string]string + if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + changed, err := config.SetSettings(updates) + if err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to save settings") + return + } + + if len(changed) > 0 { + detail := "changed config keys: " + strings.Join(changed, ", ") + h.db.WriteAudit(&adminID, models.AuditConfigChange, + detail, middleware.ClientIP(r), r.UserAgent()) + } + + h.writeJSON(w, map[string]interface{}{ + "ok": true, + "changed": changed, + }) +} diff --git a/internal/handlers/api.go b/internal/handlers/api.go new file mode 100644 index 0000000..49be04f --- /dev/null +++ b/internal/handlers/api.go @@ -0,0 +1,636 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "strconv" + "strings" + + "github.com/gorilla/mux" + "github.com/yourusername/gomail/config" + "github.com/yourusername/gomail/internal/db" + "github.com/yourusername/gomail/internal/email" + "github.com/yourusername/gomail/internal/middleware" + "github.com/yourusername/gomail/internal/models" + "github.com/yourusername/gomail/internal/syncer" +) + +// APIHandler handles all /api/* JSON endpoints. +type APIHandler struct { + db *db.DB + cfg *config.Config + syncer *syncer.Scheduler +} + +func (h *APIHandler) writeJSON(w http.ResponseWriter, v interface{}) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} + +func (h *APIHandler) writeError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} + +// ---- Provider availability ---- + +// GetProviders returns which OAuth providers are configured and enabled. +func (h *APIHandler) GetProviders(w http.ResponseWriter, r *http.Request) { + h.writeJSON(w, map[string]bool{ + "gmail": h.cfg.GoogleClientID != "" && h.cfg.GoogleClientSecret != "", + "outlook": h.cfg.MicrosoftClientID != "" && h.cfg.MicrosoftClientSecret != "", + }) +} + +// ---- Accounts ---- + +type safeAccount struct { + ID int64 `json:"id"` + Provider models.AccountProvider `json:"provider"` + EmailAddress string `json:"email_address"` + DisplayName string `json:"display_name"` + IMAPHost string `json:"imap_host,omitempty"` + IMAPPort int `json:"imap_port,omitempty"` + SMTPHost string `json:"smtp_host,omitempty"` + SMTPPort int `json:"smtp_port,omitempty"` + LastError string `json:"last_error,omitempty"` + Color string `json:"color"` + LastSync string `json:"last_sync"` +} + +func toSafeAccount(a *models.EmailAccount) safeAccount { + lastSync := "" + if !a.LastSync.IsZero() { + lastSync = a.LastSync.Format("2006-01-02T15:04:05Z") + } + return safeAccount{ + ID: a.ID, Provider: a.Provider, EmailAddress: a.EmailAddress, + DisplayName: a.DisplayName, IMAPHost: a.IMAPHost, IMAPPort: a.IMAPPort, + SMTPHost: a.SMTPHost, SMTPPort: a.SMTPPort, + LastError: a.LastError, Color: a.Color, LastSync: lastSync, + } +} + +func (h *APIHandler) ListAccounts(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + accounts, err := h.db.ListAccountsByUser(userID) + if err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to list accounts") + return + } + result := make([]safeAccount, 0, len(accounts)) + for _, a := range accounts { + result = append(result, toSafeAccount(a)) + } + h.writeJSON(w, result) +} + +func (h *APIHandler) AddAccount(w http.ResponseWriter, r *http.Request) { + var req struct { + Email string `json:"email"` + DisplayName string `json:"display_name"` + Password string `json:"password"` + IMAPHost string `json:"imap_host"` + IMAPPort int `json:"imap_port"` + SMTPHost string `json:"smtp_host"` + SMTPPort int `json:"smtp_port"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if req.Email == "" || req.Password == "" { + h.writeError(w, http.StatusBadRequest, "email and password required") + return + } + if req.IMAPHost == "" { + h.writeError(w, http.StatusBadRequest, "IMAP host required") + return + } + if req.IMAPPort == 0 { + req.IMAPPort = 993 + } + if req.SMTPPort == 0 { + req.SMTPPort = 587 + } + + userID := middleware.GetUserID(r) + accounts, _ := h.db.ListAccountsByUser(userID) + colors := []string{"#4A90D9", "#EA4335", "#34A853", "#FBBC04", "#FF6D00", "#9C27B0"} + color := colors[len(accounts)%len(colors)] + + account := &models.EmailAccount{ + UserID: userID, Provider: models.ProviderIMAPSMTP, + EmailAddress: req.Email, DisplayName: req.DisplayName, + AccessToken: req.Password, + IMAPHost: req.IMAPHost, IMAPPort: req.IMAPPort, + SMTPHost: req.SMTPHost, SMTPPort: req.SMTPPort, + Color: color, IsActive: true, + } + + if err := h.db.CreateAccount(account); err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to create account") + return + } + + uid := userID + h.db.WriteAudit(&uid, models.AuditAccountAdd, "imap:"+req.Email, middleware.ClientIP(r), r.UserAgent()) + + // Trigger an immediate sync in background + go h.syncer.SyncAccountNow(account.ID) + + h.writeJSON(w, map[string]interface{}{"id": account.ID, "ok": true}) +} + +func (h *APIHandler) GetAccount(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + accountID := pathInt64(r, "id") + account, err := h.db.GetAccount(accountID) + if err != nil || account == nil || account.UserID != userID { + h.writeError(w, http.StatusNotFound, "account not found") + return + } + h.writeJSON(w, toSafeAccount(account)) +} + +func (h *APIHandler) UpdateAccount(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + accountID := pathInt64(r, "id") + + account, err := h.db.GetAccount(accountID) + if err != nil || account == nil || account.UserID != userID { + h.writeError(w, http.StatusNotFound, "account not found") + return + } + + var req struct { + DisplayName string `json:"display_name"` + Password string `json:"password"` + IMAPHost string `json:"imap_host"` + IMAPPort int `json:"imap_port"` + SMTPHost string `json:"smtp_host"` + SMTPPort int `json:"smtp_port"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid request") + return + } + + if req.DisplayName != "" { + account.DisplayName = req.DisplayName + } + if req.Password != "" { + account.AccessToken = req.Password + } + if req.IMAPHost != "" { + account.IMAPHost = req.IMAPHost + } + if req.IMAPPort > 0 { + account.IMAPPort = req.IMAPPort + } + if req.SMTPHost != "" { + account.SMTPHost = req.SMTPHost + } + if req.SMTPPort > 0 { + account.SMTPPort = req.SMTPPort + } + + if err := h.db.UpdateAccount(account); err != nil { + h.writeError(w, http.StatusInternalServerError, "update failed") + return + } + h.writeJSON(w, map[string]bool{"ok": true}) +} + +func (h *APIHandler) TestConnection(w http.ResponseWriter, r *http.Request) { + var req struct { + Email string `json:"email"` + Password string `json:"password"` + IMAPHost string `json:"imap_host"` + IMAPPort int `json:"imap_port"` + SMTPHost string `json:"smtp_host"` + SMTPPort int `json:"smtp_port"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid request") + return + } + if req.IMAPPort == 0 { + req.IMAPPort = 993 + } + + testAccount := &models.EmailAccount{ + Provider: models.ProviderIMAPSMTP, + EmailAddress: req.Email, + AccessToken: req.Password, + IMAPHost: req.IMAPHost, + IMAPPort: req.IMAPPort, + SMTPHost: req.SMTPHost, + SMTPPort: req.SMTPPort, + } + + if err := email.TestConnection(testAccount); err != nil { + h.writeJSON(w, map[string]interface{}{"ok": false, "error": err.Error()}) + return + } + h.writeJSON(w, map[string]bool{"ok": true}) +} + +func (h *APIHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + accountID := pathInt64(r, "id") + if err := h.db.DeleteAccount(accountID, userID); err != nil { + h.writeError(w, http.StatusInternalServerError, "delete failed") + return + } + uid := userID + h.db.WriteAudit(&uid, models.AuditAccountDel, strconv.FormatInt(accountID, 10), middleware.ClientIP(r), r.UserAgent()) + h.writeJSON(w, map[string]bool{"ok": true}) +} + +func (h *APIHandler) SyncAccount(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + accountID := pathInt64(r, "id") + + account, err := h.db.GetAccount(accountID) + if err != nil || account == nil || account.UserID != userID { + h.writeError(w, http.StatusNotFound, "account not found") + return + } + + synced, err := h.syncer.SyncAccountNow(accountID) + if err != nil { + h.writeError(w, http.StatusBadGateway, err.Error()) + return + } + h.writeJSON(w, map[string]interface{}{"ok": true, "synced": synced}) +} + +func (h *APIHandler) SyncFolder(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + folderID := pathInt64(r, "id") + + folder, err := h.db.GetFolderByID(folderID) + if err != nil || folder == nil { + h.writeError(w, http.StatusNotFound, "folder not found") + return + } + account, err := h.db.GetAccount(folder.AccountID) + if err != nil || account == nil || account.UserID != userID { + h.writeError(w, http.StatusNotFound, "folder not found") + return + } + + synced, err := h.syncer.SyncFolderNow(folder.AccountID, folderID) + if err != nil { + h.writeError(w, http.StatusBadGateway, err.Error()) + return + } + h.writeJSON(w, map[string]interface{}{"ok": true, "synced": synced}) +} + +func (h *APIHandler) SetAccountSyncSettings(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + accountID := pathInt64(r, "id") + var req struct { + SyncDays int `json:"sync_days"` + SyncMode string `json:"sync_mode"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid request") + return + } + if err := h.db.SetAccountSyncSettings(accountID, userID, req.SyncDays, req.SyncMode); err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to save") + return + } + h.writeJSON(w, map[string]bool{"ok": true}) +} + +func (h *APIHandler) SetComposePopup(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + var req struct { + Popup bool `json:"compose_popup"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid request") + return + } + if err := h.db.SetComposePopup(userID, req.Popup); err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to save") + return + } + h.writeJSON(w, map[string]bool{"ok": true}) +} + +// ---- Messages ---- + +func (h *APIHandler) ListMessages(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + page := queryInt(r, "page", 1) + pageSize := queryInt(r, "page_size", 50) + accountID := queryInt64(r, "account_id", 0) + folderID := queryInt64(r, "folder_id", 0) + + var folderIDs []int64 + if folderID > 0 { + folderIDs = []int64{folderID} + } + + result, err := h.db.ListMessages(userID, folderIDs, accountID, page, pageSize) + if err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to list messages") + return + } + h.writeJSON(w, result) +} + +func (h *APIHandler) UnifiedInbox(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + page := queryInt(r, "page", 1) + pageSize := queryInt(r, "page_size", 50) + + // Get all inbox folder IDs for this user + folders, err := h.db.GetFoldersByUser(userID) + if err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to get folders") + return + } + var inboxIDs []int64 + for _, f := range folders { + if f.FolderType == "inbox" { + inboxIDs = append(inboxIDs, f.ID) + } + } + + result, err := h.db.ListMessages(userID, inboxIDs, 0, page, pageSize) + if err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to list messages") + return + } + h.writeJSON(w, result) +} + +func (h *APIHandler) GetMessage(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + messageID := pathInt64(r, "id") + msg, err := h.db.GetMessage(messageID, userID) + if err != nil || msg == nil { + h.writeError(w, http.StatusNotFound, "message not found") + return + } + h.db.MarkMessageRead(messageID, userID, true) + h.writeJSON(w, msg) +} + +func (h *APIHandler) MarkRead(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + messageID := pathInt64(r, "id") + var req struct{ Read bool `json:"read"` } + json.NewDecoder(r.Body).Decode(&req) + h.db.MarkMessageRead(messageID, userID, req.Read) + h.writeJSON(w, map[string]bool{"ok": true}) +} + +func (h *APIHandler) ToggleStar(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + messageID := pathInt64(r, "id") + starred, err := h.db.ToggleMessageStar(messageID, userID) + if err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to toggle star") + return + } + h.writeJSON(w, map[string]bool{"ok": true, "starred": starred}) +} + +func (h *APIHandler) MoveMessage(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + messageID := pathInt64(r, "id") + var req struct{ FolderID int64 `json:"folder_id"` } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.FolderID == 0 { + h.writeError(w, http.StatusBadRequest, "folder_id required") + return + } + if err := h.db.MoveMessage(messageID, userID, req.FolderID); err != nil { + h.writeError(w, http.StatusInternalServerError, "move failed") + return + } + h.writeJSON(w, map[string]bool{"ok": true}) +} + +func (h *APIHandler) DeleteMessage(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + messageID := pathInt64(r, "id") + if err := h.db.DeleteMessage(messageID, userID); err != nil { + h.writeError(w, http.StatusInternalServerError, "delete failed") + return + } + h.writeJSON(w, map[string]bool{"ok": true}) +} + +// ---- Send / Reply / Forward ---- + +func (h *APIHandler) SendMessage(w http.ResponseWriter, r *http.Request) { + h.handleSend(w, r, "new") +} +func (h *APIHandler) ReplyMessage(w http.ResponseWriter, r *http.Request) { + h.handleSend(w, r, "reply") +} +func (h *APIHandler) ForwardMessage(w http.ResponseWriter, r *http.Request) { + h.handleSend(w, r, "forward") +} + +func (h *APIHandler) handleSend(w http.ResponseWriter, r *http.Request, mode string) { + userID := middleware.GetUserID(r) + var req models.ComposeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid request") + return + } + + account, err := h.db.GetAccount(req.AccountID) + if err != nil || account == nil || account.UserID != userID { + h.writeError(w, http.StatusBadRequest, "account not found") + return + } + + if err := email.SendMessageFull(context.Background(), account, &req); err != nil { + log.Printf("SMTP send failed account=%d user=%d: %v", req.AccountID, userID, err) + h.db.WriteAudit(&userID, models.AuditAppError, + fmt.Sprintf("send failed account:%d – %v", req.AccountID, err), + middleware.ClientIP(r), r.UserAgent()) + h.writeError(w, http.StatusBadGateway, err.Error()) + return + } + h.writeJSON(w, map[string]bool{"ok": true}) +} + +// ---- Folders ---- + +func (h *APIHandler) ListFolders(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + folders, err := h.db.GetFoldersByUser(userID) + if err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to get folders") + return + } + if folders == nil { + folders = []*models.Folder{} + } + h.writeJSON(w, folders) +} + +func (h *APIHandler) ListAccountFolders(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + accountID := pathInt64(r, "account_id") + account, err := h.db.GetAccount(accountID) + if err != nil || account == nil || account.UserID != userID { + h.writeError(w, http.StatusNotFound, "account not found") + return + } + folders, err := h.db.ListFoldersByAccount(accountID) + if err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to get folders") + return + } + if folders == nil { + folders = []*models.Folder{} + } + h.writeJSON(w, folders) +} + +// ---- Search ---- + +func (h *APIHandler) Search(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + q := strings.TrimSpace(r.URL.Query().Get("q")) + if q == "" { + h.writeError(w, http.StatusBadRequest, "q parameter required") + return + } + page := queryInt(r, "page", 1) + pageSize := queryInt(r, "page_size", 50) + + result, err := h.db.SearchMessages(userID, q, page, pageSize) + if err != nil { + h.writeError(w, http.StatusInternalServerError, "search failed") + return + } + h.writeJSON(w, result) +} + +// ---- Sync interval (per-user) ---- + +func (h *APIHandler) GetSyncInterval(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + interval, err := h.db.GetUserSyncInterval(userID) + if err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to get sync interval") + return + } + h.writeJSON(w, map[string]int{"sync_interval": interval}) +} + +func (h *APIHandler) SetSyncInterval(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + var req struct { + SyncInterval int `json:"sync_interval"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + h.writeError(w, http.StatusBadRequest, "invalid request") + return + } + if req.SyncInterval != 0 && (req.SyncInterval < 1 || req.SyncInterval > 60) { + h.writeError(w, http.StatusBadRequest, "sync_interval must be 0 (manual) or 1-60 minutes") + return + } + if err := h.db.SetUserSyncInterval(userID, req.SyncInterval); err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to update sync interval") + return + } + h.writeJSON(w, map[string]bool{"ok": true}) +} + +// ---- Helpers ---- + +func pathInt64(r *http.Request, key string) int64 { + v, _ := strconv.ParseInt(mux.Vars(r)[key], 10, 64) + return v +} + +func queryInt(r *http.Request, key string, def int) int { + if v := r.URL.Query().Get(key); v != "" { + if i, err := strconv.Atoi(v); err == nil { + return i + } + } + return def +} + +func queryInt64(r *http.Request, key string, def int64) int64 { + if v := r.URL.Query().Get(key); v != "" { + if i, err := strconv.ParseInt(v, 10, 64); err == nil { + return i + } + } + return def +} + +// ---- Message headers (for troubleshooting) ---- + +func (h *APIHandler) GetMessageHeaders(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + messageID := pathInt64(r, "id") + msg, err := h.db.GetMessage(messageID, userID) + if err != nil || msg == nil { + h.writeError(w, http.StatusNotFound, "message not found") + return + } + // Return a simplified set of headers we store + headers := map[string]string{ + "Message-ID": msg.MessageID, + "From": fmt.Sprintf("%s <%s>", msg.FromName, msg.FromEmail), + "To": msg.ToList, + "Cc": msg.CCList, + "Bcc": msg.BCCList, + "Reply-To": msg.ReplyTo, + "Subject": msg.Subject, + "Date": msg.Date.Format("Mon, 02 Jan 2006 15:04:05 -0700"), + } + h.writeJSON(w, map[string]interface{}{"headers": headers}) +} + +// ---- Remote content whitelist ---- + +func (h *APIHandler) GetRemoteContentWhitelist(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + list, err := h.db.GetRemoteContentWhitelist(userID) + if err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to get whitelist") + return + } + if list == nil { + list = []string{} + } + h.writeJSON(w, map[string]interface{}{"whitelist": list}) +} + +func (h *APIHandler) AddRemoteContentWhitelist(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + var req struct { + Sender string `json:"sender"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Sender == "" { + h.writeError(w, http.StatusBadRequest, "sender required") + return + } + if err := h.db.AddRemoteContentWhitelist(userID, req.Sender); err != nil { + h.writeError(w, http.StatusInternalServerError, "failed to add to whitelist") + return + } + h.writeJSON(w, map[string]bool{"ok": true}) +} diff --git a/internal/handlers/app.go b/internal/handlers/app.go new file mode 100644 index 0000000..92083ae --- /dev/null +++ b/internal/handlers/app.go @@ -0,0 +1,19 @@ +package handlers + +import ( + "net/http" + + "github.com/yourusername/gomail/config" + "github.com/yourusername/gomail/internal/db" +) + +// AppHandler serves the main app pages using the shared Renderer. +type AppHandler struct { + db *db.DB + cfg *config.Config + renderer *Renderer +} + +func (h *AppHandler) Index(w http.ResponseWriter, r *http.Request) { + h.renderer.Render(w, "app", nil) +} diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go new file mode 100644 index 0000000..72c0595 --- /dev/null +++ b/internal/handlers/auth.go @@ -0,0 +1,401 @@ +package handlers + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "net/http" + "time" + + "github.com/yourusername/gomail/config" + goauth "github.com/yourusername/gomail/internal/auth" + "github.com/yourusername/gomail/internal/crypto" + "github.com/yourusername/gomail/internal/db" + "github.com/yourusername/gomail/internal/mfa" + "github.com/yourusername/gomail/internal/middleware" + "github.com/yourusername/gomail/internal/models" + + "golang.org/x/oauth2" +) + +// AuthHandler handles login, register, logout, MFA, and OAuth2 connect flows. +type AuthHandler struct { + db *db.DB + cfg *config.Config + renderer *Renderer +} + +// ---- Login ---- + +func (h *AuthHandler) ShowLogin(w http.ResponseWriter, r *http.Request) { + h.renderer.Render(w, "login", nil) +} + +func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { + username := r.FormValue("username") + password := r.FormValue("password") + ip := middleware.ClientIP(r) + ua := r.UserAgent() + + if username == "" || password == "" { + http.Redirect(w, r, "/auth/login?error=missing_fields", http.StatusFound) + return + } + + // Accept login by username or email + user, err := h.db.GetUserByUsername(username) + if err != nil || user == nil { + user, err = h.db.GetUserByEmail(username) + } + if err != nil || user == nil || !user.IsActive { + h.db.WriteAudit(nil, models.AuditLoginFail, "unknown user: "+username, ip, ua) + http.Redirect(w, r, "/auth/login?error=invalid_credentials", http.StatusFound) + return + } + + if err := crypto.CheckPassword(password, user.PasswordHash); err != nil { + uid := user.ID + h.db.WriteAudit(&uid, models.AuditLoginFail, "bad password for: "+username, ip, ua) + http.Redirect(w, r, "/auth/login?error=invalid_credentials", http.StatusFound) + return + } + + token, _ := h.db.CreateSession(user.ID, 7*24*time.Hour) + h.setSessionCookie(w, token) + h.db.TouchLastLogin(user.ID) + + uid := user.ID + h.db.WriteAudit(&uid, models.AuditLogin, "login from "+ip, ip, ua) + + if user.MFAEnabled { + http.Redirect(w, r, "/auth/mfa", http.StatusFound) + return + } + + http.Redirect(w, r, "/", http.StatusFound) +} + +func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("gomail_session") + if err == nil { + userID := middleware.GetUserID(r) + if userID > 0 { + h.db.WriteAudit(&userID, models.AuditLogout, "", middleware.ClientIP(r), r.UserAgent()) + } + h.db.DeleteSession(cookie.Value) + } + http.SetCookie(w, &http.Cookie{ + Name: "gomail_session", Value: "", MaxAge: -1, Path: "/", + Secure: h.cfg.SecureCookie, HttpOnly: true, SameSite: http.SameSiteLaxMode, + }) + http.Redirect(w, r, "/auth/login", http.StatusFound) +} + +// ---- MFA ---- + +func (h *AuthHandler) ShowMFA(w http.ResponseWriter, r *http.Request) { + h.renderer.Render(w, "mfa", nil) +} + +func (h *AuthHandler) VerifyMFA(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + code := r.FormValue("code") + ip := middleware.ClientIP(r) + ua := r.UserAgent() + + user, err := h.db.GetUserByID(userID) + if err != nil || user == nil { + http.Redirect(w, r, "/auth/login", http.StatusFound) + return + } + + if !mfa.Validate(user.MFASecret, code) { + h.db.WriteAudit(&userID, models.AuditMFAFail, "bad TOTP code", ip, ua) + http.Redirect(w, r, "/auth/mfa?error=invalid_code", http.StatusFound) + return + } + + cookie, _ := r.Cookie("gomail_session") + h.db.SetSessionMFAVerified(cookie.Value) + h.db.WriteAudit(&userID, models.AuditMFASuccess, "", ip, ua) + http.Redirect(w, r, "/", http.StatusFound) +} + +// ---- MFA Setup (user settings) ---- + +func (h *AuthHandler) MFASetupBegin(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + user, err := h.db.GetUserByID(userID) + if err != nil || user == nil { + writeJSONError(w, http.StatusUnauthorized, "not authenticated") + return + } + + secret, err := mfa.GenerateSecret() + if err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to generate secret") + return + } + + if err := h.db.SetMFAPending(userID, secret); err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to store pending secret") + return + } + + qr := mfa.QRCodeURL("GoMail", user.Email, secret) + otpURL := mfa.OTPAuthURL("GoMail", user.Email, secret) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "qr_url": qr, + "otp_url": otpURL, + "secret": secret, + }) +} + +func (h *AuthHandler) MFASetupConfirm(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + var req struct{ Code string `json:"code"` } + json.NewDecoder(r.Body).Decode(&req) + + user, _ := h.db.GetUserByID(userID) + if user == nil || user.MFAPending == "" { + writeJSONError(w, http.StatusBadRequest, "no pending MFA setup") + return + } + + if !mfa.Validate(user.MFAPending, req.Code) { + writeJSONError(w, http.StatusBadRequest, "invalid code — try again") + return + } + + if err := h.db.EnableMFA(userID, user.MFAPending); err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to enable MFA") + return + } + + h.db.WriteAudit(&userID, models.AuditMFAEnable, "", middleware.ClientIP(r), r.UserAgent()) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]bool{"ok": true}) +} + +func (h *AuthHandler) MFADisable(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + var req struct{ Code string `json:"code"` } + json.NewDecoder(r.Body).Decode(&req) + + user, _ := h.db.GetUserByID(userID) + if user == nil || !user.MFAEnabled { + writeJSONError(w, http.StatusBadRequest, "MFA not enabled") + return + } + + if !mfa.Validate(user.MFASecret, req.Code) { + writeJSONError(w, http.StatusBadRequest, "invalid code") + return + } + + h.db.DisableMFA(userID) + h.db.WriteAudit(&userID, models.AuditMFADisable, "", middleware.ClientIP(r), r.UserAgent()) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]bool{"ok": true}) +} + +// ---- Change password ---- + +func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + var req struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` + } + json.NewDecoder(r.Body).Decode(&req) + + user, _ := h.db.GetUserByID(userID) + if user == nil { + writeJSONError(w, http.StatusUnauthorized, "not authenticated") + return + } + if crypto.CheckPassword(req.CurrentPassword, user.PasswordHash) != nil { + writeJSONError(w, http.StatusBadRequest, "current password incorrect") + return + } + if len(req.NewPassword) < 8 { + writeJSONError(w, http.StatusBadRequest, "password must be at least 8 characters") + return + } + if err := h.db.UpdateUserPassword(userID, req.NewPassword); err != nil { + writeJSONError(w, http.StatusInternalServerError, "failed to update password") + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]bool{"ok": true}) +} + +// ---- Me ---- + +func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) { + userID := middleware.GetUserID(r) + user, _ := h.db.GetUserByID(userID) + if user == nil { + writeJSONError(w, http.StatusUnauthorized, "not authenticated") + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": user.ID, + "email": user.Email, + "username": user.Username, + "role": user.Role, + "mfa_enabled": user.MFAEnabled, + "compose_popup": user.ComposePopup, + "sync_interval": user.SyncInterval, + }) +} + +// ---- Gmail OAuth2 ---- + +func (h *AuthHandler) GmailConnect(w http.ResponseWriter, r *http.Request) { + if h.cfg.GoogleClientID == "" { + writeJSONError(w, http.StatusServiceUnavailable, "Google OAuth2 not configured.") + return + } + userID := middleware.GetUserID(r) + state := encodeOAuthState(userID, "gmail") + cfg := goauth.NewGmailConfig(h.cfg.GoogleClientID, h.cfg.GoogleClientSecret, h.cfg.GoogleRedirectURL) + url := cfg.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce) + http.Redirect(w, r, url, http.StatusFound) +} + +func (h *AuthHandler) GmailCallback(w http.ResponseWriter, r *http.Request) { + state := r.URL.Query().Get("state") + code := r.URL.Query().Get("code") + userID, provider := decodeOAuthState(state) + if userID == 0 || provider != "gmail" { + http.Redirect(w, r, "/?error=oauth_state_mismatch", http.StatusFound) + return + } + oauthCfg := goauth.NewGmailConfig(h.cfg.GoogleClientID, h.cfg.GoogleClientSecret, h.cfg.GoogleRedirectURL) + token, err := oauthCfg.Exchange(r.Context(), code) + if err != nil { + http.Redirect(w, r, "/?error=oauth_exchange_failed", http.StatusFound) + return + } + userInfo, err := goauth.GetGoogleUserInfo(r.Context(), token, oauthCfg) + if err != nil { + http.Redirect(w, r, "/?error=userinfo_failed", http.StatusFound) + return + } + colors := []string{"#EA4335", "#4285F4", "#34A853", "#FBBC04", "#FF6D00", "#9C27B0"} + accounts, _ := h.db.ListAccountsByUser(userID) + color := colors[len(accounts)%len(colors)] + account := &models.EmailAccount{ + UserID: userID, Provider: models.ProviderGmail, + EmailAddress: userInfo.Email, DisplayName: userInfo.Name, + AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, + TokenExpiry: token.Expiry, Color: color, IsActive: true, + } + if err := h.db.CreateAccount(account); err != nil { + http.Redirect(w, r, "/?error=account_save_failed", http.StatusFound) + return + } + uid := userID + h.db.WriteAudit(&uid, models.AuditAccountAdd, "gmail:"+userInfo.Email, middleware.ClientIP(r), r.UserAgent()) + http.Redirect(w, r, "/?connected=gmail", http.StatusFound) +} + +// ---- Outlook OAuth2 ---- + +func (h *AuthHandler) OutlookConnect(w http.ResponseWriter, r *http.Request) { + if h.cfg.MicrosoftClientID == "" { + writeJSONError(w, http.StatusServiceUnavailable, "Microsoft OAuth2 not configured.") + return + } + userID := middleware.GetUserID(r) + state := encodeOAuthState(userID, "outlook") + cfg := goauth.NewOutlookConfig(h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret, + h.cfg.MicrosoftTenantID, h.cfg.MicrosoftRedirectURL) + url := cfg.AuthCodeURL(state, oauth2.AccessTypeOffline) + http.Redirect(w, r, url, http.StatusFound) +} + +func (h *AuthHandler) OutlookCallback(w http.ResponseWriter, r *http.Request) { + state := r.URL.Query().Get("state") + code := r.URL.Query().Get("code") + userID, provider := decodeOAuthState(state) + if userID == 0 || provider != "outlook" { + http.Redirect(w, r, "/?error=oauth_state_mismatch", http.StatusFound) + return + } + oauthCfg := goauth.NewOutlookConfig(h.cfg.MicrosoftClientID, h.cfg.MicrosoftClientSecret, + h.cfg.MicrosoftTenantID, h.cfg.MicrosoftRedirectURL) + token, err := oauthCfg.Exchange(r.Context(), code) + if err != nil { + http.Redirect(w, r, "/?error=oauth_exchange_failed", http.StatusFound) + return + } + userInfo, err := goauth.GetMicrosoftUserInfo(r.Context(), token, oauthCfg) + if err != nil { + http.Redirect(w, r, "/?error=userinfo_failed", http.StatusFound) + return + } + accounts, _ := h.db.ListAccountsByUser(userID) + colors := []string{"#0078D4", "#EA4335", "#34A853", "#FBBC04", "#FF6D00", "#9C27B0"} + color := colors[len(accounts)%len(colors)] + account := &models.EmailAccount{ + UserID: userID, Provider: models.ProviderOutlook, + EmailAddress: userInfo.Email(), DisplayName: userInfo.DisplayName, + AccessToken: token.AccessToken, RefreshToken: token.RefreshToken, + TokenExpiry: token.Expiry, Color: color, IsActive: true, + } + if err := h.db.CreateAccount(account); err != nil { + http.Redirect(w, r, "/?error=account_save_failed", http.StatusFound) + return + } + uid := userID + h.db.WriteAudit(&uid, models.AuditAccountAdd, "outlook:"+userInfo.Email(), middleware.ClientIP(r), r.UserAgent()) + http.Redirect(w, r, "/?connected=outlook", http.StatusFound) +} + +// ---- Helpers ---- + +type oauthStatePayload struct { + UserID int64 `json:"u"` + Provider string `json:"p"` + Nonce string `json:"n"` +} + +func encodeOAuthState(userID int64, provider string) string { + nonce := make([]byte, 16) + rand.Read(nonce) + payload := oauthStatePayload{UserID: userID, Provider: provider, + Nonce: base64.URLEncoding.EncodeToString(nonce)} + b, _ := json.Marshal(payload) + return base64.URLEncoding.EncodeToString(b) +} + +func decodeOAuthState(state string) (int64, string) { + b, err := base64.URLEncoding.DecodeString(state) + if err != nil { + return 0, "" + } + var payload oauthStatePayload + if err := json.Unmarshal(b, &payload); err != nil { + return 0, "" + } + return payload.UserID, payload.Provider +} + +func (h *AuthHandler) setSessionCookie(w http.ResponseWriter, token string) { + http.SetCookie(w, &http.Cookie{ + Name: "gomail_session", Value: token, Path: "/", + MaxAge: 7 * 24 * 3600, Secure: h.cfg.SecureCookie, + HttpOnly: true, SameSite: http.SameSiteLaxMode, + }) +} + +func writeJSONError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go new file mode 100644 index 0000000..9cb2aaf --- /dev/null +++ b/internal/handlers/handlers.go @@ -0,0 +1,30 @@ +package handlers + +import ( + "log" + + "github.com/yourusername/gomail/config" + "github.com/yourusername/gomail/internal/db" + "github.com/yourusername/gomail/internal/syncer" +) + +type Handlers struct { + Auth *AuthHandler + App *AppHandler + API *APIHandler + Admin *AdminHandler +} + +func New(database *db.DB, cfg *config.Config, sc *syncer.Scheduler) *Handlers { + renderer, err := NewRenderer() + if err != nil { + log.Fatalf("failed to load templates: %v", err) + } + + return &Handlers{ + Auth: &AuthHandler{db: database, cfg: cfg, renderer: renderer}, + App: &AppHandler{db: database, cfg: cfg, renderer: renderer}, + API: &APIHandler{db: database, cfg: cfg, syncer: sc}, + Admin: &AdminHandler{db: database, cfg: cfg, renderer: renderer}, + } +} diff --git a/internal/handlers/renderer.go b/internal/handlers/renderer.go new file mode 100644 index 0000000..58d910d --- /dev/null +++ b/internal/handlers/renderer.go @@ -0,0 +1,73 @@ +package handlers + +import ( + "bytes" + "fmt" + "html/template" + "log" + "net/http" + "path/filepath" +) + +// Renderer holds one compiled *template.Template per page name. +// Each entry is parsed from base.html + .html in isolation so that +// {{define}} blocks from one page never bleed into another (the ParseGlob bug). +type Renderer struct { + templates map[string]*template.Template +} + +const ( + tmplBase = "web/templates/base.html" + tmplDir = "web/templates" +) + +// NewRenderer parses every page template paired with the base layout. +// Call once at startup; fails fast if any template has a syntax error. +func NewRenderer() (*Renderer, error) { + pages := []string{ + "app.html", + "login.html", + "mfa.html", + "admin.html", + } + + r := &Renderer{templates: make(map[string]*template.Template, len(pages))} + + for _, page := range pages { + pagePath := filepath.Join(tmplDir, page) + // New instance per page — base FIRST, then the page file. + // This means the page's {{define}} blocks override the base's {{block}} defaults + // without any other page's definitions being present in the same pool. + t, err := template.New("base").ParseFiles(tmplBase, pagePath) + if err != nil { + return nil, fmt.Errorf("renderer: parse %s: %w", page, err) + } + name := page[:len(page)-5] // strip ".html" + r.templates[name] = t + log.Printf("renderer: loaded template %q", name) + } + + return r, nil +} + +// Render executes the named page template and writes it to w. +// Renders into a buffer first so a mid-execution error doesn't send partial HTML. +func (r *Renderer) Render(w http.ResponseWriter, name string, data interface{}) { + t, ok := r.templates[name] + if !ok { + log.Printf("renderer: unknown template %q", name) + http.Error(w, "page not found", http.StatusInternalServerError) + return + } + + var buf bytes.Buffer + // Always execute "base" — it pulls in the page's block overrides automatically. + if err := t.ExecuteTemplate(&buf, "base", data); err != nil { + log.Printf("renderer: execute %q: %v", name, err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + buf.WriteTo(w) +} diff --git a/internal/mfa/totp.go b/internal/mfa/totp.go new file mode 100644 index 0000000..0f7faf5 --- /dev/null +++ b/internal/mfa/totp.go @@ -0,0 +1,100 @@ +// Package mfa provides TOTP-based two-factor authentication (RFC 6238). +// Compatible with Google Authenticator, Authy, and any standard TOTP app. +// No external dependencies — uses only the Go standard library. +package mfa + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha1" + "encoding/base32" + "encoding/binary" + "fmt" + "math" + "net/url" + "strings" + "time" +) + +const ( + totpDigits = 6 + totpPeriod = 30 // seconds + totpWindow = 1 // accept ±1 period to allow for clock skew +) + +// GenerateSecret creates a new random 20-byte (160-bit) TOTP secret, +// returned as a base32-encoded string (the standard format for authenticator apps). +func GenerateSecret() (string, error) { + secret := make([]byte, 20) + if _, err := rand.Read(secret); err != nil { + return "", fmt.Errorf("generate secret: %w", err) + } + return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(secret), nil +} + +// OTPAuthURL builds an otpauth:// URI for QR code generation. +// issuer is the application name (e.g. "GoMail"), accountName is the user's email. +func OTPAuthURL(issuer, accountName, secret string) string { + v := url.Values{} + v.Set("secret", secret) + v.Set("issuer", issuer) + v.Set("algorithm", "SHA1") + v.Set("digits", fmt.Sprintf("%d", totpDigits)) + v.Set("period", fmt.Sprintf("%d", totpPeriod)) + + label := url.PathEscape(issuer + ":" + accountName) + return fmt.Sprintf("otpauth://totp/%s?%s", label, v.Encode()) +} + +// QRCodeURL returns a Google Charts URL that renders the otpauth URI as a QR code. +// In production you'd generate this server-side; this is convenient for self-hosted use. +func QRCodeURL(issuer, accountName, secret string) string { + otpURL := OTPAuthURL(issuer, accountName, secret) + return fmt.Sprintf( + "https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=%s", + url.QueryEscape(otpURL), + ) +} + +// Validate checks whether code is a valid TOTP code for secret at the current time. +// It accepts codes from [now-window*period, now+window*period] to handle clock skew. +func Validate(secret, code string) bool { + code = strings.TrimSpace(code) + if len(code) != totpDigits { + return false + } + keyBytes, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString( + strings.ToUpper(secret), + ) + if err != nil { + return false + } + now := time.Now().Unix() + counter := now / totpPeriod + for delta := int64(-totpWindow); delta <= int64(totpWindow); delta++ { + if totp(keyBytes, counter+delta) == code { + return true + } + } + return false +} + +// totp computes a 6-digit TOTP for the given key and counter (RFC 6238 / HOTP RFC 4226). +func totp(key []byte, counter int64) string { + msg := make([]byte, 8) + binary.BigEndian.PutUint64(msg, uint64(counter)) + + mac := hmac.New(sha1.New, key) + mac.Write(msg) + h := mac.Sum(nil) + + // Dynamic truncation + offset := h[len(h)-1] & 0x0f + code := (int64(h[offset]&0x7f) << 24) | + (int64(h[offset+1]) << 16) | + (int64(h[offset+2]) << 8) | + int64(h[offset+3]) + + otp := code % int64(math.Pow10(totpDigits)) + return fmt.Sprintf("%0*d", totpDigits, otp) +} diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go new file mode 100644 index 0000000..b76f980 --- /dev/null +++ b/internal/middleware/middleware.go @@ -0,0 +1,171 @@ +// Package middleware provides HTTP middleware for GoMail. +package middleware + +import ( + "context" + "log" + "net" + "net/http" + "strings" + "time" + + "github.com/yourusername/gomail/config" + "github.com/yourusername/gomail/internal/db" + "github.com/yourusername/gomail/internal/models" +) + +type contextKey string + +const ( + UserIDKey contextKey = "user_id" + UserRoleKey contextKey = "user_role" +) + +func Logger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rw := &responseWriter{ResponseWriter: w, status: 200} + next.ServeHTTP(rw, r) + log.Printf("%s %s %d %s", r.Method, r.URL.Path, rw.status, time.Since(start)) + }) +} + +type responseWriter struct { + http.ResponseWriter + status int +} + +func (rw *responseWriter) WriteHeader(status int) { + rw.status = status + rw.ResponseWriter.WriteHeader(status) +} + +func SecurityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-XSS-Protection", "1; mode=block") + w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + w.Header().Set("Content-Security-Policy", + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https://api.qrserver.com;") + next.ServeHTTP(w, r) + }) +} + +func CORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + +func JSONContentType(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + next.ServeHTTP(w, r) + }) +} + +// RequireAuth validates the session, enforces MFA, injects user context. +func RequireAuth(database *db.DB, cfg *config.Config) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("gomail_session") + if err != nil || cookie.Value == "" { + redirectToLogin(w, r) + return + } + + userID, mfaVerified, err := database.GetSession(cookie.Value) + if err != nil || userID == 0 { + clearSessionCookie(w, cfg) + redirectToLogin(w, r) + return + } + + user, err := database.GetUserByID(userID) + if err != nil || user == nil || !user.IsActive { + clearSessionCookie(w, cfg) + redirectToLogin(w, r) + return + } + + // MFA gate: if enabled but not yet verified this session + if user.MFAEnabled && !mfaVerified { + if r.URL.Path != "/auth/mfa" && r.URL.Path != "/auth/mfa/verify" { + http.Redirect(w, r, "/auth/mfa", http.StatusFound) + return + } + } + + ctx := context.WithValue(r.Context(), UserIDKey, userID) + ctx = context.WithValue(ctx, UserRoleKey, user.Role) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// RequireAdmin rejects non-admin users with 403. +func RequireAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + role, _ := r.Context().Value(UserRoleKey).(models.UserRole) + if role != models.RoleAdmin { + if isAPIPath(r) { + http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden) + } else { + http.Error(w, "403 Forbidden", http.StatusForbidden) + } + return + } + next.ServeHTTP(w, r) + }) +} + +func redirectToLogin(w http.ResponseWriter, r *http.Request) { + if isAPIPath(r) { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + return + } + http.Redirect(w, r, "/auth/login", http.StatusFound) +} + +func clearSessionCookie(w http.ResponseWriter, cfg *config.Config) { + http.SetCookie(w, &http.Cookie{ + Name: "gomail_session", Value: "", MaxAge: -1, Path: "/", + Secure: cfg.SecureCookie, HttpOnly: true, SameSite: http.SameSiteLaxMode, + }) +} + +func isAPIPath(r *http.Request) bool { + return len(r.URL.Path) >= 4 && r.URL.Path[:4] == "/api" +} + +func GetUserID(r *http.Request) int64 { + id, _ := r.Context().Value(UserIDKey).(int64) + return id +} + +func GetUserRole(r *http.Request) models.UserRole { + role, _ := r.Context().Value(UserRoleKey).(models.UserRole) + return role +} + +func ClientIP(r *http.Request) string { + // Use X-Forwarded-For as-is for logging — proxy trust is enforced at config level + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + parts := strings.Split(xff, ",") + if ip := strings.TrimSpace(parts[0]); ip != "" { + return ip + } + } + if ip, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + return ip + } + return r.RemoteAddr +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..954bece --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,239 @@ +package models + +import "time" + +// ---- Users ---- + +// UserRole controls access level within GoMail. +type UserRole string + +const ( + RoleAdmin UserRole = "admin" + RoleUser UserRole = "user" +) + +// User represents a GoMail application user. +type User struct { + ID int64 `json:"id"` + Email string `json:"email"` + Username string `json:"username"` + PasswordHash string `json:"-"` + Role UserRole `json:"role"` + IsActive bool `json:"is_active"` + // MFA + MFAEnabled bool `json:"mfa_enabled"` + MFASecret string `json:"-"` // TOTP secret, stored encrypted + // Pending MFA setup (secret generated but not yet verified) + MFAPending string `json:"-"` + // Preferences + SyncInterval int `json:"sync_interval"` + ComposePopup bool `json:"compose_popup"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + LastLoginAt *time.Time `json:"last_login_at,omitempty"` +} + +// ---- Audit Log ---- + +// AuditEventType categorises log events. +type AuditEventType string + +const ( + AuditLogin AuditEventType = "login" + AuditLoginFail AuditEventType = "login_fail" + AuditLogout AuditEventType = "logout" + AuditMFASuccess AuditEventType = "mfa_success" + AuditMFAFail AuditEventType = "mfa_fail" + AuditMFAEnable AuditEventType = "mfa_enable" + AuditMFADisable AuditEventType = "mfa_disable" + AuditUserCreate AuditEventType = "user_create" + AuditUserDelete AuditEventType = "user_delete" + AuditUserUpdate AuditEventType = "user_update" + AuditAccountAdd AuditEventType = "account_add" + AuditAccountDel AuditEventType = "account_delete" + AuditSyncRun AuditEventType = "sync_run" + AuditConfigChange AuditEventType = "config_change" + AuditAppError AuditEventType = "app_error" +) + +// AuditLog is a single audit event. +type AuditLog struct { + ID int64 `json:"id"` + UserID *int64 `json:"user_id,omitempty"` + UserEmail string `json:"user_email,omitempty"` + Event AuditEventType `json:"event"` + Detail string `json:"detail,omitempty"` + IPAddress string `json:"ip_address,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// AuditPage is a paginated audit log result. +type AuditPage struct { + Logs []AuditLog `json:"logs"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + HasMore bool `json:"has_more"` +} + +// ---- Email Accounts ---- + +// AccountProvider indicates the email provider type. +type AccountProvider string + +const ( + ProviderGmail AccountProvider = "gmail" + ProviderOutlook AccountProvider = "outlook" + ProviderIMAPSMTP AccountProvider = "imap_smtp" +) + +// EmailAccount represents a connected email account (Gmail, Outlook, IMAP). +type EmailAccount struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + Provider AccountProvider `json:"provider"` + EmailAddress string `json:"email_address"` + DisplayName string `json:"display_name"` + // OAuth tokens (stored encrypted in DB) + AccessToken string `json:"-"` + RefreshToken string `json:"-"` + TokenExpiry time.Time `json:"-"` + // IMAP/SMTP settings (optional, stored encrypted) + IMAPHost string `json:"imap_host,omitempty"` + IMAPPort int `json:"imap_port,omitempty"` + SMTPHost string `json:"smtp_host,omitempty"` + SMTPPort int `json:"smtp_port,omitempty"` + // Sync settings + SyncDays int `json:"sync_days"` // how many days back to fetch (0 = all) + SyncMode string `json:"sync_mode"` // "days" or "all" + // SyncInterval is populated from the owning user's setting during background sync + SyncInterval int `json:"-"` + LastError string `json:"last_error,omitempty"` + // Display + Color string `json:"color"` + IsActive bool `json:"is_active"` + LastSync time.Time `json:"last_sync"` + CreatedAt time.Time `json:"created_at"` +} +// Folder represents a mailbox folder or Gmail label. +type Folder struct { + ID int64 `json:"id"` + AccountID int64 `json:"account_id"` + Name string `json:"name"` // Display name + FullPath string `json:"full_path"` // e.g. "INBOX", "[Gmail]/Sent Mail" + FolderType string `json:"folder_type"` // inbox, sent, drafts, trash, spam, custom + UnreadCount int `json:"unread_count"` + TotalCount int `json:"total_count"` +} + +// ---- Messages ---- + +// MessageFlag represents IMAP message flags. +type MessageFlag string + +const ( + FlagSeen MessageFlag = "\\Seen" + FlagAnswered MessageFlag = "\\Answered" + FlagFlagged MessageFlag = "\\Flagged" + FlagDeleted MessageFlag = "\\Deleted" + FlagDraft MessageFlag = "\\Draft" +) + +// Attachment holds metadata for email attachments. +type Attachment struct { + ID int64 `json:"id"` + MessageID int64 `json:"message_id"` + Filename string `json:"filename"` + ContentType string `json:"content_type"` + Size int64 `json:"size"` + ContentID string `json:"content_id,omitempty"` // for inline attachments + Data []byte `json:"-"` // actual bytes, loaded on demand +} + +// Message represents a cached email message. +type Message struct { + ID int64 `json:"id"` + AccountID int64 `json:"account_id"` + FolderID int64 `json:"folder_id"` + RemoteUID string `json:"remote_uid"` // UID from provider (IMAP UID or Gmail message ID) + ThreadID string `json:"thread_id,omitempty"` + MessageID string `json:"message_id"` // RFC 2822 Message-ID header + // Encrypted fields (stored encrypted, decrypted on read) + Subject string `json:"subject"` + FromName string `json:"from_name"` + FromEmail string `json:"from_email"` + ToList string `json:"to"` // comma-separated + CCList string `json:"cc"` + BCCList string `json:"bcc"` + ReplyTo string `json:"reply_to"` + BodyText string `json:"body_text,omitempty"` + BodyHTML string `json:"body_html,omitempty"` + // Metadata (not encrypted) + Date time.Time `json:"date"` + IsRead bool `json:"is_read"` + IsStarred bool `json:"is_starred"` + IsDraft bool `json:"is_draft"` + HasAttachment bool `json:"has_attachment"` + Attachments []Attachment `json:"attachments,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// MessageSummary is a lightweight version for list views. +type MessageSummary struct { + ID int64 `json:"id"` + AccountID int64 `json:"account_id"` + AccountEmail string `json:"account_email"` + AccountColor string `json:"account_color"` + FolderID int64 `json:"folder_id"` + FolderName string `json:"folder_name"` + Subject string `json:"subject"` + FromName string `json:"from_name"` + FromEmail string `json:"from_email"` + Preview string `json:"preview"` // first ~100 chars of body + Date time.Time `json:"date"` + IsRead bool `json:"is_read"` + IsStarred bool `json:"is_starred"` + HasAttachment bool `json:"has_attachment"` +} + +// ---- Compose ---- + +// ComposeRequest is the payload for sending/replying/forwarding. +type ComposeRequest struct { + AccountID int64 `json:"account_id"` + To []string `json:"to"` + CC []string `json:"cc"` + BCC []string `json:"bcc"` + Subject string `json:"subject"` + BodyHTML string `json:"body_html"` + BodyText string `json:"body_text"` + // For reply/forward + InReplyToID int64 `json:"in_reply_to_id,omitempty"` + ForwardFromID int64 `json:"forward_from_id,omitempty"` +} + +// ---- Search ---- + +// SearchQuery parameters. +type SearchQuery struct { + Query string `json:"query"` + AccountID int64 `json:"account_id"` // 0 = all accounts + FolderID int64 `json:"folder_id"` // 0 = all folders + From string `json:"from"` + To string `json:"to"` + HasAttachment bool `json:"has_attachment"` + IsUnread bool `json:"is_unread"` + IsStarred bool `json:"is_starred"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// PagedMessages is a paginated message result. +type PagedMessages struct { + Messages []MessageSummary `json:"messages"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + HasMore bool `json:"has_more"` +} diff --git a/internal/syncer/syncer.go b/internal/syncer/syncer.go new file mode 100644 index 0000000..35438f9 --- /dev/null +++ b/internal/syncer/syncer.go @@ -0,0 +1,188 @@ +// Package syncer provides background IMAP synchronisation for all active accounts. +package syncer + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/yourusername/gomail/internal/db" + "github.com/yourusername/gomail/internal/email" + "github.com/yourusername/gomail/internal/models" +) + +// Scheduler runs background sync for all active accounts according to their +// individual sync_interval settings. +type Scheduler struct { + db *db.DB + stop chan struct{} +} + +// New creates a new Scheduler. Call Start() to begin background syncing. +func New(database *db.DB) *Scheduler { + return &Scheduler{db: database, stop: make(chan struct{})} +} + +// Start launches the scheduler goroutine. Ticks every minute and checks +// which accounts are due for sync based on last_sync and sync_interval. +func (s *Scheduler) Start() { + go func() { + log.Println("Background sync scheduler started") + s.runDue() + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ticker.C: + s.runDue() + case <-s.stop: + log.Println("Background sync scheduler stopped") + return + } + } + }() +} + +// Stop signals the scheduler to exit. +func (s *Scheduler) Stop() { + close(s.stop) +} + +func (s *Scheduler) runDue() { + accounts, err := s.db.ListAllActiveAccounts() + if err != nil { + log.Printf("Sync scheduler: list accounts: %v", err) + return + } + now := time.Now() + for _, account := range accounts { + if account.SyncInterval <= 0 { + continue + } + nextSync := account.LastSync.Add(time.Duration(account.SyncInterval) * time.Minute) + if account.LastSync.IsZero() || now.After(nextSync) { + go s.syncAccount(account) + } + } +} + +// SyncAccountNow performs an immediate sync of one account. Returns messages synced. +func (s *Scheduler) SyncAccountNow(accountID int64) (int, error) { + account, err := s.db.GetAccount(accountID) + if err != nil || account == nil { + return 0, fmt.Errorf("account %d not found", accountID) + } + return s.doSync(account) +} + +// SyncFolderNow syncs a single folder for an account. +func (s *Scheduler) SyncFolderNow(accountID, folderID int64) (int, error) { + account, err := s.db.GetAccount(accountID) + if err != nil || account == nil { + return 0, fmt.Errorf("account %d not found", accountID) + } + folder, err := s.db.GetFolderByID(folderID) + if err != nil || folder == nil || folder.AccountID != accountID { + return 0, fmt.Errorf("folder %d not found", folderID) + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + c, err := email.Connect(ctx, account) + if err != nil { + return 0, err + } + defer c.Close() + days := account.SyncDays + if days <= 0 || account.SyncMode == "all" { + days = 36500 // ~100 years = full mailbox + } + messages, err := c.FetchMessages(folder.FullPath, days) + if err != nil { + return 0, err + } + synced := 0 + for _, msg := range messages { + msg.FolderID = folder.ID + if err := s.db.UpsertMessage(msg); err == nil { + synced++ + } + } + s.db.UpdateFolderCounts(folder.ID) + s.db.UpdateAccountLastSync(accountID) + return synced, nil +} + +func (s *Scheduler) syncAccount(account *models.EmailAccount) { + synced, err := s.doSync(account) + if err != nil { + log.Printf("Sync [%s]: %v", account.EmailAddress, err) + s.db.SetAccountError(account.ID, err.Error()) + s.db.WriteAudit(nil, models.AuditAppError, + "sync error for "+account.EmailAddress+": "+err.Error(), "", "") + return + } + s.db.ClearAccountError(account.ID) + if synced > 0 { + log.Printf("Synced %d messages for %s", synced, account.EmailAddress) + } +} + +func (s *Scheduler) doSync(account *models.EmailAccount) (int, error) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + c, err := email.Connect(ctx, account) + if err != nil { + return 0, err + } + defer c.Close() + + mailboxes, err := c.ListMailboxes() + if err != nil { + return 0, fmt.Errorf("list mailboxes: %w", err) + } + + synced := 0 + for _, mb := range mailboxes { + folderType := email.InferFolderType(mb.Name, mb.Attributes) + + folder := &models.Folder{ + AccountID: account.ID, + Name: mb.Name, + FullPath: mb.Name, + FolderType: folderType, + } + if err := s.db.UpsertFolder(folder); err != nil { + log.Printf("Upsert folder %s: %v", mb.Name, err) + continue + } + + dbFolder, _ := s.db.GetFolderByPath(account.ID, mb.Name) + if dbFolder == nil { + continue + } + + days := account.SyncDays + if days <= 0 || account.SyncMode == "all" { + days = 36500 // ~100 years = full mailbox + } + messages, err := c.FetchMessages(mb.Name, days) + if err != nil { + log.Printf("Fetch %s/%s: %v", account.EmailAddress, mb.Name, err) + continue + } + + for _, msg := range messages { + msg.FolderID = dbFolder.ID + if err := s.db.UpsertMessage(msg); err == nil { + synced++ + } + } + + s.db.UpdateFolderCounts(dbFolder.ID) + } + + s.db.UpdateAccountLastSync(account.ID) + return synced, nil +} diff --git a/web/static/css/gomail.css b/web/static/css/gomail.css new file mode 100644 index 0000000..b7da2ee --- /dev/null +++ b/web/static/css/gomail.css @@ -0,0 +1,378 @@ +/* ---- Reset & Variables ---- */ +*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} +:root{ + --bg:#0d0f14;--surface:#111318;--surface2:#161920;--surface3:#1c1f28; + --border:#1e2330;--border2:#252a38;--text:#dde1ed;--text2:#9aa0b8; + --muted:#5a6278;--accent:#5b8def;--accent-dim:rgba(91,141,239,.12); + --accent-glow:rgba(91,141,239,.2);--danger:#ef4444;--success:#22c55e; + --warn:#f59e0b;--star:#f59e0b; + --sidebar-w:240px;--panel-w:320px; +} +html,body{height:100%;background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;font-size:14px} + +/* ---- Scrollbar ---- */ +::-webkit-scrollbar{width:5px} +::-webkit-scrollbar-track{background:transparent} +::-webkit-scrollbar-thumb{background:var(--border2);border-radius:3px} + +/* ---- Spinner ---- */ +.spinner{width:20px;height:20px;border:2px solid var(--border2);border-top-color:var(--accent); + border-radius:50%;animation:spin .6s linear infinite;margin:40px auto;display:block} +.spinner-inline{width:14px;height:14px;border:2px solid var(--border2);border-top-color:var(--accent); + border-radius:50%;animation:spin .6s linear infinite;display:inline-block;vertical-align:middle;margin-right:6px} +@keyframes spin{to{transform:rotate(360deg)}} + +/* ---- Toast ---- */ +.toast-container{position:fixed;top:16px;right:16px;z-index:300;display:flex;flex-direction:column;gap:6px} +.toast{padding:9px 14px;border-radius:8px;font-size:13px;font-weight:500;background:var(--surface2); + border:1px solid var(--border2);box-shadow:0 4px 20px rgba(0,0,0,.4);animation:slideIn .2s ease;max-width:320px} +.toast.success{border-color:rgba(34,197,94,.4);background:rgba(34,197,94,.08);color:#86efac} +.toast.error{border-color:rgba(239,68,68,.4);background:rgba(239,68,68,.08);color:#fca5a5} +.toast.warn{border-color:rgba(245,158,11,.4);background:rgba(245,158,11,.08);color:#fde68a} +@keyframes slideIn{from{transform:translateX(20px);opacity:0}to{transform:translateX(0);opacity:1}} + +/* ---- Context menu ---- */ +.ctx-menu{position:fixed;z-index:200;background:var(--surface2);border:1px solid var(--border2); + border-radius:8px;padding:4px;box-shadow:0 8px 30px rgba(0,0,0,.5);min-width:190px;display:none} +.ctx-menu.open{display:block} +.ctx-item{padding:7px 12px;border-radius:5px;font-size:13px;cursor:pointer; + transition:background .1s;display:flex;align-items:center;gap:8px;color:var(--text2)} +.ctx-item:hover{background:var(--surface3);color:var(--text)} +.ctx-item.danger:hover{background:rgba(239,68,68,.1);color:var(--danger)} +.ctx-item svg{width:13px;height:13px;fill:currentColor;flex-shrink:0} +.ctx-sep{height:1px;background:var(--border);margin:3px 0} + +/* ---- Modal ---- */ +.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.65);backdrop-filter:blur(4px); + z-index:100;display:flex;align-items:center;justify-content:center; + opacity:0;pointer-events:none;transition:opacity .2s} +.modal-overlay.open{opacity:1;pointer-events:all} +.modal{width:480px;max-height:90vh;overflow-y:auto;background:var(--surface2); + border:1px solid var(--border2);border-radius:14px;padding:26px; + transform:scale(.95);transition:transform .2s} +.modal-overlay.open .modal{transform:scale(1)} +.modal h2{font-family:'DM Serif Display',serif;font-size:20px;font-weight:400;margin-bottom:6px} +.modal > p{font-size:13px;color:var(--muted);margin-bottom:18px} +.modal-field{margin-bottom:12px} +.modal-field label{display:block;font-size:11px;font-weight:500;text-transform:uppercase; + letter-spacing:.8px;color:var(--muted);margin-bottom:5px} +.modal-field input,.modal-field select,.modal-field textarea{ + width:100%;padding:8px 11px;background:var(--bg);border:1px solid var(--border); + border-radius:7px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px; + outline:none;transition:border-color .2s} +.modal-field input:focus,.modal-field select:focus,.modal-field textarea:focus{border-color:var(--accent)} +.modal-field select option{background:var(--surface2)} +.modal-field textarea{resize:vertical;min-height:80px} +.modal-row{display:flex;gap:10px} +.modal-row .modal-field{flex:1} +.modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:18px} +.modal-submit{padding:8px 18px;background:var(--accent);border:none;border-radius:7px; + color:white;font-family:'DM Sans',sans-serif;font-size:13px;font-weight:500;cursor:pointer;transition:opacity .15s} +.modal-submit:hover{opacity:.85} +.modal-submit:disabled{opacity:.5;cursor:default} +.modal-cancel{padding:8px 14px;background:none;border:1px solid var(--border2);border-radius:7px; + color:var(--text2);font-family:'DM Sans',sans-serif;font-size:13px;cursor:pointer;transition:background .15s} +.modal-cancel:hover{background:var(--surface3)} +.modal-divider{text-align:center;font-size:11px;color:var(--muted);margin:14px 0;position:relative} +.modal-divider::before{content:'';position:absolute;top:50%;left:0;right:0;height:1px;background:var(--border)} +.modal-divider span{background:var(--surface2);padding:0 10px;position:relative} + +/* ---- Buttons ---- */ +.btn-primary{padding:8px 18px;background:var(--accent);border:none;border-radius:7px; + color:white;font-family:'DM Sans',sans-serif;font-size:13px;font-weight:500;cursor:pointer;transition:opacity .15s} +.btn-primary:hover{opacity:.85} +.btn-primary:disabled{opacity:.5;cursor:default} +.btn-secondary{padding:7px 14px;background:none;border:1px solid var(--border2);border-radius:6px; + color:var(--text2);font-family:'DM Sans',sans-serif;font-size:13px;cursor:pointer;transition:background .15s} +.btn-secondary:hover{background:var(--surface3);color:var(--text)} +.btn-danger{padding:7px 14px;background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.3); + border-radius:6px;color:#fca5a5;font-family:'DM Sans',sans-serif;font-size:13px;cursor:pointer;transition:background .15s} +.btn-danger:hover{background:rgba(239,68,68,.2)} +.icon-btn{background:none;border:none;color:var(--muted);cursor:pointer;padding:4px; + border-radius:4px;transition:color .15s,background .15s;display:flex;align-items:center} +.icon-btn:hover{color:var(--text);background:var(--surface3)} +.icon-btn svg{width:14px;height:14px;fill:currentColor} + +/* ---- Forms ---- */ +.field{margin-bottom:16px} +.field label{display:block;font-size:11px;font-weight:500;text-transform:uppercase; + letter-spacing:.8px;color:var(--muted);margin-bottom:6px} +.field input,.field select{width:100%;padding:10px 12px;background:var(--bg);border:1px solid var(--border); + border-radius:8px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none;transition:border-color .2s,box-shadow .2s} +.field input:focus,.field select:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-glow)} + +/* ---- Info / Alert banners ---- */ +.alert{padding:10px 14px;border-radius:8px;font-size:13px;margin-bottom:12px} +.alert.error{background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.3);color:#fca5a5} +.alert.success{background:rgba(34,197,94,.1);border:1px solid rgba(34,197,94,.3);color:#86efac} +.alert.warn{background:rgba(245,158,11,.1);border:1px solid rgba(245,158,11,.3);color:#fde68a} +.alert.info{background:rgba(91,141,239,.1);border:1px solid rgba(91,141,239,.3);color:#93c5fd} + +/* ---- Badges ---- */ +.badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:500} +.badge.green{background:rgba(34,197,94,.15);color:#86efac} +.badge.red{background:rgba(239,68,68,.15);color:#fca5a5} +.badge.blue{background:rgba(91,141,239,.15);color:#93c5fd} +.badge.amber{background:rgba(245,158,11,.15);color:#fde68a} + +/* ---- Tables ---- */ +.data-table{width:100%;border-collapse:collapse} +.data-table th{padding:8px 12px;text-align:left;font-size:11px;font-weight:500; + text-transform:uppercase;letter-spacing:.8px;color:var(--muted);border-bottom:1px solid var(--border)} +.data-table td{padding:10px 12px;border-bottom:1px solid var(--border);font-size:13px} +.data-table tr:last-child td{border-bottom:none} +.data-table tr:hover td{background:var(--surface3)} + +/* ---- Auth pages ---- */ +body.auth-page{display:flex;align-items:center;justify-content:center;min-height:100vh; + background:radial-gradient(ellipse 80% 60% at 50% -10%,rgba(91,141,239,.08) 0%,transparent 70%),var(--bg)} +.auth-card{width:100%;max-width:400px;padding:48px 40px;background:var(--surface); + border:1px solid var(--border);border-radius:16px} +.auth-card .logo{display:flex;align-items:center;gap:10px;margin-bottom:32px} +.auth-card .logo-icon{width:36px;height:36px;background:var(--accent);border-radius:8px; + display:flex;align-items:center;justify-content:center} +.auth-card .logo-icon svg{width:20px;height:20px;fill:white} +.auth-card .logo-text{font-family:'DM Serif Display',serif;font-size:22px} +.auth-card h1{font-size:26px;font-weight:300;font-family:'DM Serif Display',serif;margin-bottom:6px} +.auth-card .subtitle{color:var(--muted);font-size:14px;margin-bottom:32px} + +/* ---- App layout ---- */ +body.app-page{overflow:hidden} +.app{display:flex;height:100vh} + +/* Sidebar */ +.sidebar{width:var(--sidebar-w);flex-shrink:0;background:var(--surface); + border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden} +.sidebar-header{padding:16px 14px 12px;border-bottom:1px solid var(--border); + display:flex;align-items:center;justify-content:space-between} +.logo{display:flex;align-items:center;gap:8px} +.logo-icon{width:26px;height:26px;background:var(--accent);border-radius:6px; + display:flex;align-items:center;justify-content:center;flex-shrink:0} +.logo-icon svg{width:14px;height:14px;fill:white} +.logo-text{font-family:'DM Serif Display',serif;font-size:17px} +.compose-btn{padding:6px 12px;background:var(--accent);border:none;border-radius:6px; + color:white;font-family:'DM Sans',sans-serif;font-size:12px;font-weight:500;cursor:pointer;transition:opacity .15s} +.compose-btn:hover{opacity:.85} +.accounts-section{padding:10px 8px 4px;border-bottom:1px solid var(--border)} +.section-label{font-size:10px;font-weight:500;text-transform:uppercase;letter-spacing:1px; + color:var(--muted);padding:0 6px 6px} +.account-item{display:flex;align-items:center;gap:7px;padding:5px 6px;border-radius:6px; + cursor:pointer;transition:background .1s;position:relative} +.account-item:hover{background:var(--surface3)} +.account-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0} +.account-email{font-size:12px;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1} +.account-error-dot{width:6px;height:6px;background:var(--danger);border-radius:50%;flex-shrink:0} +.add-account-btn{display:flex;align-items:center;gap:6px;padding:5px 6px;color:var(--accent); + font-size:12px;cursor:pointer;border-radius:6px;transition:background .1s;margin-top:2px} +.add-account-btn:hover{background:var(--accent-dim)} +.nav-section{padding:4px 8px;flex:1;overflow-y:auto} +.nav-item{display:flex;align-items:center;gap:9px;padding:7px 8px;border-radius:7px; + cursor:pointer;transition:background .1s;color:var(--text2);user-select:none;font-size:13px} +.nav-item:hover{background:var(--surface3);color:var(--text)} +.nav-item.active{background:var(--accent-dim);color:var(--accent)} +.nav-item svg{width:15px;height:15px;flex-shrink:0} +.unread-badge{margin-left:auto;background:var(--accent);color:white;font-size:10px; + font-weight:600;padding:1px 6px;border-radius:10px;min-width:18px;text-align:center} +.nav-folder-header{font-size:10px;font-weight:500;text-transform:uppercase;letter-spacing:1px; + color:var(--muted);padding:10px 8px 3px;display:flex;align-items:center;gap:6px} +.sidebar-footer{padding:10px 14px;border-top:1px solid var(--border);display:flex; + align-items:center;justify-content:space-between;flex-shrink:0} +.user-info{display:flex;flex-direction:column;gap:2px;min-width:0} +.user-name{font-size:12px;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.footer-actions{display:flex;gap:4px;flex-shrink:0} + +/* Message list panel */ +.message-list-panel{width:var(--panel-w);flex-shrink:0;border-right:1px solid var(--border); + display:flex;flex-direction:column;background:var(--surface)} +.panel-header{padding:14px 14px 10px;border-bottom:1px solid var(--border); + display:flex;align-items:center;justify-content:space-between;flex-shrink:0} +.panel-title{font-family:'DM Serif Display',serif;font-size:17px} +.panel-count{font-size:12px;color:var(--muted)} +.search-bar{padding:8px 10px;border-bottom:1px solid var(--border);flex-shrink:0} +.search-wrap{position:relative} +.search-wrap svg{position:absolute;left:9px;top:50%;transform:translateY(-50%); + width:13px;height:13px;fill:var(--muted);pointer-events:none} +.search-input{width:100%;padding:6px 9px 6px 30px;background:var(--surface3); + border:1px solid var(--border2);border-radius:6px;color:var(--text); + font-family:'DM Sans',sans-serif;font-size:13px;outline:none;transition:border-color .2s} +.search-input:focus{border-color:var(--accent)} +.search-input::placeholder{color:var(--muted)} +.message-list{flex:1;overflow-y:auto} +.message-item{padding:10px 12px;border-bottom:1px solid var(--border);cursor:pointer;transition:background .1s;position:relative} +.message-item:hover{background:var(--surface2)} +.message-item.active{background:var(--accent-dim);border-left:2px solid var(--accent);padding-left:10px} +.message-item.unread .msg-subject{font-weight:500;color:var(--text)} +.message-item.unread::before{content:'';position:absolute;left:0;top:50%;transform:translateY(-50%); + width:3px;height:22px;background:var(--accent);border-radius:0 2px 2px 0} +.message-item.unread.active::before{display:none} +.msg-top{display:flex;align-items:center;justify-content:space-between;gap:6px;margin-bottom:2px} +.msg-from{font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1} +.msg-date{font-size:11px;color:var(--muted);flex-shrink:0} +.msg-subject{font-size:12px;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-bottom:2px} +.msg-preview{font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.msg-meta{display:flex;align-items:center;gap:5px;margin-top:3px} +.msg-dot{width:5px;height:5px;border-radius:50%;flex-shrink:0} +.msg-acct{font-size:10px;color:var(--muted)} +.msg-star{margin-left:auto;color:var(--muted);font-size:11px;cursor:pointer} +.msg-star.on{color:var(--star)} +.load-more{padding:10px;text-align:center} +.load-more-btn{background:none;border:1px solid var(--border2);color:var(--accent); + padding:6px 18px;border-radius:6px;cursor:pointer;font-size:12px;transition:background .15s} +.load-more-btn:hover{background:var(--accent-dim)} +.empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center; + height:200px;color:var(--muted);gap:8px} +.empty-state svg{width:36px;height:36px;fill:var(--border2)} +.empty-state p{font-size:13px} + +/* Message detail */ +.message-detail{flex:1;display:flex;flex-direction:column;overflow:hidden;background:var(--bg)} +.no-message{display:flex;flex-direction:column;align-items:center;justify-content:center; + height:100%;color:var(--muted);gap:10px} +.no-message svg{width:48px;height:48px;fill:var(--border2)} +.no-message h3{font-family:'DM Serif Display',serif;font-size:20px;color:var(--surface3)} +.no-message p{font-size:13px} +.detail-header{padding:16px 20px 12px;border-bottom:1px solid var(--border);flex-shrink:0} +.detail-subject{font-family:'DM Serif Display',serif;font-size:20px;margin-bottom:10px} +.detail-meta{display:flex;align-items:flex-start;justify-content:space-between;gap:12px} +.detail-from{font-size:13px} +.detail-from strong{color:var(--text)} +.detail-from span{color:var(--muted);font-size:12px} +.detail-date{font-size:12px;color:var(--muted);flex-shrink:0} +.detail-actions{padding:8px 20px;border-bottom:1px solid var(--border);display:flex;gap:6px;flex-shrink:0} +.action-btn{padding:5px 12px;background:var(--surface2);border:1px solid var(--border2);border-radius:6px; + color:var(--text2);font-family:'DM Sans',sans-serif;font-size:12px;cursor:pointer;transition:background .15s} +.action-btn:hover{background:var(--surface3);color:var(--text)} +.action-btn.danger:hover{background:rgba(239,68,68,.1);color:var(--danger);border-color:rgba(239,68,68,.3)} +.detail-body{flex:1;overflow-y:auto;padding:20px} +.detail-body-text{font-size:13px;line-height:1.7;color:var(--text2);white-space:pre-wrap;word-break:break-word} +.detail-body iframe{width:100%;border:none;min-height:400px} + +/* Compose */ +.compose-overlay{position:fixed;bottom:20px;right:24px;z-index:50;display:none} +.compose-overlay.open{display:block} +.compose-window{width:540px;background:var(--surface2);border:1px solid var(--border2); + border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.6);display:flex;flex-direction:column} +.compose-header{padding:12px 16px;border-bottom:1px solid var(--border); + display:flex;align-items:center;justify-content:space-between} +.compose-title{font-size:14px;font-weight:500} +.compose-close{background:none;border:none;color:var(--muted);font-size:18px;cursor:pointer; + line-height:1;padding:2px 6px;border-radius:4px} +.compose-close:hover{background:var(--surface3);color:var(--text)} +.compose-field{display:flex;align-items:center;border-bottom:1px solid var(--border);padding:8px 14px;gap:10px} +.compose-field label{font-size:12px;color:var(--muted);width:44px;flex-shrink:0} +.compose-field input,.compose-field select{flex:1;background:none;border:none;color:var(--text); + font-family:'DM Sans',sans-serif;font-size:13px;outline:none} +.compose-field select option{background:var(--surface2)} +.compose-body textarea{width:100%;height:200px;background:none;border:none;color:var(--text); + font-family:'DM Sans',sans-serif;font-size:13px;line-height:1.6;resize:none;outline:none;padding:12px 14px} +.compose-footer{padding:8px 14px;border-top:1px solid var(--border);display:flex;align-items:center;gap:8px} +.send-btn{padding:7px 20px;background:var(--accent);border:none;border-radius:6px;color:white; + font-family:'DM Sans',sans-serif;font-size:13px;font-weight:500;cursor:pointer;transition:opacity .15s} +.send-btn:hover{opacity:.85} +.send-btn:disabled{opacity:.5;cursor:default} + +/* Provider buttons */ +.provider-btns{display:flex;gap:10px;margin-bottom:14px} +.provider-btn{flex:1;padding:10px;background:var(--surface3);border:1px solid var(--border2); + border-radius:8px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;font-weight:500; + cursor:pointer;transition:background .15s;display:flex;align-items:center;gap:8px;justify-content:center} +.provider-btn:hover{background:var(--border2)} +.provider-btn:disabled,.provider-btn.unavailable{opacity:.35;cursor:not-allowed} +.test-result{padding:8px 12px;border-radius:6px;font-size:12px;margin-bottom:10px;display:none} +.test-result.ok{background:rgba(34,197,94,.1);border:1px solid rgba(34,197,94,.3);color:#86efac} +.test-result.err{background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.3);color:#fca5a5} + +/* ---- Admin layout ---- */ +body.admin-page{overflow:auto;background:var(--bg)} +.admin-layout{display:flex;min-height:100vh} +.admin-sidebar{width:220px;flex-shrink:0;background:var(--surface);border-right:1px solid var(--border); + display:flex;flex-direction:column;position:sticky;top:0;height:100vh} +.admin-sidebar .logo-area{padding:20px 18px;border-bottom:1px solid var(--border)} +.admin-sidebar .logo-area a{display:flex;align-items:center;gap:8px;text-decoration:none;color:var(--text)} +.admin-nav{padding:8px} +.admin-nav a{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:7px; + color:var(--text2);text-decoration:none;font-size:13px;transition:background .1s} +.admin-nav a:hover{background:var(--surface3);color:var(--text)} +.admin-nav a.active{background:var(--accent-dim);color:var(--accent)} +.admin-nav a svg{width:15px;height:15px;fill:currentColor;flex-shrink:0} +.admin-main{flex:1;padding:32px 36px;max-width:960px} +.admin-page-header{margin-bottom:28px} +.admin-page-header h1{font-family:'DM Serif Display',serif;font-size:26px;font-weight:400;margin-bottom:4px} +.admin-page-header p{font-size:13px;color:var(--muted)} +.admin-card{background:var(--surface);border:1px solid var(--border);border-radius:12px; + padding:22px 24px;margin-bottom:20px} +.admin-card h3{font-size:14px;font-weight:500;margin-bottom:4px} +.admin-card .card-desc{font-size:12px;color:var(--muted);margin-bottom:16px} +.settings-group{margin-bottom:24px;padding-bottom:24px;border-bottom:1px solid var(--border)} +.settings-group:last-child{border-bottom:none;margin-bottom:0;padding-bottom:0} +.settings-group-title{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.8px; + color:var(--accent);margin-bottom:14px} +.setting-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0; + border-bottom:1px solid var(--border);gap:16px} +.setting-row:last-child{border-bottom:none} +.setting-label{font-size:13px;font-weight:500;margin-bottom:2px} +.setting-desc{font-size:12px;color:var(--muted)} +.setting-control{flex-shrink:0;min-width:200px} +.setting-control input,.setting-control select{width:100%;padding:7px 10px;background:var(--bg); + border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif; + font-size:13px;outline:none} +.setting-control input:focus,.setting-control select:focus{border-color:var(--accent)} +.setting-control input[type=password]{font-family:monospace;letter-spacing:.1em} + +/* ---- Rich text compose editor ---- */ +.compose-toolbar{display:flex;align-items:center;gap:2px;padding:6px 10px;border-bottom:1px solid var(--border);background:var(--surface3);flex-wrap:wrap} +.fmt-btn{background:none;border:none;color:var(--text2);cursor:pointer;padding:4px 7px;border-radius:4px;font-size:13px;line-height:1;transition:background .1s} +.fmt-btn:hover{background:var(--border2);color:var(--text)} +.fmt-sep{width:1px;height:16px;background:var(--border2);margin:0 3px} +.compose-editor{flex:1;min-height:160px;max-height:320px;overflow-y:auto;padding:12px 14px; + font-size:13px;line-height:1.6;color:var(--text);outline:none;background:var(--bg)} +.compose-editor:empty::before{content:attr(placeholder);color:var(--muted);pointer-events:none} +.compose-editor blockquote{border-left:3px solid var(--border2);margin:8px 0;padding-left:12px;color:var(--muted)} +.compose-editor .quote-divider{font-size:11px;color:var(--muted);margin:10px 0 4px} +.compose-editor a{color:var(--accent)} +.compose-editor ul,.compose-editor ol{padding-left:20px} + +/* ---- Remote content banner ---- */ +.remote-content-banner{display:flex;align-items:center;gap:10px;flex-wrap:wrap; + padding:9px 14px;background:rgba(245,158,11,.08);border:1px solid rgba(245,158,11,.3); + border-radius:8px;margin-bottom:12px;font-size:13px;color:#fde68a} +.rcb-btn{padding:4px 12px;background:rgba(245,158,11,.15);border:1px solid rgba(245,158,11,.4); + border-radius:5px;color:#fde68a;cursor:pointer;font-size:12px;white-space:nowrap;transition:background .15s} +.rcb-btn:hover{background:rgba(245,158,11,.25)} +.rcb-whitelist{margin-left:4px} + +/* ---- Attachment chips ---- */ +.attachment-chip{display:inline-flex;align-items:center;gap:5px;padding:4px 10px; + background:var(--surface3);border:1px solid var(--border2);border-radius:6px;font-size:12px;cursor:pointer} +.attachment-chip:hover{background:var(--border2)} +.attachments-bar{display:flex;align-items:center;flex-wrap:wrap;gap:6px; + padding:8px 14px;border-bottom:1px solid var(--border)} + +/* ── Email tag input ─────────────────────────────────────────── */ +.tag-container{display:flex;flex-wrap:wrap;align-items:center;gap:4px;flex:1; + padding:4px 6px;min-height:34px;cursor:text;background:var(--bg); + border:1px solid var(--border);border-radius:6px} +.tag-container:focus-within{border-color:var(--accent);box-shadow:0 0 0 2px rgba(99,102,241,.15)} +.compose-tag-field label{flex-shrink:0;align-self:flex-start;padding-top:8px} +.email-tag{display:inline-flex;align-items:center;gap:4px;padding:2px 6px 2px 8px; + background:var(--surface3);border:1px solid var(--border2);border-radius:12px; + font-size:12px;color:var(--text);white-space:nowrap} +.email-tag.invalid{background:rgba(239,68,68,.1);border-color:rgba(239,68,68,.4);color:#fca5a5} +.tag-remove{background:none;border:none;color:var(--muted);cursor:pointer; + padding:0;font-size:14px;line-height:1;margin-left:2px} +.tag-remove:hover{color:var(--text)} +.tag-input{background:none;border:none;outline:none;color:var(--text);font-size:13px; + font-family:inherit;min-width:120px;flex:1;padding:1px 0} + +/* ── Compose resize handle ───────────────────────────────────── */ +#compose-resize-handle{position:absolute;top:0;left:0;right:0;height:5px; + cursor:n-resize;border-radius:10px 10px 0 0;z-index:1} +#compose-resize-handle:hover{background:var(--accent);opacity:.4} +.compose-window{position:relative;display:flex;flex-direction:column; + min-width:360px;min-height:280px;resize:none} +.compose-attach-list{display:flex;flex-wrap:wrap;gap:6px;padding:6px 14px 0;min-height:0} + +/* ── Icon sync button ─────────────────────────────────────────── */ +.icon-sync-btn{background:none;border:none;color:var(--muted);cursor:pointer; + padding:2px;border-radius:4px;line-height:1;flex-shrink:0;transition:color .15s} +.icon-sync-btn:hover{color:var(--text)} diff --git a/web/static/js/admin.js b/web/static/js/admin.js new file mode 100644 index 0000000..f75b336 --- /dev/null +++ b/web/static/js/admin.js @@ -0,0 +1,311 @@ +// GoMail Admin SPA + +const adminRoutes = { + '/admin': renderUsers, + '/admin/settings': renderSettings, + '/admin/audit': renderAudit, +}; + +function navigate(path) { + history.pushState({}, '', path); + document.querySelectorAll('.admin-nav a').forEach(a => a.classList.toggle('active', a.getAttribute('href') === path)); + const fn = adminRoutes[path]; + if (fn) fn(); +} + +window.addEventListener('popstate', () => { + const fn = adminRoutes[location.pathname]; + if (fn) fn(); +}); + +// ============================================================ +// Users +// ============================================================ +async function renderUsers() { + const el = document.getElementById('admin-content'); + el.innerHTML = ` +
+

Users

+

Manage GoMail accounts and permissions.

+
+
+
+ +
+
+
+ `; + loadUsersTable(); +} + +async function loadUsersTable() { + const r = await api('GET', '/admin/users'); + const el = document.getElementById('users-table'); + if (!r) { el.innerHTML = '

Failed to load users

'; return; } + if (!r.length) { el.innerHTML = '

No users yet.

'; return; } + el.innerHTML = ` + + ${r.map(u => ` + + + + + + + + `).join('')} +
UsernameEmailRoleStatusLast Login
${esc(u.username)}${esc(u.email)}${u.role}${u.is_active?'Active':'Disabled'}${u.last_login_at ? new Date(u.last_login_at).toLocaleDateString() : 'Never'} + + +
`; +} + +function openCreateUser() { + document.getElementById('user-modal-title').textContent = 'New User'; + document.getElementById('user-id').value = ''; + document.getElementById('user-username').value = ''; + document.getElementById('user-email').value = ''; + document.getElementById('user-password').value = ''; + document.getElementById('user-role').value = 'user'; + document.getElementById('user-pw-label').textContent = 'Password'; + document.getElementById('user-active-field').style.display = 'none'; + openModal('user-modal'); +} + +async function openEditUser(userId) { + const r = await api('GET', '/admin/users'); + if (!r) return; + const user = r.find(u => u.id === userId); + if (!user) return; + document.getElementById('user-modal-title').textContent = 'Edit User'; + document.getElementById('user-id').value = userId; + document.getElementById('user-username').value = user.username; + document.getElementById('user-email').value = user.email; + document.getElementById('user-password').value = ''; + document.getElementById('user-role').value = user.role; + document.getElementById('user-active').value = user.is_active ? '1' : '0'; + document.getElementById('user-pw-label').textContent = 'New Password (leave blank to keep)'; + document.getElementById('user-active-field').style.display = 'block'; + openModal('user-modal'); +} + +async function saveUser() { + const userId = document.getElementById('user-id').value; + const body = { + username: document.getElementById('user-username').value.trim(), + email: document.getElementById('user-email').value.trim(), + role: document.getElementById('user-role').value, + is_active: document.getElementById('user-active').value === '1', + }; + const pw = document.getElementById('user-password').value; + if (pw) body.password = pw; + else if (!userId) { toast('Password required for new users', 'error'); return; } + + const r = userId + ? await api('PUT', '/admin/users/' + userId, body) + : await api('POST', '/admin/users', { ...body, password: pw }); + + if (r && r.ok) { toast(userId ? 'User updated' : 'User created', 'success'); closeModal('user-modal'); loadUsersTable(); } + else toast((r && r.error) || 'Save failed', 'error'); +} + +async function deleteUser(userId) { + if (!confirm('Delete this user? All their accounts and messages will be deleted.')) return; + const r = await api('DELETE', '/admin/users/' + userId); + if (r && r.ok) { toast('User deleted', 'success'); loadUsersTable(); } + else toast((r && r.error) || 'Delete failed', 'error'); +} + +// ============================================================ +// Settings +// ============================================================ +const SETTINGS_META = [ + { + group: 'Server', + fields: [ + { key: 'HOSTNAME', label: 'Hostname', desc: 'Public hostname (no protocol or port). e.g. mail.example.com', type: 'text' }, + { key: 'LISTEN_ADDR', label: 'Listen Address', desc: 'Bind address e.g. :8080 or 0.0.0.0:8080', type: 'text' }, + { key: 'BASE_URL', label: 'Base URL', desc: 'Leave blank to auto-build from hostname + port', type: 'text' }, + ] + }, + { + group: 'Security', + fields: [ + { key: 'SECURE_COOKIE', label: 'Secure Cookies', desc: 'Set true when serving over HTTPS', type: 'select', options: ['false','true'] }, + { key: 'TRUSTED_PROXIES', label: 'Trusted Proxies', desc: 'Comma-separated IPs/CIDRs allowed to set X-Forwarded-For', type: 'text' }, + { key: 'SESSION_MAX_AGE', label: 'Session Max Age', desc: 'Session lifetime in seconds (default 604800 = 7 days)', type: 'number' }, + ] + }, + { + group: 'Gmail OAuth', + fields: [ + { key: 'GOOGLE_CLIENT_ID', label: 'Google Client ID', type: 'text' }, + { key: 'GOOGLE_CLIENT_SECRET', label: 'Google Client Secret', type: 'password' }, + { key: 'GOOGLE_REDIRECT_URL', label: 'Google Redirect URL', desc: 'Leave blank to auto-derive from Base URL', type: 'text' }, + ] + }, + { + group: 'Outlook OAuth', + fields: [ + { key: 'MICROSOFT_CLIENT_ID', label: 'Microsoft Client ID', type: 'text' }, + { key: 'MICROSOFT_CLIENT_SECRET', label: 'Microsoft Client Secret', type: 'password' }, + { key: 'MICROSOFT_TENANT_ID', label: 'Microsoft Tenant ID', desc: 'Use "common" for multi-tenant', type: 'text' }, + { key: 'MICROSOFT_REDIRECT_URL', label: 'Microsoft Redirect URL', desc: 'Leave blank to auto-derive from Base URL', type: 'text' }, + ] + }, + { + group: 'Database', + fields: [ + { key: 'DB_PATH', label: 'Database Path', desc: 'Path to SQLite file, relative to working directory', type: 'text' }, + ] + }, +]; + +async function renderSettings() { + const el = document.getElementById('admin-content'); + el.innerHTML = '
'; + const r = await api('GET', '/admin/settings'); + if (!r) { el.innerHTML = '

Failed to load settings

'; return; } + + const groups = SETTINGS_META.map(g => ` +
+
${g.group}
+ ${g.fields.map(f => { + const val = esc(r[f.key] || ''); + const control = f.type === 'select' + ? `` + : ``; + return ` +
+
${f.label}
${f.desc?`
${f.desc}
`:''}
+
${control}
+
`; + }).join('')} +
`).join(''); + + el.innerHTML = ` +
+

Application Settings

+

Changes are saved to data/gomail.conf and take effect immediately for most settings. A restart is required for LISTEN_ADDR changes.

+
+ +
+ ${groups} +
+ + +
+
`; +} + +async function loadSettingsValues() { + const r = await api('GET', '/admin/settings'); + if (!r) return; + SETTINGS_META.forEach(g => g.fields.forEach(f => { + const el = document.getElementById('cfg-' + f.key); + if (el) el.value = r[f.key] || ''; + })); +} + +async function saveSettings() { + const body = {}; + SETTINGS_META.forEach(g => g.fields.forEach(f => { + const el = document.getElementById('cfg-' + f.key); + if (el) body[f.key] = el.value.trim(); + })); + const r = await api('PUT', '/admin/settings', body); + const alertEl = document.getElementById('settings-alert'); + if (r && r.ok) { + toast('Settings saved', 'success'); + alertEl.className = 'alert success'; + alertEl.textContent = 'Settings saved. LISTEN_ADDR changes require a restart.'; + alertEl.style.display = 'block'; + setTimeout(() => alertEl.style.display = 'none', 5000); + } else { + alertEl.className = 'alert error'; + alertEl.textContent = (r && r.error) || 'Save failed'; + alertEl.style.display = 'block'; + } +} + +// ============================================================ +// Audit Log +// ============================================================ +async function renderAudit(page) { + page = page || 1; + const el = document.getElementById('admin-content'); + if (page === 1) el.innerHTML = '
'; + + const r = await api('GET', '/admin/audit?page=' + page + '&page_size=50'); + if (!r) { el.innerHTML = '

Failed to load audit log

'; return; } + + const rows = (r.logs || []).map(l => ` + + ${new Date(l.created_at).toLocaleString()} + ${esc(l.user_email || 'system')} + ${esc(l.event)} + ${esc(l.detail)} + ${esc(l.ip_address)} + `).join(''); + + el.innerHTML = ` +
+

Audit Log

+

Security and administrative activity log.

+
+
+ + + ${rows || ''} +
TimeUserEventDetailIP
No events
+ ${r.has_more ? `
` : ''} +
`; +} + +function eventBadge(evt) { + if (!evt) return 'amber'; + if (evt.includes('login') || evt.includes('auth')) return 'blue'; + if (evt.includes('error') || evt.includes('fail')) return 'red'; + if (evt.includes('delete') || evt.includes('remove')) return 'red'; + if (evt.includes('create') || evt.includes('add')) return 'green'; + return 'amber'; +} + +// Boot: detect current page from URL +(function() { + const path = location.pathname; + document.querySelectorAll('.admin-nav a').forEach(a => a.classList.toggle('active', a.getAttribute('href') === path)); + const fn = adminRoutes[path]; + if (fn) fn(); + else renderUsers(); + + document.querySelectorAll('.admin-nav a').forEach(a => { + a.addEventListener('click', e => { + e.preventDefault(); + navigate(a.getAttribute('href')); + }); + }); +})(); \ No newline at end of file diff --git a/web/static/js/app.js b/web/static/js/app.js new file mode 100644 index 0000000..2d2b167 --- /dev/null +++ b/web/static/js/app.js @@ -0,0 +1,797 @@ +// GoMail app.js — full client + +// ── State ────────────────────────────────────────────────────────────────── +const S = { + me: null, accounts: [], providers: {gmail:false,outlook:false}, + folders: [], messages: [], totalMessages: 0, + currentPage: 1, currentFolder: 'unified', currentFolderName: 'Unified Inbox', + currentMessage: null, selectedMessageId: null, + searchQuery: '', composeMode: 'new', composeReplyToId: null, + remoteWhitelist: new Set(), + draftTimer: null, draftDirty: false, +}; + +// ── Boot ─────────────────────────────────────────────────────────────────── +async function init() { + const [me, providers, wl] = await Promise.all([ + api('GET','/me'), api('GET','/providers'), api('GET','/remote-content-whitelist'), + ]); + if (me) { + S.me = me; + document.getElementById('user-display').textContent = me.username || me.email; + if (me.role === 'admin') document.getElementById('admin-link').style.display = 'block'; + if (me.compose_popup) document.getElementById('compose-popup-toggle').checked = true; + } + if (providers) { S.providers = providers; updateProviderButtons(); } + if (wl?.whitelist) S.remoteWhitelist = new Set(wl.whitelist); + + await Promise.all([loadAccounts(), loadFolders()]); + await loadMessages(); + + const p = new URLSearchParams(location.search); + if (p.get('connected')) { toast('Account connected!', 'success'); history.replaceState({},'',' /'); } + if (p.get('error')) { toast('Connection failed: '+p.get('error'), 'error'); history.replaceState({},'','/'); } + + document.addEventListener('keydown', e => { + if (['INPUT','TEXTAREA','SELECT'].includes(e.target.tagName)) return; + if (e.target.contentEditable === 'true') return; + if ((e.metaKey||e.ctrlKey) && e.key==='n') { e.preventDefault(); openCompose(); } + if ((e.metaKey||e.ctrlKey) && e.key==='k') { e.preventDefault(); document.getElementById('search-input').focus(); } + }); + + // Resizable compose + initComposeResize(); +} + +// ── Providers ────────────────────────────────────────────────────────────── +function updateProviderButtons() { + ['gmail','outlook'].forEach(p => { + const btn = document.getElementById('btn-'+p); + if (!S.providers[p]) { btn.disabled=true; btn.classList.add('unavailable'); btn.title=p+' OAuth not configured'; } + }); +} + +// ── Accounts ─────────────────────────────────────────────────────────────── +async function loadAccounts() { + const data = await api('GET','/accounts'); + if (!data) return; + S.accounts = data; + renderAccounts(); + populateComposeFrom(); +} + +function renderAccounts() { + const el = document.getElementById('accounts-list'); + el.innerHTML = S.accounts.map(a => ` + `).join(''); +} + +function showAccountMenu(e, id) { + e.preventDefault(); e.stopPropagation(); + const a = S.accounts.find(a=>a.id===id); + showCtxMenu(e, ` +
↻ Sync now
+
⚡ Test connection
+
✎ Edit credentials
+ ${a?.last_error?`
⚠ View last error
`:''} +
+
🗑 Remove account
`); +} + +async function syncNow(id, e) { + if (e) e.stopPropagation(); + const btn = document.getElementById('sync-btn-'+id); + if (btn) { btn.style.opacity='0.3'; btn.style.pointerEvents='none'; } + const r = await api('POST','/accounts/'+id+'/sync'); + if (btn) { btn.style.opacity=''; btn.style.pointerEvents=''; } + if (r?.ok) { toast('Synced '+(r.synced||0)+' messages','success'); loadAccounts(); loadFolders(); loadMessages(); } + else toast(r?.error||'Sync failed','error'); +} + +function connectOAuth(p) { location.href='/auth/'+p+'/connect'; } + +// ── Add Account modal ────────────────────────────────────────────────────── +function openAddAccountModal() { + ['imap-email','imap-name','imap-password','imap-host','smtp-host'].forEach(id=>{ const el=document.getElementById(id); if(el) el.value=''; }); + document.getElementById('imap-port').value='993'; + document.getElementById('smtp-port').value='587'; + const r=document.getElementById('test-result'); if(r){r.style.display='none';r.className='test-result';} + openModal('add-account-modal'); +} + +async function testNewConnection() { + const btn=document.getElementById('test-btn'), result=document.getElementById('test-result'); + const body={email:document.getElementById('imap-email').value.trim(),password:document.getElementById('imap-password').value, + imap_host:document.getElementById('imap-host').value.trim(),imap_port:parseInt(document.getElementById('imap-port').value)||993, + smtp_host:document.getElementById('smtp-host').value.trim(),smtp_port:parseInt(document.getElementById('smtp-port').value)||587}; + if (!body.email||!body.password||!body.imap_host){result.textContent='Email, password and IMAP host required.';result.className='test-result err';result.style.display='block';return;} + btn.innerHTML='Testing...';btn.disabled=true; + const r=await api('POST','/accounts/test',body); + btn.textContent='Test Connection';btn.disabled=false; + result.textContent=(r?.ok)?'✓ Connection successful!':((r?.error)||'Connection failed'); + result.className='test-result '+((r?.ok)?'ok':'err'); result.style.display='block'; +} + +async function addIMAPAccount() { + const btn=document.getElementById('save-acct-btn'); + const body={email:document.getElementById('imap-email').value.trim(),display_name:document.getElementById('imap-name').value.trim(), + password:document.getElementById('imap-password').value,imap_host:document.getElementById('imap-host').value.trim(), + imap_port:parseInt(document.getElementById('imap-port').value)||993,smtp_host:document.getElementById('smtp-host').value.trim(), + smtp_port:parseInt(document.getElementById('smtp-port').value)||587}; + if (!body.email||!body.password||!body.imap_host){toast('Email, password and IMAP host required','error');return;} + btn.disabled=true;btn.textContent='Connecting...'; + const r=await api('POST','/accounts',body); + btn.disabled=false;btn.textContent='Connect'; + if (r?.ok){toast('Account added!','success');closeModal('add-account-modal');loadAccounts();loadFolders();loadMessages();} + else toast(r?.error||'Failed to add account','error'); +} + +// ── Edit Account modal ───────────────────────────────────────────────────── +async function openEditAccount(id, testAfterOpen) { + const r=await api('GET','/accounts/'+id); + if (!r) return; + document.getElementById('edit-account-id').value=id; + document.getElementById('edit-account-email').textContent=r.email_address; + document.getElementById('edit-name').value=r.display_name||''; + document.getElementById('edit-password').value=''; + document.getElementById('edit-imap-host').value=r.imap_host||''; + document.getElementById('edit-imap-port').value=r.imap_port||993; + document.getElementById('edit-smtp-host').value=r.smtp_host||''; + document.getElementById('edit-smtp-port').value=r.smtp_port||587; + // Sync settings + document.getElementById('edit-sync-mode').value=r.sync_mode||'days'; + document.getElementById('edit-sync-days').value=r.sync_days||30; + toggleSyncDaysField(); + const errEl=document.getElementById('edit-last-error'), connEl=document.getElementById('edit-conn-result'); + connEl.style.display='none'; + errEl.style.display=r.last_error?'block':'none'; + if (r.last_error) errEl.textContent='Last sync error: '+r.last_error; + openModal('edit-account-modal'); + if (testAfterOpen) setTimeout(testEditConnection,200); +} + +function toggleSyncDaysField() { + const mode=document.getElementById('edit-sync-mode')?.value; + const row=document.getElementById('edit-sync-days-row'); + if (row) row.style.display=(mode==='all')?'none':'flex'; +} + +async function testEditConnection() { + const btn=document.getElementById('edit-test-btn'), connEl=document.getElementById('edit-conn-result'); + const pw=document.getElementById('edit-password').value, email=document.getElementById('edit-account-email').textContent.trim(); + if (!pw){connEl.textContent='Enter new password to test.';connEl.className='test-result err';connEl.style.display='block';return;} + btn.innerHTML='Testing...';btn.disabled=true; + const r=await api('POST','/accounts/test',{email,password:pw, + imap_host:document.getElementById('edit-imap-host').value.trim(),imap_port:parseInt(document.getElementById('edit-imap-port').value)||993, + smtp_host:document.getElementById('edit-smtp-host').value.trim(),smtp_port:parseInt(document.getElementById('edit-smtp-port').value)||587}); + btn.textContent='Test Connection';btn.disabled=false; + connEl.textContent=(r?.ok)?'✓ Successful!':((r?.error)||'Failed'); + connEl.className='test-result '+((r?.ok)?'ok':'err'); connEl.style.display='block'; +} + +async function saveAccountEdit() { + const id=document.getElementById('edit-account-id').value; + const body={display_name:document.getElementById('edit-name').value.trim(), + imap_host:document.getElementById('edit-imap-host').value.trim(),imap_port:parseInt(document.getElementById('edit-imap-port').value)||993, + smtp_host:document.getElementById('edit-smtp-host').value.trim(),smtp_port:parseInt(document.getElementById('edit-smtp-port').value)||587}; + const pw=document.getElementById('edit-password').value; + if (pw) body.password=pw; + const [r1, r2] = await Promise.all([ + api('PUT','/accounts/'+id, body), + api('PUT','/accounts/'+id+'/sync-settings',{ + sync_mode: document.getElementById('edit-sync-mode').value, + sync_days: parseInt(document.getElementById('edit-sync-days').value)||30, + }), + ]); + if (r1?.ok){toast('Account updated','success');closeModal('edit-account-modal');loadAccounts();} + else toast(r1?.error||'Update failed','error'); +} + +async function deleteAccount(id) { + const a=S.accounts.find(a=>a.id===id); + if (!confirm('Remove '+(a?a.email_address:id)+'?\nAll synced messages will be deleted.')) return; + const r=await api('DELETE','/accounts/'+id); + if (r?.ok){toast('Account removed','success');loadAccounts();loadFolders();loadMessages();} + else toast('Remove failed','error'); +} + +// ── Folders ──────────────────────────────────────────────────────────────── +async function loadFolders() { + const data=await api('GET','/folders'); + if (!data) return; + S.folders=data||[]; + renderFolders(); + updateUnreadBadge(); +} + +const FOLDER_ICONS = { + inbox:'', + sent:'', + drafts:'', + trash:'', + spam:'', + archive:'', + custom:'', +}; + +function renderFolders() { + const el=document.getElementById('folders-by-account'); + const accMap={}; S.accounts.forEach(a=>accMap[a.id]=a); + const byAcc={}; + S.folders.forEach(f=>{(byAcc[f.account_id]=byAcc[f.account_id]||[]).push(f);}); + const prio=['inbox','sent','drafts','trash','spam','archive']; + el.innerHTML=Object.entries(byAcc).map(([accId,folders])=>{ + const acc=accMap[parseInt(accId)]; if(!acc) return ''; + const sorted=[...prio.map(t=>folders.find(f=>f.folder_type===t)).filter(Boolean),...folders.filter(f=>f.folder_type==='custom')]; + return ``+sorted.map(f=>` + `).join(''); + }).join(''); +} + +function showFolderMenu(e, folderId, accountId) { + e.preventDefault(); e.stopPropagation(); + showCtxMenu(e, ` +
↻ Sync this folder
+
📂 Open folder
`); +} + +async function syncFolderNow(folderId) { + const r=await api('POST','/folders/'+folderId+'/sync'); + if (r?.ok) { toast('Synced '+(r.synced||0)+' messages','success'); loadFolders(); loadMessages(); } + else toast(r?.error||'Sync failed','error'); +} + +function updateUnreadBadge() { + const total=S.folders.filter(f=>f.folder_type==='inbox').reduce((s,f)=>s+(f.unread_count||0),0); + const badge=document.getElementById('unread-total'); + badge.textContent=total; badge.style.display=total>0?'':'none'; +} + +// ── Messages ─────────────────────────────────────────────────────────────── +function selectFolder(folderId, folderName) { + S.currentFolder=folderId; S.currentFolderName=folderName||S.currentFolderName; + S.currentPage=1; S.messages=[]; S.searchQuery=''; + document.getElementById('search-input').value=''; + document.getElementById('panel-title').textContent=folderName||S.currentFolderName; + document.querySelectorAll('.nav-item').forEach(n=>n.classList.remove('active')); + const navEl=folderId==='unified'?document.getElementById('nav-unified') + :folderId==='starred'?document.getElementById('nav-starred') + :document.getElementById('nav-f'+folderId); + if (navEl) navEl.classList.add('active'); + loadMessages(); +} + +const handleSearch=debounce(q=>{ + S.searchQuery=q.trim(); S.currentPage=1; + document.getElementById('panel-title').textContent=q.trim()?'Search: '+q.trim():S.currentFolderName; + loadMessages(); +},350); + +async function loadMessages(append) { + const list=document.getElementById('message-list'); + if (!append) list.innerHTML='
'; + let result; + if (S.searchQuery) result=await api('GET',`/search?q=${encodeURIComponent(S.searchQuery)}&page=${S.currentPage}&page_size=50`); + else if (S.currentFolder==='unified') result=await api('GET',`/messages/unified?page=${S.currentPage}&page_size=50`); + else result=await api('GET',`/messages?folder_id=${S.currentFolder}&page=${S.currentPage}&page_size=50`); + if (!result){list.innerHTML='

Failed to load

';return;} + S.totalMessages=result.total||(result.messages||[]).length; + if (append) S.messages.push(...(result.messages||[])); + else S.messages=result.messages||[]; + renderMessageList(); + document.getElementById('panel-count').textContent=S.totalMessages>0?S.totalMessages+' messages':''; +} + +function renderMessageList() { + const list=document.getElementById('message-list'); + if (!S.messages.length){ + list.innerHTML=`

No messages

`; + return; + } + list.innerHTML=S.messages.map(m=>` +
+
+ ${esc(m.from_name||m.from_email)} + ${formatDate(m.date)} +
+
${esc(m.subject||'(no subject)')}
+
${esc(m.preview||'')}
+
+ + ${esc(m.account_email||'')} + ${m.has_attachment?'':''} + ${m.is_starred?'★':'☆'} +
+
`).join('')+(S.messages.length`:''); +} + +function loadMoreMessages(){ S.currentPage++; loadMessages(true); } + +async function openMessage(id) { + S.selectedMessageId=id; renderMessageList(); + const detail=document.getElementById('message-detail'); + detail.innerHTML='
'; + const msg=await api('GET','/messages/'+id); + if (!msg){detail.innerHTML='

Failed to load

';return;} + S.currentMessage=msg; + renderMessageDetail(msg, false); + const li=S.messages.find(m=>m.id===id); + if (li&&!li.is_read){li.is_read=true;renderMessageList();} +} + +function renderMessageDetail(msg, showRemoteContent) { + const detail=document.getElementById('message-detail'); + const allowed=showRemoteContent||S.remoteWhitelist.has(msg.from_email); + + let bodyHtml=''; + if (msg.body_html) { + if (allowed) { + bodyHtml=``; + } else { + bodyHtml=`
+ + Remote images blocked. + + +
+
${esc(msg.body_text||'(empty)')}
`; + } + } else { + bodyHtml=`
${esc(msg.body_text||'(empty)')}
`; + } + + let attachHtml=''; + if (msg.attachments?.length) { + attachHtml=`
+ Attachments + ${msg.attachments.map(a=>`
+ 📎 ${esc(a.filename)} + ${formatSize(a.size)} +
`).join('')} +
`; + } + + detail.innerHTML=` +
+
${esc(msg.subject||'(no subject)')}
+
+
+ ${esc(msg.from_name||msg.from_email)} + ${msg.from_name?` <${esc(msg.from_email)}>`:''} + ${msg.to?`
To: ${esc(msg.to)}
`:''} + ${msg.cc?`
CC: ${esc(msg.cc)}
`:''} +
+
${formatFullDate(msg.date)}
+
+
+
+ + + + + + +
+ ${attachHtml} +
${bodyHtml}
`; + + if (msg.body_html && allowed) { + const frame=document.getElementById('msg-frame'); + if (frame) frame.onload=()=>{try{const h=frame.contentDocument.documentElement.scrollHeight;frame.style.height=(h+30)+'px';}catch(e){}}; + } +} + +async function whitelistSender(sender) { + const r=await api('POST','/remote-content-whitelist',{sender}); + if (r?.ok){S.remoteWhitelist.add(sender);toast('Always allowing content from '+sender,'success');if(S.currentMessage)renderMessageDetail(S.currentMessage,false);} +} + +async function showMessageHeaders(id) { + const r=await api('GET','/messages/'+id+'/headers'); + if (!r?.headers) return; + const rows=Object.entries(r.headers).filter(([,v])=>v) + .map(([k,v])=>`${esc(k)}${esc(v)}`).join(''); + const overlay=document.createElement('div'); + overlay.className='modal-overlay open'; + overlay.innerHTML=``; + overlay.addEventListener('click',e=>{if(e.target===overlay)overlay.remove();}); + document.body.appendChild(overlay); +} + +function showMessageMenu(e, id) { + e.preventDefault(); e.stopPropagation(); + const moveFolders=S.folders.slice(0,8).map(f=>`
${esc(f.name)}
`).join(''); + showCtxMenu(e,` +
↩ Reply
+
★ Toggle star
+
⋮ View headers
+ ${moveFolders?`
Move to
${moveFolders}`:''} +
+
🗑 Delete
`); +} + +async function toggleStar(id, e) { + if(e) e.stopPropagation(); + const r=await api('PUT','/messages/'+id+'/star'); + if (r){const m=S.messages.find(m=>m.id===id);if(m)m.is_starred=r.starred;renderMessageList(); + if(S.currentMessage?.id===id){S.currentMessage.is_starred=r.starred;renderMessageDetail(S.currentMessage,false);}} +} + +async function markRead(id, read) { + await api('PUT','/messages/'+id+'/read',{read}); + const m=S.messages.find(m=>m.id===id);if(m){m.is_read=read;renderMessageList();} + loadFolders(); +} + +async function moveMessage(msgId, folderId) { + const r=await api('PUT','/messages/'+msgId+'/move',{folder_id:folderId}); + if(r?.ok){toast('Moved','success');S.messages=S.messages.filter(m=>m.id!==msgId);renderMessageList(); + if(S.currentMessage?.id===msgId)resetDetail();loadFolders();} +} + +async function deleteMessage(id) { + if(!confirm('Delete this message?')) return; + const r=await api('DELETE','/messages/'+id); + if(r?.ok){toast('Deleted','success');S.messages=S.messages.filter(m=>m.id!==id);renderMessageList(); + if(S.currentMessage?.id===id)resetDetail();loadFolders();} +} + +function resetDetail() { + S.currentMessage=null;S.selectedMessageId=null; + document.getElementById('message-detail').innerHTML=`
+ +

Select a message

Choose a message to read it

`; +} + +function formatSize(b){if(!b)return'';if(b<1024)return b+' B';if(b<1048576)return Math.round(b/1024)+' KB';return(b/1048576).toFixed(1)+' MB';} + +// ── Compose ──────────────────────────────────────────────────────────────── +let composeAttachments=[]; + +function populateComposeFrom() { + const sel=document.getElementById('compose-from'); + if(!sel) return; + sel.innerHTML=S.accounts.map(a=>``).join(''); +} + +function openCompose(opts={}) { + S.composeMode=opts.mode||'new'; S.composeReplyToId=opts.replyId||null; + composeAttachments=[]; + document.getElementById('compose-title').textContent=opts.title||'New Message'; + document.getElementById('compose-to').innerHTML=''; + document.getElementById('compose-cc-tags').innerHTML=''; + document.getElementById('compose-bcc-tags').innerHTML=''; + document.getElementById('compose-subject').value=opts.subject||''; + document.getElementById('cc-row').style.display='none'; + document.getElementById('bcc-row').style.display='none'; + const editor=document.getElementById('compose-editor'); + editor.innerHTML=opts.body||''; + S.draftDirty=false; + updateAttachList(); + if (S.me?.compose_popup) { + openComposePopup(); + } else { + document.getElementById('compose-overlay').classList.add('open'); + // Focus the To field's input + setTimeout(()=>{ const inp=document.querySelector('#compose-to .tag-input'); if(inp) inp.focus(); },50); + } + startDraftAutosave(); +} + +function openReply() { if (S.currentMessage) openReplyTo(S.currentMessage.id); } + +function openReplyTo(msgId) { + const msg=(S.currentMessage?.id===msgId)?S.currentMessage:S.messages.find(m=>m.id===msgId); + if (!msg) return; + openCompose({ + mode:'reply', replyId:msgId, title:'Reply', + subject:msg.subject&&!msg.subject.startsWith('Re:')?'Re: '+msg.subject:(msg.subject||''), + body:`

—— Original message ——
${msg.body_html||('
'+esc(msg.body_text||'')+'
')}
`, + }); + // Pre-fill To + addTag('compose-to', msg.from_email||''); +} + +function openForward() { + if (!S.currentMessage) return; + const msg=S.currentMessage; + openCompose({ + mode:'forward', title:'Forward', + subject:'Fwd: '+(msg.subject||''), + body:`

—— Forwarded message ——
From: ${esc(msg.from_email||'')}
${msg.body_html||('
'+esc(msg.body_text||'')+'
')}
`, + }); +} + +function closeCompose(skipDraftCheck) { + if (!skipDraftCheck && S.draftDirty) { + const choice=confirm('Save draft before closing?'); + if (choice) { saveDraft(); return; } + } + clearDraftAutosave(); + if (S.me?.compose_popup) { + const win=window._composeWin; + if (win&&!win.closed) win.close(); + } else { + document.getElementById('compose-overlay').classList.remove('open'); + } + S.draftDirty=false; +} + +// ── Email Tag Input ──────────────────────────────────────────────────────── +function initTagField(containerId) { + const container=document.getElementById(containerId); + if (!container) return; + const inp=document.createElement('input'); + inp.type='text'; inp.className='tag-input'; inp.placeholder=containerId==='compose-to'?'recipient@example.com':''; + container.appendChild(inp); + inp.addEventListener('keydown', e=>{ + if ((e.key===' '||e.key==='Enter'||e.key===','||e.key===';') && inp.value.trim()) { + e.preventDefault(); + addTag(containerId, inp.value.trim().replace(/[,;]$/,'')); + inp.value=''; + } else if (e.key==='Backspace'&&!inp.value) { + const tags=container.querySelectorAll('.email-tag'); + if (tags.length) tags[tags.length-1].remove(); + } + }); + inp.addEventListener('blur', ()=>{ + if (inp.value.trim()) { addTag(containerId, inp.value.trim()); inp.value=''; } + }); + container.addEventListener('click', ()=>inp.focus()); +} + +function addTag(containerId, value) { + if (!value) return; + const container=document.getElementById(containerId); + if (!container) return; + // Basic email validation + const isValid=/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); + const tag=document.createElement('span'); + tag.className='email-tag'+(isValid?'':' invalid'); + tag.textContent=value; + const remove=document.createElement('button'); + remove.innerHTML='×'; remove.className='tag-remove'; + remove.onclick=e=>{e.stopPropagation();tag.remove();S.draftDirty=true;}; + tag.appendChild(remove); + const inp=container.querySelector('.tag-input'); + container.insertBefore(tag, inp); + S.draftDirty=true; +} + +function getTagValues(containerId) { + return Array.from(document.querySelectorAll('#'+containerId+' .email-tag')) + .map(t=>t.textContent.replace('×','').trim()).filter(Boolean); +} + +// ── Draft autosave ───────────────────────────────────────────────────────── +function startDraftAutosave() { + clearDraftAutosave(); + S.draftTimer=setInterval(()=>{ + if (S.draftDirty) saveDraft(true); + }, 60000); // every 60s + // Mark dirty on any edit + const editor=document.getElementById('compose-editor'); + if (editor) editor.oninput=()=>S.draftDirty=true; + ['compose-subject'].forEach(id=>{ + const el=document.getElementById(id); + if(el) el.oninput=()=>S.draftDirty=true; + }); +} + +function clearDraftAutosave() { + if (S.draftTimer) { clearInterval(S.draftTimer); S.draftTimer=null; } +} + +async function saveDraft(silent) { + const accountId=parseInt(document.getElementById('compose-from')?.value||0); + if (!accountId) return; + const to=getTagValues('compose-to'); + const editor=document.getElementById('compose-editor'); + // For now save as a local note — a real IMAP APPEND to Drafts would be ideal + // but for MVP we just suppress the dirty flag and toast + S.draftDirty=false; + if (!silent) toast('Draft saved','success'); + else toast('Draft auto-saved','success'); +} + +// ── Compose formatting ───────────────────────────────────────────────────── +function execFmt(cmd, val) { + document.getElementById('compose-editor').focus(); + document.execCommand(cmd, false, val||null); +} + +function triggerAttach() { document.getElementById('compose-attach-input').click(); } + +function handleAttachFiles(input) { + for (const file of input.files) composeAttachments.push({file,name:file.name,size:file.size}); + input.value=''; updateAttachList(); S.draftDirty=true; +} + +function removeAttachment(i) { composeAttachments.splice(i,1); updateAttachList(); } + +function updateAttachList() { + const el=document.getElementById('compose-attach-list'); + if (!composeAttachments.length){el.innerHTML='';return;} + el.innerHTML=composeAttachments.map((a,i)=>`
+ 📎 ${esc(a.name)} + ${formatSize(a.size)} + +
`).join(''); +} + +async function sendMessage() { + const accountId=parseInt(document.getElementById('compose-from')?.value||0); + const to=getTagValues('compose-to'); + if (!accountId||!to.length){toast('From account and To address required','error');return;} + const editor=document.getElementById('compose-editor'); + const bodyHTML=editor.innerHTML.trim(); + const bodyText=editor.innerText.trim(); + const btn=document.getElementById('send-btn'); + btn.disabled=true;btn.textContent='Sending...'; + const endpoint=S.composeMode==='reply'?'/reply':S.composeMode==='forward'?'/forward':'/send'; + const r=await api('POST',endpoint,{ + account_id:accountId, to, + cc:getTagValues('compose-cc-tags'), + bcc:getTagValues('compose-bcc-tags'), + subject:document.getElementById('compose-subject').value, + body_text:bodyText, body_html:bodyHTML, + in_reply_to_id:S.composeMode==='reply'?S.composeReplyToId:0, + }); + btn.disabled=false;btn.textContent='Send'; + if (r?.ok){toast('Sent!','success');clearDraftAutosave();S.draftDirty=false; + document.getElementById('compose-overlay').classList.remove('open');} + else toast(r?.error||'Send failed','error'); +} + +// ── Resizable compose ────────────────────────────────────────────────────── +function initComposeResize() { + const win=document.getElementById('compose-window'); + if (!win) return; + let resizing=false, startX, startY, startW, startH; + const handle=document.getElementById('compose-resize-handle'); + if (!handle) return; + handle.addEventListener('mousedown', e=>{ + resizing=true; startX=e.clientX; startY=e.clientY; + startW=win.offsetWidth; startH=win.offsetHeight; + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', ()=>{resizing=false;document.removeEventListener('mousemove',onMouseMove);}); + e.preventDefault(); + }); + function onMouseMove(e) { + if (!resizing) return; + const newW=Math.max(360, startW+(e.clientX-startX)); + const newH=Math.max(280, startH-(e.clientY-startY)); + win.style.width=newW+'px'; + win.style.height=newH+'px'; + document.getElementById('compose-editor').style.height=(newH-240)+'px'; + } +} + +// ── Compose popup window ─────────────────────────────────────────────────── +function openComposePopup() { + const popup=window.open('','_blank','width=640,height=520,resizable=yes,scrollbars=yes'); + window._composeWin=popup; + // Simpler: just use the in-page compose anyway for now; popup would need full HTML + // Fall back to in-page for robustness + document.getElementById('compose-overlay').classList.add('open'); +} + +// ── Settings ─────────────────────────────────────────────────────────────── +async function openSettings() { + openModal('settings-modal'); + loadSyncInterval(); + renderMFAPanel(); +} + +async function loadSyncInterval() { + const r=await api('GET','/sync-interval'); + if (r) document.getElementById('sync-interval-select').value=String(r.sync_interval||15); +} + +async function saveSyncInterval() { + const val=parseInt(document.getElementById('sync-interval-select').value)||0; + const r=await api('PUT','/sync-interval',{sync_interval:val}); + if (r?.ok) toast('Saved','success'); else toast('Failed','error'); +} + +async function saveComposePopupPref() { + const val=document.getElementById('compose-popup-toggle').checked; + await api('PUT','/compose-popup',{compose_popup:val}); + if (S.me) S.me.compose_popup=val; +} + +async function changePassword() { + const cur=document.getElementById('cur-pw').value, nw=document.getElementById('new-pw').value; + if (!cur||!nw){toast('Both fields required','error');return;} + const r=await api('POST','/change-password',{current_password:cur,new_password:nw}); + if (r?.ok){toast('Password updated','success');document.getElementById('cur-pw').value='';document.getElementById('new-pw').value='';} + else toast(r?.error||'Failed','error'); +} + +async function renderMFAPanel() { + const me=await api('GET','/me'); + if (!me) return; + const badge=document.getElementById('mfa-badge'), panel=document.getElementById('mfa-panel'); + if (me.mfa_enabled) { + badge.innerHTML='Enabled'; + panel.innerHTML=`

TOTP active. Enter code to disable.

+ + `; + } else { + badge.innerHTML='Disabled'; + panel.innerHTML=''; + } +} + +async function beginMFASetup() { + const r=await api('POST','/mfa/setup'); if (!r) return; + document.getElementById('mfa-panel').innerHTML=` +

Scan with your authenticator app.

+
+

Key: ${r.secret}

+ + `; +} +async function confirmMFASetup() { + const r=await api('POST','/mfa/confirm',{code:document.getElementById('mfa-code').value}); + if (r?.ok){toast('MFA enabled','success');renderMFAPanel();}else toast(r?.error||'Invalid code','error'); +} +async function disableMFA() { + const r=await api('POST','/mfa/disable',{code:document.getElementById('mfa-code').value}); + if (r?.ok){toast('MFA disabled','success');renderMFAPanel();}else toast(r?.error||'Invalid code','error'); +} + +async function doLogout() { await fetch('/auth/logout',{method:'POST'}); location.href='/auth/login'; } + +// ── Context menu helper ──────────────────────────────────────────────────── +function showCtxMenu(e, html) { + const menu=document.getElementById('ctx-menu'); + menu.innerHTML=html; menu.classList.add('open'); + requestAnimationFrame(()=>{ + menu.style.left=Math.min(e.clientX,window.innerWidth-menu.offsetWidth-8)+'px'; + menu.style.top=Math.min(e.clientY,window.innerHeight-menu.offsetHeight-8)+'px'; + }); +} + +// Close compose on overlay click +document.addEventListener('click', e=>{ + if (e.target===document.getElementById('compose-overlay')) { + if (S.draftDirty) { if (confirm('Save draft before closing?')) { saveDraft(); return; } } + closeCompose(true); + } +}); + +// Init tag fields after DOM is ready +document.addEventListener('DOMContentLoaded', ()=>{ + initTagField('compose-to'); + initTagField('compose-cc-tags'); + initTagField('compose-bcc-tags'); +}); + +init(); diff --git a/web/static/js/gomail.js b/web/static/js/gomail.js new file mode 100644 index 0000000..f315098 --- /dev/null +++ b/web/static/js/gomail.js @@ -0,0 +1,110 @@ +// GoMail shared utilities - loaded on every page + +// ---- API helper ---- +async function api(method, path, body) { + const opts = { method, headers: { 'Content-Type': 'application/json' } }; + if (body !== undefined) opts.body = JSON.stringify(body); + try { + const r = await fetch('/api' + path, opts); + if (r.status === 401) { location.href = '/auth/login'; return null; } + return r.json().catch(() => null); + } catch (e) { + console.error('API error:', path, e); + return null; + } +} + +// ---- Toast notifications ---- +function toast(msg, type) { + let container = document.getElementById('toast-container'); + if (!container) { + container = document.createElement('div'); + container.id = 'toast-container'; + container.className = 'toast-container'; + document.body.appendChild(container); + } + const el = document.createElement('div'); + el.className = 'toast' + (type ? ' ' + type : ''); + el.textContent = msg; + container.appendChild(el); + setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity .3s'; }, 3200); + setTimeout(() => el.remove(), 3500); +} + +// ---- HTML escaping ---- +function esc(s) { + return String(s || '') + .replace(/&/g,'&') + .replace(//g,'>') + .replace(/"/g,'"'); +} + +// ---- Date formatting ---- +function formatDate(d) { + if (!d) return ''; + const date = new Date(d), now = new Date(), diff = now - date; + if (diff < 86400000 && date.getDate() === now.getDate()) + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + if (diff < 7 * 86400000) + return date.toLocaleDateString([], { weekday: 'short' }); + return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); +} + +function formatFullDate(d) { + return d ? new Date(d).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) : ''; +} + +// ---- Context menu helpers ---- +function closeMenu() { + const m = document.getElementById('ctx-menu'); + if (m) m.classList.remove('open'); +} + +function positionMenu(menu, x, y) { + menu.style.left = Math.min(x, window.innerWidth - menu.offsetWidth - 8) + 'px'; + menu.style.top = Math.min(y, window.innerHeight - menu.offsetHeight - 8) + 'px'; +} + +// ---- Debounce ---- +function debounce(fn, ms) { + let t; + return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; +} + +// ---- Modal helpers ---- +function openModal(id) { + const el = document.getElementById(id); + if (el) el.classList.add('open'); +} +function closeModal(id) { + const el = document.getElementById(id); + if (el) el.classList.remove('open'); +} + +// Close modals on overlay click +document.addEventListener('click', e => { + if (e.target.classList.contains('modal-overlay')) { + e.target.classList.remove('open'); + } +}); + +// Close context menu on any click +document.addEventListener('click', closeMenu); + +// Keyboard shortcuts +document.addEventListener('keydown', e => { + if (e.key === 'Escape') { + document.querySelectorAll('.modal-overlay.open').forEach(m => m.classList.remove('open')); + closeMenu(); + } +}); + +// ---- Rich text compose helpers ---- +function insertLink() { + const url = prompt('Enter URL:'); + if (!url) return; + const text = window.getSelection().toString() || url; + document.getElementById('compose-editor').focus(); + document.execCommand('createLink', false, url); +} diff --git a/web/templates/admin.html b/web/templates/admin.html new file mode 100644 index 0000000..9586e27 --- /dev/null +++ b/web/templates/admin.html @@ -0,0 +1,39 @@ +{{template "base" .}} +{{define "title"}}GoMail Admin{{end}} +{{define "body_class"}}admin-page{{end}} +{{define "body"}} + +
+
+{{end}} + +{{define "scripts"}} + +{{end}} \ No newline at end of file diff --git a/web/templates/app.html b/web/templates/app.html new file mode 100644 index 0000000..5100106 --- /dev/null +++ b/web/templates/app.html @@ -0,0 +1,251 @@ +{{template "base" .}} +{{define "title"}}GoMail{{end}} +{{define "body_class"}}app-page{{end}} + +{{define "body"}} +
+ + + + +
+
+ Unified Inbox + +
+ +
+
+
+
+ + +
+
+ +

Select a message

+

Choose a message from the list to read it

+
+
+
+ + +
+
+
+
+ New Message + +
+
+
+ + +
+
+ + + + + + + + + +
+
+
+ +
+
+ + + + + + + + + + + +
+
+{{end}} + +{{define "scripts"}} + +{{end}} diff --git a/web/templates/base.html b/web/templates/base.html new file mode 100644 index 0000000..f9d59e7 --- /dev/null +++ b/web/templates/base.html @@ -0,0 +1,17 @@ +{{define "base"}} + + + + +{{block "title" .}}GoMail{{end}} + + +{{block "head_extra" .}}{{end}} + + +{{block "body" .}}{{end}} + +{{block "scripts" .}}{{end}} + + +{{end}} \ No newline at end of file diff --git a/web/templates/login.html b/web/templates/login.html new file mode 100644 index 0000000..d406f15 --- /dev/null +++ b/web/templates/login.html @@ -0,0 +1,27 @@ +{{template "base" .}} +{{define "title"}}GoMail — Sign In{{end}} +{{define "body_class"}}auth-page{{end}} +{{define "body"}} +
+ +

Welcome back

+

Sign in to your GoMail account

+ +
+
+
+ +
+

Default credentials: admin / admin

+
+{{end}} +{{define "scripts"}} + +{{end}} \ No newline at end of file diff --git a/web/templates/mfa.html b/web/templates/mfa.html new file mode 100644 index 0000000..03baec1 --- /dev/null +++ b/web/templates/mfa.html @@ -0,0 +1,27 @@ +{{template "base" .}} +{{define "title"}}GoMail — Two-Factor Auth{{end}} +{{define "body_class"}}auth-page{{end}} +{{define "body"}} +
+ +

Two-Factor Auth

+

Enter the 6-digit code from your authenticator app

+ +
+
+ +
+ +
+
+{{end}} +{{define "scripts"}} + +{{end}} \ No newline at end of file