mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-04-17 08:36:01 +01:00
first commit
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
data/envs
|
||||
data/*.db
|
||||
data/gomail.conf
|
||||
data/*.txt
|
||||
30
Dockerfile
Normal file
30
Dockerfile
Normal file
@@ -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"]
|
||||
674
LICENSE
Normal file
674
LICENSE
Normal file
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
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.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
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
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
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
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
144
README.md
Normal file
144
README.md
Normal file
@@ -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
|
||||
180
cmd/server/main.go
Normal file
180
cmd/server/main.go
Normal file
@@ -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)
|
||||
}
|
||||
621
config/config.go
Normal file
621
config/config.go
Normal file
@@ -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 : <BASE_URL>/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 : <BASE_URL>/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://<hostname>, port 80 → http://<hostname>,
|
||||
// anything else → http://<hostname>:<port>
|
||||
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_<KEY>) 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
|
||||
90
data/gomail.conf.example
Normal file
90
data/gomail.conf.example
Normal file
@@ -0,0 +1,90 @@
|
||||
# GoMail Configuration
|
||||
# =====================
|
||||
# Auto-generated and updated on each startup.
|
||||
# Edit freely — your values are always preserved.
|
||||
# Environment variables (or GOMAIL_<KEY>) 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 : <BASE_URL>/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 : <BASE_URL>/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 =
|
||||
|
||||
35
docker-compose.yml
Normal file
35
docker-compose.yml
Normal file
@@ -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:
|
||||
18
go.mod
Normal file
18
go.mod
Normal file
@@ -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
|
||||
)
|
||||
23
go.sum
Normal file
23
go.sum
Normal file
@@ -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=
|
||||
132
internal/auth/oauth.go
Normal file
132
internal/auth/oauth.go
Normal file
@@ -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()
|
||||
}
|
||||
134
internal/crypto/crypto.go
Normal file
134
internal/crypto/crypto.go
Normal file
@@ -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
|
||||
}
|
||||
1060
internal/db/db.go
Normal file
1060
internal/db/db.go
Normal file
File diff suppressed because it is too large
Load Diff
759
internal/email/imap.go
Normal file
759
internal/email/imap.go
Normal file
@@ -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("<pre>" + htmlEscape(plainText) + "</pre>"))
|
||||
}
|
||||
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
|
||||
}
|
||||
220
internal/handlers/admin.go
Normal file
220
internal/handlers/admin.go
Normal file
@@ -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,
|
||||
})
|
||||
}
|
||||
636
internal/handlers/api.go
Normal file
636
internal/handlers/api.go
Normal file
@@ -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})
|
||||
}
|
||||
19
internal/handlers/app.go
Normal file
19
internal/handlers/app.go
Normal file
@@ -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)
|
||||
}
|
||||
401
internal/handlers/auth.go
Normal file
401
internal/handlers/auth.go
Normal file
@@ -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})
|
||||
}
|
||||
30
internal/handlers/handlers.go
Normal file
30
internal/handlers/handlers.go
Normal file
@@ -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},
|
||||
}
|
||||
}
|
||||
73
internal/handlers/renderer.go
Normal file
73
internal/handlers/renderer.go
Normal file
@@ -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 + <page>.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)
|
||||
}
|
||||
100
internal/mfa/totp.go
Normal file
100
internal/mfa/totp.go
Normal file
@@ -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)
|
||||
}
|
||||
171
internal/middleware/middleware.go
Normal file
171
internal/middleware/middleware.go
Normal file
@@ -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
|
||||
}
|
||||
239
internal/models/models.go
Normal file
239
internal/models/models.go
Normal file
@@ -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"`
|
||||
}
|
||||
188
internal/syncer/syncer.go
Normal file
188
internal/syncer/syncer.go
Normal file
@@ -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
|
||||
}
|
||||
378
web/static/css/gomail.css
Normal file
378
web/static/css/gomail.css
Normal file
@@ -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)}
|
||||
311
web/static/js/admin.js
Normal file
311
web/static/js/admin.js
Normal file
@@ -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 = `
|
||||
<div class="admin-page-header">
|
||||
<h1>Users</h1>
|
||||
<p>Manage GoMail accounts and permissions.</p>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:16px">
|
||||
<button class="btn-primary" onclick="openCreateUser()">+ New User</button>
|
||||
</div>
|
||||
<div id="users-table"><div class="spinner"></div></div>
|
||||
</div>
|
||||
<div class="modal-overlay" id="user-modal">
|
||||
<div class="modal">
|
||||
<h2 id="user-modal-title">New User</h2>
|
||||
<input type="hidden" id="user-id">
|
||||
<div class="modal-field"><label>Username</label><input type="text" id="user-username"></div>
|
||||
<div class="modal-field"><label>Email</label><input type="email" id="user-email"></div>
|
||||
<div class="modal-field"><label id="user-pw-label">Password</label><input type="password" id="user-password" placeholder="Min. 8 characters"></div>
|
||||
<div class="modal-field">
|
||||
<label>Role</label>
|
||||
<select id="user-role">
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-field" id="user-active-field">
|
||||
<label>Active</label>
|
||||
<select id="user-active"><option value="1">Active</option><option value="0">Disabled</option></select>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-cancel" onclick="closeModal('user-modal')">Cancel</button>
|
||||
<button class="modal-submit" onclick="saveUser()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
loadUsersTable();
|
||||
}
|
||||
|
||||
async function loadUsersTable() {
|
||||
const r = await api('GET', '/admin/users');
|
||||
const el = document.getElementById('users-table');
|
||||
if (!r) { el.innerHTML = '<p class="alert error">Failed to load users</p>'; return; }
|
||||
if (!r.length) { el.innerHTML = '<p style="color:var(--muted);font-size:13px">No users yet.</p>'; return; }
|
||||
el.innerHTML = `<table class="data-table">
|
||||
<thead><tr><th>Username</th><th>Email</th><th>Role</th><th>Status</th><th>Last Login</th><th></th></tr></thead>
|
||||
<tbody>${r.map(u => `
|
||||
<tr>
|
||||
<td style="font-weight:500">${esc(u.username)}</td>
|
||||
<td style="color:var(--muted)">${esc(u.email)}</td>
|
||||
<td><span class="badge ${u.role==='admin'?'blue':'amber'}">${u.role}</span></td>
|
||||
<td><span class="badge ${u.is_active?'green':'red'}">${u.is_active?'Active':'Disabled'}</span></td>
|
||||
<td style="color:var(--muted);font-size:12px">${u.last_login_at ? new Date(u.last_login_at).toLocaleDateString() : 'Never'}</td>
|
||||
<td style="display:flex;gap:6px;justify-content:flex-end">
|
||||
<button class="btn-secondary" style="padding:4px 10px;font-size:12px" onclick="openEditUser(${u.id})">Edit</button>
|
||||
<button class="btn-danger" style="padding:4px 10px;font-size:12px" onclick="deleteUser(${u.id})">Delete</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
}
|
||||
|
||||
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 = '<div class="spinner" style="margin-top:80px"></div>';
|
||||
const r = await api('GET', '/admin/settings');
|
||||
if (!r) { el.innerHTML = '<p class="alert error">Failed to load settings</p>'; return; }
|
||||
|
||||
const groups = SETTINGS_META.map(g => `
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">${g.group}</div>
|
||||
${g.fields.map(f => {
|
||||
const val = esc(r[f.key] || '');
|
||||
const control = f.type === 'select'
|
||||
? `<select id="cfg-${f.key}">${f.options.map(o => `<option value="${o}" ${r[f.key]===o?'selected':''}>${o}</option>`).join('')}</select>`
|
||||
: `<input type="${f.type}" id="cfg-${f.key}" value="${val}" placeholder="${f.desc||''}">`;
|
||||
return `
|
||||
<div class="setting-row">
|
||||
<div><div class="setting-label">${f.label}</div>${f.desc?`<div class="setting-desc">${f.desc}</div>`:''}</div>
|
||||
<div class="setting-control">${control}</div>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>`).join('');
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="admin-page-header">
|
||||
<h1>Application Settings</h1>
|
||||
<p>Changes are saved to <code style="font-family:monospace;background:var(--surface3);padding:2px 6px;border-radius:4px">data/gomail.conf</code> and take effect immediately for most settings. A restart is required for LISTEN_ADDR changes.</p>
|
||||
</div>
|
||||
<div id="settings-alert" style="display:none"></div>
|
||||
<div class="admin-card">
|
||||
${groups}
|
||||
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:20px">
|
||||
<button class="btn-secondary" onclick="loadSettingsValues()">Reset</button>
|
||||
<button class="btn-primary" onclick="saveSettings()">Save Settings</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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 = '<div class="spinner" style="margin-top:80px"></div>';
|
||||
|
||||
const r = await api('GET', '/admin/audit?page=' + page + '&page_size=50');
|
||||
if (!r) { el.innerHTML = '<p class="alert error">Failed to load audit log</p>'; return; }
|
||||
|
||||
const rows = (r.logs || []).map(l => `
|
||||
<tr>
|
||||
<td style="font-family:monospace;font-size:11px;color:var(--muted)">${new Date(l.created_at).toLocaleString()}</td>
|
||||
<td style="font-weight:500">${esc(l.user_email || 'system')}</td>
|
||||
<td><span class="badge ${eventBadge(l.event)}">${esc(l.event)}</span></td>
|
||||
<td style="color:var(--muted);font-size:12px">${esc(l.detail)}</td>
|
||||
<td style="font-family:monospace;font-size:11px;color:var(--muted)">${esc(l.ip_address)}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="admin-page-header">
|
||||
<h1>Audit Log</h1>
|
||||
<p>Security and administrative activity log.</p>
|
||||
</div>
|
||||
<div class="admin-card" style="padding:0;overflow:hidden">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Time</th><th>User</th><th>Event</th><th>Detail</th><th>IP</th></tr></thead>
|
||||
<tbody>${rows || '<tr><td colspan="5" style="text-align:center;color:var(--muted);padding:30px">No events</td></tr>'}</tbody>
|
||||
</table>
|
||||
${r.has_more ? `<div style="padding:12px;text-align:center"><button class="load-more-btn" onclick="renderAudit(${page+1})">Load more</button></div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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'));
|
||||
});
|
||||
});
|
||||
})();
|
||||
797
web/static/js/app.js
Normal file
797
web/static/js/app.js
Normal file
@@ -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 => `
|
||||
<div class="account-item" oncontextmenu="showAccountMenu(event,${a.id})"
|
||||
title="${esc(a.email_address)}${a.last_error?' ⚠ '+esc(a.last_error):''}">
|
||||
<div class="account-dot" style="background:${a.color}"></div>
|
||||
<span class="account-email">${esc(a.email_address)}</span>
|
||||
${a.last_error?'<div class="account-error-dot"></div>':''}
|
||||
<button onclick="syncNow(${a.id},event)" id="sync-btn-${a.id}" class="icon-sync-btn" title="Sync now">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg>
|
||||
</button>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function showAccountMenu(e, id) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const a = S.accounts.find(a=>a.id===id);
|
||||
showCtxMenu(e, `
|
||||
<div class="ctx-item" onclick="syncNow(${id});closeMenu()">↻ Sync now</div>
|
||||
<div class="ctx-item" onclick="openEditAccount(${id},true);closeMenu()">⚡ Test connection</div>
|
||||
<div class="ctx-item" onclick="openEditAccount(${id});closeMenu()">✎ Edit credentials</div>
|
||||
${a?.last_error?`<div class="ctx-item" onclick="toast('${esc(a.last_error)}','error');closeMenu()">⚠ View last error</div>`:''}
|
||||
<div class="ctx-sep"></div>
|
||||
<div class="ctx-item danger" onclick="deleteAccount(${id});closeMenu()">🗑 Remove account</div>`);
|
||||
}
|
||||
|
||||
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='<span class="spinner-inline"></span>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='<span class="spinner-inline"></span>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:'<path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/>',
|
||||
sent:'<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>',
|
||||
drafts:'<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>',
|
||||
trash:'<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>',
|
||||
spam:'<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>',
|
||||
archive:'<path d="M20.54 5.23l-1.39-1.68C18.88 3.21 18.47 3 18 3H6c-.47 0-.88.21-1.16.55L3.46 5.23C3.17 5.57 3 6.02 3 6.5V19c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6.5c0-.48-.17-.93-.46-1.27zM12 17.5L6.5 12H10v-2h4v2h3.5L12 17.5zM5.12 5l.81-1h12l.94 1H5.12z"/>',
|
||||
custom:'<path d="M20 6h-2.18c.07-.44.18-.86.18-1 0-2.21-1.79-4-4-4s-4 1.79-4 4c0 .14.11.56.18 1H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2z"/>',
|
||||
};
|
||||
|
||||
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 `<div class="nav-folder-header">
|
||||
<span style="width:6px;height:6px;border-radius:50%;background:${acc.color};display:inline-block;flex-shrink:0"></span>
|
||||
${esc(acc.email_address)}
|
||||
</div>`+sorted.map(f=>`
|
||||
<div class="nav-item" id="nav-f${f.id}" onclick="selectFolder(${f.id},'${esc(f.name)}')"
|
||||
oncontextmenu="showFolderMenu(event,${f.id},${acc.id})">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">${FOLDER_ICONS[f.folder_type]||FOLDER_ICONS.custom}</svg>
|
||||
${esc(f.name)}
|
||||
${f.unread_count>0?`<span class="unread-badge">${f.unread_count}</span>`:''}
|
||||
</div>`).join('');
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function showFolderMenu(e, folderId, accountId) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
showCtxMenu(e, `
|
||||
<div class="ctx-item" onclick="syncFolderNow(${folderId});closeMenu()">↻ Sync this folder</div>
|
||||
<div class="ctx-item" onclick="selectFolder(${folderId});closeMenu()">📂 Open folder</div>`);
|
||||
}
|
||||
|
||||
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='<div class="spinner" style="margin-top:60px"></div>';
|
||||
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='<div class="empty-state"><p>Failed to load</p></div>';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=`<div class="empty-state"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg><p>No messages</p></div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML=S.messages.map(m=>`
|
||||
<div class="message-item ${m.id===S.selectedMessageId?'active':''} ${!m.is_read?'unread':''}"
|
||||
onclick="openMessage(${m.id})" oncontextmenu="showMessageMenu(event,${m.id})">
|
||||
<div class="msg-top">
|
||||
<span class="msg-from">${esc(m.from_name||m.from_email)}</span>
|
||||
<span class="msg-date">${formatDate(m.date)}</span>
|
||||
</div>
|
||||
<div class="msg-subject">${esc(m.subject||'(no subject)')}</div>
|
||||
<div class="msg-preview">${esc(m.preview||'')}</div>
|
||||
<div class="msg-meta">
|
||||
<span class="msg-dot" style="background:${m.account_color}"></span>
|
||||
<span class="msg-acct">${esc(m.account_email||'')}</span>
|
||||
${m.has_attachment?'<svg width="11" height="11" viewBox="0 0 24 24" fill="var(--muted)" style="margin-left:4px"><path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z"/></svg>':''}
|
||||
<span class="msg-star ${m.is_starred?'on':''}" onclick="toggleStar(${m.id},event)">${m.is_starred?'★':'☆'}</span>
|
||||
</div>
|
||||
</div>`).join('')+(S.messages.length<S.totalMessages
|
||||
?`<div class="load-more"><button class="load-more-btn" onclick="loadMoreMessages()">Load more</button></div>`:'');
|
||||
}
|
||||
|
||||
function loadMoreMessages(){ S.currentPage++; loadMessages(true); }
|
||||
|
||||
async function openMessage(id) {
|
||||
S.selectedMessageId=id; renderMessageList();
|
||||
const detail=document.getElementById('message-detail');
|
||||
detail.innerHTML='<div class="spinner" style="margin-top:100px"></div>';
|
||||
const msg=await api('GET','/messages/'+id);
|
||||
if (!msg){detail.innerHTML='<div class="no-message"><p>Failed to load</p></div>';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=`<iframe id="msg-frame" sandbox="allow-same-origin allow-popups"
|
||||
style="width:100%;border:none;min-height:300px;display:block"
|
||||
srcdoc="${msg.body_html.replace(/"/g,'"')}"></iframe>`;
|
||||
} else {
|
||||
bodyHtml=`<div class="remote-content-banner">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>
|
||||
Remote images blocked.
|
||||
<button class="rcb-btn" onclick="renderMessageDetail(S.currentMessage,true)">Load content</button>
|
||||
<button class="rcb-btn" onclick="whitelistSender('${esc(msg.from_email)}')">Always allow from ${esc(msg.from_email)}</button>
|
||||
</div>
|
||||
<div class="detail-body-text">${esc(msg.body_text||'(empty)')}</div>`;
|
||||
}
|
||||
} else {
|
||||
bodyHtml=`<div class="detail-body-text">${esc(msg.body_text||'(empty)')}</div>`;
|
||||
}
|
||||
|
||||
let attachHtml='';
|
||||
if (msg.attachments?.length) {
|
||||
attachHtml=`<div class="attachments-bar">
|
||||
<span style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.6px;margin-right:8px">Attachments</span>
|
||||
${msg.attachments.map(a=>`<div class="attachment-chip">
|
||||
📎 <span>${esc(a.filename)}</span>
|
||||
<span style="color:var(--muted);font-size:10px">${formatSize(a.size)}</span>
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
detail.innerHTML=`
|
||||
<div class="detail-header">
|
||||
<div class="detail-subject">${esc(msg.subject||'(no subject)')}</div>
|
||||
<div class="detail-meta">
|
||||
<div class="detail-from">
|
||||
<strong>${esc(msg.from_name||msg.from_email)}</strong>
|
||||
${msg.from_name?`<span style="color:var(--muted);font-size:12px"> <${esc(msg.from_email)}></span>`:''}
|
||||
${msg.to?`<div style="font-size:12px;color:var(--muted);margin-top:2px">To: ${esc(msg.to)}</div>`:''}
|
||||
${msg.cc?`<div style="font-size:12px;color:var(--muted)">CC: ${esc(msg.cc)}</div>`:''}
|
||||
</div>
|
||||
<div class="detail-date">${formatFullDate(msg.date)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-actions">
|
||||
<button class="action-btn" onclick="openReply()">↩ Reply</button>
|
||||
<button class="action-btn" onclick="openForward()">↪ Forward</button>
|
||||
<button class="action-btn" onclick="toggleStar(${msg.id})">${msg.is_starred?'★ Unstar':'☆ Star'}</button>
|
||||
<button class="action-btn" onclick="markRead(${msg.id},${!msg.is_read})">${msg.is_read?'Mark unread':'Mark read'}</button>
|
||||
<button class="action-btn" onclick="showMessageHeaders(${msg.id})">⋮ Headers</button>
|
||||
<button class="action-btn danger" onclick="deleteMessage(${msg.id})">🗑 Delete</button>
|
||||
</div>
|
||||
${attachHtml}
|
||||
<div class="detail-body">${bodyHtml}</div>`;
|
||||
|
||||
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])=>`<tr><td style="color:var(--muted);padding:4px 12px 4px 0;font-size:12px;white-space:nowrap;vertical-align:top">${esc(k)}</td><td style="font-size:12px;word-break:break-all">${esc(v)}</td></tr>`).join('');
|
||||
const overlay=document.createElement('div');
|
||||
overlay.className='modal-overlay open';
|
||||
overlay.innerHTML=`<div class="modal" style="width:600px;max-height:80vh;overflow-y:auto">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||
<h2 style="margin:0">Message Headers</h2>
|
||||
<button class="icon-btn" onclick="this.closest('.modal-overlay').remove()"><svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>
|
||||
</div>
|
||||
<table style="width:100%"><tbody>${rows}</tbody></table>
|
||||
</div>`;
|
||||
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=>`<div class="ctx-item" onclick="moveMessage(${id},${f.id});closeMenu()">${esc(f.name)}</div>`).join('');
|
||||
showCtxMenu(e,`
|
||||
<div class="ctx-item" onclick="openReplyTo(${id});closeMenu()">↩ Reply</div>
|
||||
<div class="ctx-item" onclick="toggleStar(${id});closeMenu()">★ Toggle star</div>
|
||||
<div class="ctx-item" onclick="showMessageHeaders(${id});closeMenu()">⋮ View headers</div>
|
||||
${moveFolders?`<div class="ctx-sep"></div><div style="font-size:10px;color:var(--muted);padding:4px 12px;text-transform:uppercase;letter-spacing:.8px">Move to</div>${moveFolders}`:''}
|
||||
<div class="ctx-sep"></div>
|
||||
<div class="ctx-item danger" onclick="deleteMessage(${id});closeMenu()">🗑 Delete</div>`);
|
||||
}
|
||||
|
||||
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=`<div class="no-message">
|
||||
<svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg>
|
||||
<h3>Select a message</h3><p>Choose a message to read it</p></div>`;
|
||||
}
|
||||
|
||||
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=>`<option value="${a.id}">${esc(a.display_name||a.email_address)} <${esc(a.email_address)}></option>`).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:`<br><br><div class="quote-divider">—— Original message ——</div><blockquote>${msg.body_html||('<pre>'+esc(msg.body_text||'')+'</pre>')}</blockquote>`,
|
||||
});
|
||||
// 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:`<br><br><div class="quote-divider">—— Forwarded message ——<br>From: ${esc(msg.from_email||'')}</div><blockquote>${msg.body_html||('<pre>'+esc(msg.body_text||'')+'</pre>')}</blockquote>`,
|
||||
});
|
||||
}
|
||||
|
||||
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)=>`<div class="attachment-chip">
|
||||
📎 <span>${esc(a.name)}</span>
|
||||
<span style="color:var(--muted);font-size:10px">${formatSize(a.size)}</span>
|
||||
<button onclick="removeAttachment(${i})" class="tag-remove">×</button>
|
||||
</div>`).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='<span class="badge green">Enabled</span>';
|
||||
panel.innerHTML=`<p style="font-size:13px;color:var(--muted);margin-bottom:12px">TOTP active. Enter code to disable.</p>
|
||||
<div class="modal-field"><label>Code</label><input type="text" id="mfa-code" placeholder="000000" maxlength="6" inputmode="numeric"></div>
|
||||
<button class="btn-danger" onclick="disableMFA()">Disable MFA</button>`;
|
||||
} else {
|
||||
badge.innerHTML='<span class="badge red">Disabled</span>';
|
||||
panel.innerHTML='<button class="btn-primary" onclick="beginMFASetup()">Set up Authenticator App</button>';
|
||||
}
|
||||
}
|
||||
|
||||
async function beginMFASetup() {
|
||||
const r=await api('POST','/mfa/setup'); if (!r) return;
|
||||
document.getElementById('mfa-panel').innerHTML=`
|
||||
<p style="font-size:13px;color:var(--muted);margin-bottom:12px">Scan with your authenticator app.</p>
|
||||
<div style="text-align:center;margin-bottom:14px"><img src="${r.qr_url}" style="border-radius:8px;background:white;padding:8px"></div>
|
||||
<p style="font-size:11px;color:var(--muted);margin-bottom:12px;word-break:break-all">Key: <strong>${r.secret}</strong></p>
|
||||
<div class="modal-field"><label>Confirm code</label><input type="text" id="mfa-code" placeholder="000000" maxlength="6" inputmode="numeric"></div>
|
||||
<button class="btn-primary" onclick="confirmMFASetup()">Activate MFA</button>`;
|
||||
}
|
||||
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();
|
||||
110
web/static/js/gomail.js
Normal file
110
web/static/js/gomail.js
Normal file
@@ -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,'>')
|
||||
.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);
|
||||
}
|
||||
39
web/templates/admin.html
Normal file
39
web/templates/admin.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{{template "base" .}}
|
||||
{{define "title"}}GoMail Admin{{end}}
|
||||
{{define "body_class"}}admin-page{{end}}
|
||||
{{define "body"}}
|
||||
<div class="admin-layout">
|
||||
<nav class="admin-sidebar">
|
||||
<div class="logo-area">
|
||||
<a href="/">
|
||||
<div class="logo-icon"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg></div>
|
||||
<span class="logo-text">GoMail</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="admin-nav">
|
||||
<a href="/admin" id="nav-users" class="active">
|
||||
<svg viewBox="0 0 24 24"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>
|
||||
Users
|
||||
</a>
|
||||
<a href="/admin/settings" id="nav-settings">
|
||||
<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
|
||||
Settings
|
||||
</a>
|
||||
<a href="/admin/audit" id="nav-audit">
|
||||
<svg viewBox="0 0 24 24"><path d="M14 2H6c-1.1 0-1.99.9-1.99 2L4 20c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm2 16H8v-2h8v2zm0-4H8v-2h8v2zm-3-5V3.5L18.5 9H13z"/></svg>
|
||||
Audit Log
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="admin-main" id="admin-content">
|
||||
<div class="spinner" style="margin-top:80px"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
<div class="ctx-menu" id="ctx-menu"></div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script src="/static/js/admin.js"></script>
|
||||
{{end}}
|
||||
251
web/templates/app.html
Normal file
251
web/templates/app.html
Normal file
@@ -0,0 +1,251 @@
|
||||
{{template "base" .}}
|
||||
{{define "title"}}GoMail{{end}}
|
||||
{{define "body_class"}}app-page{{end}}
|
||||
|
||||
{{define "body"}}
|
||||
<div class="app">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo">
|
||||
<div class="logo-icon"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg></div>
|
||||
<span class="logo-text">GoMail</span>
|
||||
</div>
|
||||
<button class="compose-btn" onclick="openCompose()">+ Compose</button>
|
||||
</div>
|
||||
|
||||
<div class="accounts-section">
|
||||
<div class="section-label">Accounts</div>
|
||||
<div id="accounts-list"></div>
|
||||
<div class="add-account-btn" onclick="openModal('add-account-modal')">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||
Connect account
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">
|
||||
<div class="nav-item active" id="nav-unified" onclick="selectFolder('unified','Unified Inbox')">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg>
|
||||
Unified Inbox
|
||||
<span class="unread-badge" id="unread-total" style="display:none"></span>
|
||||
</div>
|
||||
<div class="nav-item" id="nav-starred" onclick="selectFolder('starred','Starred')">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>
|
||||
Starred
|
||||
</div>
|
||||
<div id="folders-by-account"></div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-info">
|
||||
<span class="user-name" id="user-display">...</span>
|
||||
<a href="/admin" id="admin-link" style="display:none;font-size:11px;color:var(--accent);text-decoration:none">Admin</a>
|
||||
</div>
|
||||
<div class="footer-actions">
|
||||
<button class="icon-btn" onclick="openSettings()" title="Settings">
|
||||
<svg viewBox="0 0 24 24"><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
|
||||
</button>
|
||||
<button class="icon-btn" onclick="doLogout()" title="Sign out">
|
||||
<svg viewBox="0 0 24 24"><path d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Message list -->
|
||||
<div class="message-list-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title" id="panel-title">Unified Inbox</span>
|
||||
<span class="panel-count" id="panel-count"></span>
|
||||
</div>
|
||||
<div class="search-bar">
|
||||
<div class="search-wrap">
|
||||
<svg viewBox="0 0 24 24"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
||||
<input class="search-input" type="text" id="search-input" placeholder="Search emails..." oninput="handleSearch(this.value)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-list" id="message-list">
|
||||
<div class="spinner" style="margin-top:60px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message detail -->
|
||||
<main class="message-detail" id="message-detail">
|
||||
<div class="no-message">
|
||||
<svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg>
|
||||
<h3>Select a message</h3>
|
||||
<p>Choose a message from the list to read it</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Compose window -->
|
||||
<div class="compose-overlay" id="compose-overlay">
|
||||
<div class="compose-window" id="compose-window">
|
||||
<div id="compose-resize-handle"></div>
|
||||
<div class="compose-header">
|
||||
<span class="compose-title" id="compose-title">New Message</span>
|
||||
<button class="compose-close" onclick="closeCompose()">×</button>
|
||||
</div>
|
||||
<div class="compose-field"><label>From</label><select id="compose-from"></select></div>
|
||||
<div class="compose-field compose-tag-field"><label>To</label><div id="compose-to" class="tag-container"></div></div>
|
||||
<div class="compose-field compose-tag-field" id="cc-row" style="display:none"><label>CC</label><div id="compose-cc-tags" class="tag-container"></div></div>
|
||||
<div class="compose-field compose-tag-field" id="bcc-row" style="display:none"><label>BCC</label><div id="compose-bcc-tags" class="tag-container"></div></div>
|
||||
<div class="compose-field"><label>Subject</label><input type="text" id="compose-subject" oninput="S.draftDirty=true"></div>
|
||||
<div class="compose-toolbar">
|
||||
<button class="fmt-btn" title="Bold" onclick="execFmt('bold')"><b>B</b></button>
|
||||
<button class="fmt-btn" title="Italic" onclick="execFmt('italic')"><i>I</i></button>
|
||||
<button class="fmt-btn" title="Underline" onclick="execFmt('underline')"><u>U</u></button>
|
||||
<span class="fmt-sep"></span>
|
||||
<button class="fmt-btn" title="Bullets" onclick="execFmt('insertUnorderedList')">•—</button>
|
||||
<button class="fmt-btn" title="Numbers" onclick="execFmt('insertOrderedList')">1—</button>
|
||||
<span class="fmt-sep"></span>
|
||||
<button class="fmt-btn" title="Link" onclick="insertLink()">🔗</button>
|
||||
<button class="fmt-btn" title="Clear format" onclick="execFmt('removeFormat')">T⃗</button>
|
||||
</div>
|
||||
<div id="compose-editor" contenteditable="true" class="compose-editor" placeholder="Write your message..."></div>
|
||||
<div id="compose-attach-list" class="compose-attach-list"></div>
|
||||
<div class="compose-footer">
|
||||
<button class="send-btn" id="send-btn" onclick="sendMessage()">Send</button>
|
||||
<div style="display:flex;gap:6px;margin-left:4px">
|
||||
<button class="btn-secondary" style="font-size:12px" onclick="document.getElementById('cc-row').style.display='flex'">+CC</button>
|
||||
<button class="btn-secondary" style="font-size:12px" onclick="document.getElementById('bcc-row').style.display='flex'">+BCC</button>
|
||||
<button class="btn-secondary" style="font-size:12px" onclick="triggerAttach()">📎 Attach</button>
|
||||
<button class="btn-secondary" style="font-size:12px" onclick="saveDraft()">✎ Draft</button>
|
||||
</div>
|
||||
<input type="file" id="compose-attach-input" multiple style="display:none" onchange="handleAttachFiles(this)">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Account Modal -->
|
||||
<div class="modal-overlay" id="add-account-modal">
|
||||
<div class="modal">
|
||||
<h2>Connect an account</h2>
|
||||
<p>Connect Gmail or Outlook via OAuth, or any email via IMAP/SMTP.</p>
|
||||
<div class="provider-btns">
|
||||
<button class="provider-btn" id="btn-gmail" onclick="connectOAuth('gmail')">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18"><path fill="#EA4335" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#4285F4" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>
|
||||
Gmail
|
||||
</button>
|
||||
<button class="provider-btn" id="btn-outlook" onclick="connectOAuth('outlook')">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="#0078D4"><path d="M21.179 4.781H11.25V12h9.929V4.781zM11.25 19.219h9.929V12H11.25v7.219zM2.821 12H11.25V4.781H2.821V12zm0 7.219H11.25V12H2.821v7.219z"/></svg>
|
||||
Outlook
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-divider"><span>or add IMAP account</span></div>
|
||||
<div class="modal-field"><label>Email Address</label><input type="email" id="imap-email" placeholder="you@example.com"></div>
|
||||
<div class="modal-field"><label>Display Name</label><input type="text" id="imap-name" placeholder="Your Name"></div>
|
||||
<div class="modal-field"><label>Password / App Password</label><input type="password" id="imap-password"></div>
|
||||
<div class="modal-row">
|
||||
<div class="modal-field"><label>IMAP Host</label><input type="text" id="imap-host" placeholder="imap.example.com"></div>
|
||||
<div class="modal-field"><label>IMAP Port</label><input type="number" id="imap-port" value="993"></div>
|
||||
</div>
|
||||
<div class="modal-row">
|
||||
<div class="modal-field"><label>SMTP Host</label><input type="text" id="smtp-host" placeholder="smtp.example.com"></div>
|
||||
<div class="modal-field"><label>SMTP Port</label><input type="number" id="smtp-port" value="587"></div>
|
||||
</div>
|
||||
<div class="test-result" id="test-result"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-cancel" onclick="closeModal('add-account-modal')">Cancel</button>
|
||||
<button class="btn-secondary" onclick="testNewConnection()" id="test-btn">Test Connection</button>
|
||||
<button class="modal-submit" onclick="addIMAPAccount()" id="save-acct-btn">Connect</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Account Modal -->
|
||||
<div class="modal-overlay" id="edit-account-modal">
|
||||
<div class="modal">
|
||||
<h2>Account Settings</h2>
|
||||
<p id="edit-account-email" style="font-weight:500;color:var(--text);margin-bottom:16px"></p>
|
||||
<input type="hidden" id="edit-account-id">
|
||||
<div class="modal-field"><label>Display Name</label><input type="text" id="edit-name"></div>
|
||||
<div class="modal-field"><label>New Password (leave blank to keep current)</label><input type="password" id="edit-password"></div>
|
||||
<div class="modal-row">
|
||||
<div class="modal-field"><label>IMAP Host</label><input type="text" id="edit-imap-host"></div>
|
||||
<div class="modal-field"><label>IMAP Port</label><input type="number" id="edit-imap-port"></div>
|
||||
</div>
|
||||
<div class="modal-row">
|
||||
<div class="modal-field"><label>SMTP Host</label><input type="text" id="edit-smtp-host"></div>
|
||||
<div class="modal-field"><label>SMTP Port</label><input type="number" id="edit-smtp-port"></div>
|
||||
</div>
|
||||
<div class="settings-group-title" style="margin:16px 0 8px">Sync Settings</div>
|
||||
<div class="modal-field">
|
||||
<label>Import mode</label>
|
||||
<select id="edit-sync-mode" onchange="toggleSyncDaysField()" style="padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px;outline:none">
|
||||
<option value="days">Last N days</option>
|
||||
<option value="all">Full mailbox (all email)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-row" id="edit-sync-days-row">
|
||||
<div class="modal-field"><label>Days to fetch</label><input type="number" id="edit-sync-days" value="30" min="1" max="3650"></div>
|
||||
</div>
|
||||
<div id="edit-conn-result" class="test-result" style="display:none"></div>
|
||||
<div id="edit-last-error" style="display:none" class="alert error"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-cancel" onclick="closeModal('edit-account-modal')">Cancel</button>
|
||||
<button class="btn-secondary" id="edit-test-btn" onclick="testEditConnection()">Test Connection</button>
|
||||
<button class="modal-submit" onclick="saveAccountEdit()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div class="modal-overlay" id="settings-modal">
|
||||
<div class="modal" style="width:520px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:22px">
|
||||
<h2 style="margin-bottom:0">Settings</h2>
|
||||
<button onclick="closeModal('settings-modal')" class="icon-btn"><svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Email Sync</div>
|
||||
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">How often to automatically check all your accounts for new mail.</div>
|
||||
<div style="display:flex;gap:10px;align-items:center">
|
||||
<select id="sync-interval-select" style="flex:1;padding:8px 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">
|
||||
<option value="0">Manual only</option>
|
||||
<option value="1">Every 1 minute</option>
|
||||
<option value="5">Every 5 minutes</option>
|
||||
<option value="10">Every 10 minutes</option>
|
||||
<option value="15">Every 15 minutes (default)</option>
|
||||
<option value="30">Every 30 minutes</option>
|
||||
<option value="60">Every 60 minutes</option>
|
||||
</select>
|
||||
<button class="btn-primary" onclick="saveSyncInterval()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Compose Window</div>
|
||||
<div style="font-size:13px;color:var(--muted);margin-bottom:12px">Open new message as an in-page panel (default) or a separate popup window.</div>
|
||||
<label style="display:flex;align-items:center;gap:10px;font-size:13px;cursor:pointer">
|
||||
<input type="checkbox" id="compose-popup-toggle" onchange="saveComposePopupPref()">
|
||||
Open compose in new popup window
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">Change Password</div>
|
||||
<div class="modal-field"><label>Current Password</label><input type="password" id="cur-pw"></div>
|
||||
<div class="modal-field"><label>New Password</label><input type="password" id="new-pw" placeholder="Min. 8 characters"></div>
|
||||
<button class="btn-primary" onclick="changePassword()">Update Password</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title" style="display:flex;align-items:center;gap:10px">
|
||||
Two-Factor Authentication <span id="mfa-badge"></span>
|
||||
</div>
|
||||
<div id="mfa-panel">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context menu -->
|
||||
<div class="ctx-menu" id="ctx-menu"></div>
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script src="/static/js/app.js"></script>
|
||||
{{end}}
|
||||
17
web/templates/base.html
Normal file
17
web/templates/base.html
Normal file
@@ -0,0 +1,17 @@
|
||||
{{define "base"}}<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{block "title" .}}GoMail{{end}}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:ital,wght@0,300;0,400;0,500;1,400&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/css/gomail.css">
|
||||
{{block "head_extra" .}}{{end}}
|
||||
</head>
|
||||
<body class="{{block "body_class" .}}{{end}}">
|
||||
{{block "body" .}}{{end}}
|
||||
<script src="/static/js/gomail.js"></script>
|
||||
{{block "scripts" .}}{{end}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
27
web/templates/login.html
Normal file
27
web/templates/login.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{{template "base" .}}
|
||||
{{define "title"}}GoMail — Sign In{{end}}
|
||||
{{define "body_class"}}auth-page{{end}}
|
||||
{{define "body"}}
|
||||
<div class="auth-card">
|
||||
<div class="logo">
|
||||
<div class="logo-icon"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg></div>
|
||||
<span class="logo-text">GoMail</span>
|
||||
</div>
|
||||
<h1>Welcome back</h1>
|
||||
<p class="subtitle">Sign in to your GoMail account</p>
|
||||
<div id="err" class="alert error" style="display:none"></div>
|
||||
<form method="POST" action="/auth/login">
|
||||
<div class="field"><label>Username or Email</label><input type="text" name="username" placeholder="admin" required autocomplete="username"></div>
|
||||
<div class="field"><label>Password</label><input type="password" name="password" placeholder="••••••••" required autocomplete="current-password"></div>
|
||||
<button class="btn-primary" type="submit" style="width:100%;padding:13px;font-size:15px;margin-top:8px">Sign In</button>
|
||||
</form>
|
||||
<p style="font-size:12px;color:var(--muted);margin-top:16px;text-align:center">Default credentials: <strong>admin</strong> / <strong>admin</strong></p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
const msgs={invalid_credentials:'Invalid username or password.',missing_fields:'Please fill in all fields.'};
|
||||
const k=new URLSearchParams(location.search).get('error');
|
||||
if(k){const b=document.getElementById('err');b.textContent=msgs[k]||'An error occurred.';b.style.display='block';}
|
||||
</script>
|
||||
{{end}}
|
||||
27
web/templates/mfa.html
Normal file
27
web/templates/mfa.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{{template "base" .}}
|
||||
{{define "title"}}GoMail — Two-Factor Auth{{end}}
|
||||
{{define "body_class"}}auth-page{{end}}
|
||||
{{define "body"}}
|
||||
<div class="auth-card">
|
||||
<div class="logo">
|
||||
<div class="logo-icon"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg></div>
|
||||
<span class="logo-text">GoMail</span>
|
||||
</div>
|
||||
<h1>Two-Factor Auth</h1>
|
||||
<p class="subtitle">Enter the 6-digit code from your authenticator app</p>
|
||||
<div id="err" class="alert error" style="display:none"></div>
|
||||
<form method="POST" action="/auth/mfa/verify">
|
||||
<div class="field"><label>Verification Code</label>
|
||||
<input type="text" name="code" placeholder="000000" maxlength="6" inputmode="numeric" autocomplete="one-time-code" autofocus required
|
||||
style="font-size:22px;letter-spacing:.3em;text-align:center">
|
||||
</div>
|
||||
<button class="btn-primary" type="submit" style="width:100%;padding:13px;font-size:15px;margin-top:8px">Verify</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
const k=new URLSearchParams(location.search).get('error');
|
||||
if(k){const b=document.getElementById('err');b.textContent='Invalid code. Please try again.';b.style.display='block';}
|
||||
</script>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user