├── .gitignore
├── Dockerfile
├── LICENSE
├── README.md
├── go.mod
├── go.sum
├── mailgun-docker-compose.yml
├── main.go
├── main_test.go
├── models
└── models.go
└── smtp-docker-compose.yml
/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | *.exe
3 | *.exe~
4 | *.dll
5 | *.so
6 | *.dylib
7 |
8 | # Test binary, built with `go test -c`
9 | *.test
10 |
11 | # Output of the go coverage tool, specifically when used with LiteIDE
12 | *.out
13 |
14 | # Dependency directories (remove the comment below to include it)
15 | # vendor/
16 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:alpine AS builder
2 | # Install git.
3 | # Git is required for fetching the dependencies.
4 | RUN apk update && apk add --no-cache git
5 |
6 | # Create appuser.
7 | ENV USER=appuser
8 | ENV UID=10001
9 | # See https://stackoverflow.com/a/55757473/12429735RUN
10 | RUN adduser \
11 | --disabled-password \
12 | --gecos "" \
13 | --home "/nonexistent" \
14 | --shell "/sbin/nologin" \
15 | --no-create-home \
16 | --uid "${UID}" \
17 | "${USER}"
18 |
19 |
20 | WORKDIR $GOPATH/src/calenvite_svc
21 | COPY . .
22 |
23 | # Fetch and install dependencies.
24 | RUN go get -d -v
25 |
26 | # Using go mod.
27 | RUN go mod download
28 | RUN go mod verify
29 |
30 | # Build the binary.
31 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
32 | go build -a -ldflags="-w -s" -o /go/bin/calenvite_svc
33 |
34 | # STEP 2 build a small image
35 | FROM alpine:latest
36 |
37 | # Import the user and group files from the builder.
38 | COPY --from=builder /etc/passwd /etc/passwd
39 | COPY --from=builder /etc/group /etc/group
40 |
41 | # Copy our static executable.
42 | COPY --from=builder /go/bin/calenvite_svc /go/bin/calenvite_svc
43 |
44 | # Use an unprivileged user.
45 | USER appuser:appuser
46 |
47 | # Run the service
48 | EXPOSE 8000
49 | ENTRYPOINT ["/go/bin/calenvite_svc"]
50 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Adriano Galello
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Calenvite
2 |
3 | A simple microservice designed in [GO](https://golang.org/) using [Echo Microframework](https://echo.labstack.com/) for sending emails and/or calendar invitations to users.
4 |
5 |
6 | # Features
7 |
8 | - Send emails using your Mailgun API credentials.
9 | - Send using a standar SMTP server.
10 | - Support for HTML and Plain Text emails.
11 | - Calendar invitation with RSVP.
12 | - Docker image is built using multistage and alpine image to keep it as small and secure as possible.
13 |
14 | # How to Use
15 |
16 | ### Build the Docker image
17 |
18 | Download the repo and build the docker image:
19 |
20 | ```
21 | $ git clone https://github.com/gdi3d/calenvite
22 | $ docker build -t calenvite_svc:latest .
23 | ```
24 | and use provided [docker-compose files](#Sample-docker-compose-files-included) for more info.
25 |
26 | ### Or Build the binary
27 |
28 | ```
29 | $ git clone https://github.com/gdi3d/calenvite
30 | $ go get -d -v
31 | $ go mod download
32 | $ go mod verify
33 | $ go build -a -o calenvite
34 |
35 | # run de service
36 | $ ./calenvite
37 | ```
38 |
39 | ### Set the Env vars
40 |
41 | There's a few env vars that you need to set when you launch the container in order to work:
42 |
43 | ```
44 | # If you want use Mailgun API:
45 | CALENVITE_SVC_MAILGUN_DOMAIN: The domain from which the email are going to be sent
46 | CALENVITE_SVC_MAILGUN_KEY: The Mailgun API secret key
47 |
48 | # If you want to use SMTP:
49 | CALENVITE_SVC_SMTP_HOST: The host/ip of the SMTP server
50 | CALENVITE_SVC_SMTP_PORT: The port of the SMTP server
51 | CALENVITE_SVC_SMTP_USER: The username to authenticate to the SMTP server
52 | CALENVITE_SVC_SMTP_PASSWORD: The password to authenticate to the SMTP server
53 |
54 | # common to both options
55 | CALENVITE_SVC_EMAIL_SENDER_ADDRESS: The email address that would be used to send the email (this value will be used in the FROM part of the email)
56 | CALENVITE_SVC_SEND_USING: MAILGUN or SMTP
57 | CALENVITE_SVC_PORT: Port to expose (optional, default: 8000)
58 | ```
59 |
60 | ### Sample docker-compose files included
61 |
62 | ```
63 | # mailgun-docker-compose.yml
64 |
65 | version: "3.9"
66 | services:
67 | app_backend:
68 | image: calenvite_svc:latest
69 | ports:
70 | - "8080:8000"
71 | environment:
72 | - CALENVITE_SVC_MAILGUN_DOMAIN=mycooldomain.com
73 | - CALENVITE_SVC_MAILGUN_KEY=abcd1234
74 | - CALENVITE_SVC_EMAIL_SENDER_ADDRESS=no-reply@mycooldomain.com
75 | - CALENVITE_SVC_SEND_USING=MAILGUN
76 | ```
77 |
78 |
79 | ```
80 | # smtp-docker-compose.yml
81 |
82 | version: "3.9"
83 | services:
84 | app_backend:
85 | image: calenvite_svc:latest
86 | ports:
87 | - "8080:8000"
88 | environment:
89 | - CALENVITE_SVC_SMTP_HOST=smtp.mailprovider.com
90 | - CALENVITE_SVC_SMTP_PORT=587
91 | - CALENVITE_SVC_SMTP_USER=mysmtpuser
92 | - CALENVITE_SVC_SMTP_PASSWORD=shhhh.is.secret
93 | - CALENVITE_SVC_EMAIL_SENDER_ADDRESS=no-reply@mycooldomain.com
94 | - CALENVITE_SVC_SEND_USING=SMTP
95 | ```
96 |
97 | # API Docs
98 |
99 | ## Healthcheck Endpoint
100 |
101 | A healthcheck endpoint to test if the service is up and running and a valid configuration is present.
102 |
103 | *Note: This healthcheck is only going to check that the environment vars are defined. It will not check if the credentials are valid or not*
104 |
105 | ### URL
106 |
107 | `/healthcheck/`
108 |
109 | ### Request Example
110 |
111 | `curl --location --request GET 'http://127.0.0.1:8080/healthcheck'`
112 |
113 | ### Responses
114 |
115 | **Service ok**
116 |
117 | ```
118 | HTTP/1.1 200
119 | Content-Type: application/json; charset=UTF-8
120 | Vary: Accept-Encoding
121 |
122 | null
123 | ```
124 |
125 | **Service not working**
126 |
127 | ```
128 | HTTP/1.1 500 Internal Server Error
129 | Content-Type: application/json; charset=UTF-8
130 | Vary: Accept-Encoding
131 |
132 | {
133 | "message": "Internal Server Error"
134 | }
135 | ```
136 |
137 | ## Invite Endpoint
138 |
139 | Send email, and optionally, an calendar invitation with RSVP to the users.
140 |
141 | ### URL
142 |
143 | `/invite/`
144 |
145 | ### Payload
146 |
147 | ```
148 | {
149 | "users": [
150 | {
151 | "full_name": "Eric Cartman",
152 | "email": "ihateyouguys@southpark.cc"
153 | },
154 | {
155 | "full_name": "Tina belcher",
156 | "email": "aaaooo@bobsburger.com"
157 | }
158 | ],
159 | "invitation": {
160 | "start_at": "2030-10-12T07:20:50.52Z",
161 | "end_at": "2030-10-12T08:20:50.52Z",
162 | "organizer_email": "meetingorganizer@meeting.com",
163 | "organizer_full_name": "Mr. Mojo Rising",
164 | "summary": "This meeting will be about...",
165 | "location": "https://zoom.us/332324342",
166 | "description": "Voluptatum ut quis ut. Voluptas qui pariatur quo. Omnis enim rerum dolorum. Qui aut est sed qui voluptatem harum. Consequuntur et accusantium culpa est fuga molestiae in ut. Numquam harum"
167 | },
168 | "email_subject": "You've just been invited!",
169 | "email_body": "
email body about the invitation/event
",
170 | "email_is_html": true
171 | }
172 | ```
173 | *Notes about fields:*
174 |
175 | - If you don't need to send a calendar invitation you can omit the field `invitation`
176 | - If you want to send plain text messages set the key `email_is_html` to `false`
177 |
178 | ### Request example
179 |
180 | ```
181 | curl --location --request POST 'http://127.0.0.1:8080/invite/' \
182 | --header 'Content-Type: application/json' \
183 | --data-raw '{"users":[{"full_name":"Eric Cartman","email":"ihateyouguys@southpark.cc"},{"full_name":"Tina belcher","email":"aaaooo@bobsburger.com"}],"invitation":{"start_at":"2030-10-12T07:20:50.52Z","end_at":"2030-10-12T08:20:50.52Z","organizer_email":"meetingorganizer@meeting.com","organizer_full_name":"Mr. Mojo Rising","summary":"This meeting will be about...","location":"https://zoom.us/332324342","description":"Voluptatum ut quis ut. Voluptas qui pariatur quo. Omnis enim rerum dolorum. Qui aut est sed qui voluptatem harum. Consequuntur et accusantium culpa est fuga molestiae in ut. Numquam harum"},"email_subject":"You'\''ve just been invited!","email_body":"email body about the invitation/event
","email_is_html":true}'
184 | ```
185 |
186 | ### Responses
187 |
188 | **Successful**
189 |
190 | ```
191 | HTTP/1.1 200
192 | Content-Type: application/json; charset=UTF-8
193 | Vary: Accept-Encoding
194 |
195 | {
196 | "message": "SENT_OK",
197 | "status_code": 200,
198 | "error_fields": null
199 | }
200 | ```
201 |
202 | **Field missing/invalid**
203 |
204 | ```
205 | HTTP/1.1 400 BAD REQUEST
206 | Content-Type: application/json; charset=UTF-8
207 | Vary: Accept-Encoding
208 |
209 | {
210 | "message": "INVALID_PAYLOAD",
211 | "status_code": 400,
212 | "error_fields": [
213 | {
214 | "field": "email_body",
215 | "message": "",
216 | "code": "required"
217 | }
218 | ]
219 | }
220 | ```
221 |
222 | **Error**
223 |
224 | ```
225 | HTTP/1.1 500 Internal Server Error
226 | Content-Type: application/json; charset=UTF-8
227 | Vary: Accept-Encoding
228 |
229 | {
230 | "message": "ERROR",
231 | "status_code": 500,
232 | "error_fields": null
233 | }
234 | ```
235 |
236 | # Questions, complains, death threats?
237 |
238 | You can [Contact me 🙋🏻♂️](https://www.linkedin.com/in/adrianogalello/) on LinkedIn if you have any questions. Otherwise you can open a ticket 😉
239 |
240 |
241 |
242 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module calenvite
2 |
3 | go 1.17
4 |
5 | require (
6 | github.com/labstack/echo/v4 v4.6.1
7 | github.com/mailgun/mailgun-go/v4 v4.5.3
8 | )
9 |
10 | require (
11 | github.com/arran4/golang-ical v0.0.0-20210825232153-efac1f4cb8ac // indirect
12 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
13 | gopkg.in/mail.v2 v2.3.1 // indirect
14 | )
15 |
16 | require (
17 | github.com/davecgh/go-spew v1.1.1 // indirect
18 | github.com/go-playground/locales v0.14.0 // indirect
19 | github.com/go-playground/universal-translator v0.18.0 // indirect
20 | github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
21 | github.com/google/uuid v1.3.0 // indirect
22 | github.com/gorilla/mux v1.8.0 // indirect
23 | github.com/json-iterator/go v1.1.10 // indirect
24 | github.com/labstack/gommon v0.3.1 // indirect
25 | github.com/leodido/go-urn v1.2.1 // indirect
26 | github.com/mattn/go-colorable v0.1.11 // indirect
27 | github.com/mattn/go-isatty v0.0.14 // indirect
28 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
29 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 // indirect
30 | github.com/pkg/errors v0.8.1 // indirect
31 | github.com/pmezard/go-difflib v1.0.0 // indirect
32 | github.com/stretchr/objx v0.1.0 // indirect
33 | github.com/stretchr/testify v1.7.0 // indirect
34 | github.com/valyala/bytebufferpool v1.0.0 // indirect
35 | github.com/valyala/fasttemplate v1.2.1 // indirect
36 | golang.org/x/crypto v0.0.0-20211115234514-b4de73f9ece8 // indirect
37 | golang.org/x/mod v0.4.2 // indirect
38 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 // indirect
39 | golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c // indirect
40 | golang.org/x/text v0.3.7 // indirect
41 | golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 // indirect
42 | golang.org/x/tools v0.1.7 // indirect
43 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
44 | gopkg.in/go-playground/validator.v9 v9.31.0 // indirect
45 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df
46 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
47 | )
48 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/arran4/golang-ical v0.0.0-20210825232153-efac1f4cb8ac h1:O/TDwS98y/3RCIpZKpZyFEnzV1Uc46ZXgrMxpL33b+s=
2 | github.com/arran4/golang-ical v0.0.0-20210825232153-efac1f4cb8ac/go.mod h1:BSTTrYHuM12oAL8jDdcmPdw02SBThKYWNFHQlvEG6b0=
3 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
7 | github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64=
8 | github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg=
9 | github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0=
10 | github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
11 | github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
12 | github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
13 | github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
14 | github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
15 | github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
16 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
17 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
18 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
19 | github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
20 | github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
21 | github.com/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr68=
22 | github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
23 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
24 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
25 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
26 | github.com/labstack/echo/v4 v4.6.1 h1:OMVsrnNFzYlGSdaiYGHbgWQnr+JM7NG+B9suCPie14M=
27 | github.com/labstack/echo/v4 v4.6.1/go.mod h1:RnjgMWNDB9g/HucVWhQYNQP9PvbYf6adqftqryo7s9k=
28 | github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
29 | github.com/labstack/gommon v0.3.1 h1:OomWaJXm7xR6L1HmEtGyQf26TEn7V6X88mktX9kee9o=
30 | github.com/labstack/gommon v0.3.1/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
31 | github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
32 | github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
33 | github.com/mailgun/mailgun-go/v4 v4.5.3 h1:Cc4IRTYZVSdDRD7H/wBJRYAwM9DBuFDsbBtsSwqTjCM=
34 | github.com/mailgun/mailgun-go/v4 v4.5.3/go.mod h1:FJlF9rI5cQT+mrwujtJjPMbIVy3Ebor9bKTVsJ0QU40=
35 | github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
36 | github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
37 | github.com/mattn/go-colorable v0.1.11 h1:nQ+aFkoE2TMGc0b68U2OKSexC+eq46+XwZzWXHRmPYs=
38 | github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
39 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
40 | github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
41 | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
42 | github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
43 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
44 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
45 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
46 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
47 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
48 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
49 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
50 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
51 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
52 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
53 | github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
54 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
55 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
56 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
57 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
58 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
59 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
60 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
61 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
62 | github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
63 | github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4=
64 | github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
65 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
66 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
67 | golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
68 | golang.org/x/crypto v0.0.0-20211115234514-b4de73f9ece8 h1:5QRxNnVsaJP6NAse0UdkRgL3zHMvCRRkrDVLNdNpdy4=
69 | golang.org/x/crypto v0.0.0-20211115234514-b4de73f9ece8/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
70 | golang.org/x/mod v0.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo=
71 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
72 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
73 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
74 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
75 | golang.org/x/net v0.0.0-20210913180222-943fd674d43e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
76 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE=
77 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
78 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
79 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
80 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
81 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
82 | golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
83 | golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
84 | golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
85 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
86 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
87 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
88 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
89 | golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
90 | golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
91 | golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
92 | golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c h1:DHcbWVXeY+0Y8HHKR+rbLwnoh2F4tNCY7rTiHJ30RmA=
93 | golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
94 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
95 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
96 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
97 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
98 | golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
99 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
100 | golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 h1:Hir2P/De0WpUhtrKGGjvSb2YxUgyZ7EFOSLIcSSpiwE=
101 | golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
102 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
103 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
104 | golang.org/x/tools v0.1.7 h1:6j8CgantCy3yc8JGBqkDLMKWqZ0RDU2g1HVgacojGWQ=
105 | golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
106 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
107 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
108 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
109 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
110 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
111 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
112 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
113 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
114 | gopkg.in/go-playground/validator.v9 v9.31.0 h1:bmXmP2RSNtFES+bn4uYuHT7iJFJv7Vj+an+ZQdDaD1M=
115 | gopkg.in/go-playground/validator.v9 v9.31.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
116 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE=
117 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw=
118 | gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
119 | gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
120 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
121 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
122 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
123 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
124 |
--------------------------------------------------------------------------------
/mailgun-docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.9"
2 | services:
3 | app_backend:
4 | image: calenvite_svc:latest
5 | ports:
6 | - "8080:8000"
7 | environment:
8 | - CALENVITE_SVC_MAILGUN_DOMAIN=
9 | - CALENVITE_SVC_MAILGUN_KEY=
10 | - CALENVITE_SVC_EMAIL_SENDER_ADDRESS=
11 | - CALENVITE_SVC_SEND_USING=MAILGUN
12 | - CALENVITE_SVC_PORT=8000
13 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "calenvite/models"
5 | "context"
6 | "fmt"
7 | "io/ioutil"
8 | "log"
9 | "net/http"
10 | "os"
11 | "os/signal"
12 | "reflect"
13 | "strconv"
14 | "strings"
15 | "time"
16 |
17 | ics "github.com/arran4/golang-ical"
18 | "github.com/google/uuid"
19 | "github.com/labstack/echo/v4"
20 | "github.com/labstack/echo/v4/middleware"
21 | "github.com/mailgun/mailgun-go/v4"
22 | "gopkg.in/go-playground/validator.v9"
23 | gomail "gopkg.in/gomail.v2"
24 | )
25 |
26 | var settings = models.Settings{
27 | Mailgun: models.Mailgun{
28 | Domain: os.Getenv("CALENVITE_SVC_MAILGUN_DOMAIN"),
29 | SecretKey: os.Getenv("CALENVITE_SVC_MAILGUN_KEY"),
30 | },
31 | SMTP: models.SMTP{
32 | Host: os.Getenv("CALENVITE_SVC_SMTP_HOST"),
33 | Port: os.Getenv("CALENVITE_SVC_SMTP_PORT"),
34 | User: os.Getenv("CALENVITE_SVC_SMTP_USER"),
35 | Password: os.Getenv("CALENVITE_SVC_SMTP_PASSWORD"),
36 | },
37 | SenderAddress: os.Getenv("CALENVITE_SVC_EMAIL_SENDER_ADDRESS"),
38 | SendUsing: os.Getenv("CALENVITE_SVC_SEND_USING"),
39 | ServicePort: os.Getenv("CALENVITE_SVC_PORT"),
40 | }
41 |
42 | var API500Error = models.APIResponse{
43 | Message: "ERROR",
44 | StatusCode: http.StatusInternalServerError,
45 | ErrorFields: nil,
46 | }
47 |
48 | // use a single instance of Validate, it caches struct info
49 | var validate *validator.Validate
50 |
51 | func HealthcheckHandler(c echo.Context) error {
52 |
53 | if value, ok := os.LookupEnv("CALENVITE_SVC_SEND_USING"); ok {
54 | if value != "MAILGUN" && value != "SMTP" {
55 | log.Printf("Env var CALENVITE_SVC_SEND_USING value invalid: %s. Valid Values: MAILGUN or SMTP. Check documentation\n", value)
56 | return c.JSON(http.StatusInternalServerError, nil)
57 | }
58 | } else {
59 | log.Println("Env var CALENVITE_SVC_SEND_USING not set. Check documentation")
60 | return c.JSON(http.StatusInternalServerError, nil)
61 |
62 | }
63 |
64 | if value, ok := os.LookupEnv("CALENVITE_SVC_SEND_USING"); ok {
65 | if value == "MAILGUN" {
66 | if _, ok := os.LookupEnv("CALENVITE_SVC_MAILGUN_DOMAIN"); !ok {
67 | log.Println("Env var CALENVITE_SVC_MAILGUN_DOMAIN not set. Check documentation")
68 | return c.JSON(http.StatusInternalServerError, nil)
69 | }
70 |
71 | if _, ok := os.LookupEnv("CALENVITE_SVC_MAILGUN_KEY"); !ok {
72 | log.Println("Env var CALENVITE_SVC_MAILGUN_KEY not set. Check documentation")
73 | return c.JSON(http.StatusInternalServerError, nil)
74 |
75 | }
76 | } else if value == "SMTP" {
77 | if _, ok := os.LookupEnv("CALENVITE_SVC_SMTP_HOST"); !ok {
78 | log.Println("Env var CALENVITE_SVC_SMTP_HOST not set. Check documentation")
79 | return c.JSON(http.StatusInternalServerError, nil)
80 | }
81 |
82 | if _, ok := os.LookupEnv("CALENVITE_SVC_SMTP_USER"); !ok {
83 | log.Println("Env var CALENVITE_SVC_SMTP_USER not set. Check documentation")
84 | return c.JSON(http.StatusInternalServerError, nil)
85 | }
86 |
87 | if _, ok := os.LookupEnv("CALENVITE_SVC_SMTP_PASSWORD"); !ok {
88 | log.Println("Env var CALENVITE_SVC_SMTP_PASSWORD not set. Check documentation")
89 | return c.JSON(http.StatusInternalServerError, nil)
90 | }
91 |
92 | if _, ok := os.LookupEnv("CALENVITE_SVC_SMTP_PORT"); !ok {
93 | log.Println("Env var CALENVITE_SVC_SMTP_PORT not set. Check documentation")
94 | return c.JSON(http.StatusInternalServerError, nil)
95 | }
96 | }
97 | }
98 |
99 | if _, ok := os.LookupEnv("CALENVITE_SVC_EMAIL_SENDER_ADDRESS"); !ok {
100 | log.Println("Env var CALENVITE_SVC_EMAIL_SENDER_ADDRESS is not set. Check documentation")
101 | return c.JSON(http.StatusInternalServerError, nil)
102 | }
103 |
104 | return c.JSON(http.StatusOK, nil)
105 |
106 | }
107 |
108 | func InviteHandler(c echo.Context) error {
109 |
110 | payload := new(models.RequestPayload)
111 |
112 | if err := c.Bind(payload); err != nil {
113 | return err
114 | }
115 |
116 | // validate payload
117 | validate = validator.New()
118 |
119 | // register function to get tag name from json tags.
120 | validate.RegisterTagNameFunc(func(fld reflect.StructField) string {
121 | name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
122 | if name == "-" {
123 | return ""
124 | }
125 | return name
126 | })
127 |
128 | err := validate.Struct(payload)
129 |
130 | if err != nil {
131 |
132 | var fieldErr = []models.ErrorFields{}
133 |
134 | for _, err := range err.(validator.ValidationErrors) {
135 |
136 | fieldErr = append(fieldErr, models.ErrorFields{
137 | Field: err.Field(),
138 | Message: "",
139 | Code: err.Tag(),
140 | })
141 | }
142 |
143 | var res models.APIResponse
144 | res.Message = "INVALID_PAYLOAD"
145 | res.ErrorFields = fieldErr
146 | res.StatusCode = http.StatusBadRequest
147 |
148 | return c.JSON(http.StatusBadRequest, res)
149 | }
150 |
151 | var emailsAddress []string
152 |
153 | for _, u := range payload.Users {
154 | emailsAddress = append(emailsAddress, u.Email)
155 | }
156 |
157 | var calendarICSFileAttendees string
158 | var calendarICSFileOrganizer string
159 |
160 | // create ics file
161 | if !reflect.ValueOf(payload.Invitation).IsNil() {
162 |
163 | startAt, err := time.Parse(time.RFC3339, payload.Invitation.StartAt)
164 |
165 | if err != nil {
166 | log.Printf("Error parsing StartAt value: %s", payload.Invitation.StartAt)
167 | return c.JSON(http.StatusInternalServerError, API500Error)
168 | }
169 |
170 | endAt, err := time.Parse(time.RFC3339, payload.Invitation.EndAt)
171 |
172 | if err != nil {
173 | log.Printf("Error parsing EndAt value: %s", payload.Invitation.EndAt)
174 | return c.JSON(http.StatusInternalServerError, API500Error)
175 | }
176 |
177 | var attendees = make(map[string]string)
178 |
179 | for _, u := range payload.Users {
180 | attendees[u.Email] = u.FullName
181 | }
182 |
183 | // we need to create two separate ics files
184 | // otherwise the organizator won't be able
185 | // to add the event automatically to his calendar
186 | // this is why we usee ics.MethodRequest for attendees
187 | // and ics.MethodPublish for the organizer
188 | // https://datatracker.ietf.org/doc/html/rfc2446#section-3.2
189 | icsAttendees := createICS(startAt, endAt, payload.Invitation.EventSummary, payload.Invitation.Description, payload.Invitation.Location, payload.Invitation.OrganizerEmail, payload.Invitation.OrganizerFullName, attendees, ics.MethodRequest)
190 |
191 | icsOrganizer := createICS(startAt, endAt, payload.Invitation.EventSummary, payload.Invitation.Description, payload.Invitation.Location, payload.Invitation.OrganizerEmail, payload.Invitation.OrganizerFullName, attendees, ics.MethodPublish)
192 |
193 | icsFileAttendees, err := ioutil.TempFile(os.TempDir(), "*.ics")
194 |
195 | if err != nil {
196 | log.Printf("Failed to write to temporary file: %s", err)
197 | return c.JSON(http.StatusInternalServerError, API500Error)
198 | }
199 |
200 | icsFileOrganizer, err := ioutil.TempFile(os.TempDir(), "*.ics")
201 |
202 | if err != nil {
203 | log.Printf("Failed to write to temporary file: %s", err)
204 | return c.JSON(http.StatusInternalServerError, API500Error)
205 | }
206 |
207 | icsFileAttendees.Write([]byte(icsAttendees))
208 | icsFileOrganizer.Write([]byte(icsOrganizer))
209 |
210 | // Close the file
211 | err = icsFileAttendees.Close()
212 | if err != nil {
213 | log.Printf("Unable to close file %s", err)
214 | return c.JSON(http.StatusInternalServerError, API500Error)
215 | }
216 |
217 | err = icsFileOrganizer.Close()
218 | if err != nil {
219 | log.Printf("Unable to close file %s", err)
220 | return c.JSON(http.StatusInternalServerError, API500Error)
221 | }
222 |
223 | calendarICSFileAttendees = icsFileAttendees.Name()
224 | calendarICSFileOrganizer = icsFileOrganizer.Name()
225 |
226 | defer os.Remove(icsFileAttendees.Name())
227 | defer os.Remove(icsFileOrganizer.Name())
228 | }
229 |
230 | // send emails to users
231 | if settings.SendUsing == "MAILGUN" {
232 | _, err = sendEmailMailgun(payload.EmailSubject, payload.EmailBody, payload.EmailIsHTML, emailsAddress, calendarICSFileAttendees)
233 | } else {
234 | err = sendEmailSMTP(emailsAddress, payload.EmailSubject, payload.EmailBody, payload.EmailIsHTML, calendarICSFileAttendees)
235 | }
236 |
237 | if err != nil {
238 | return c.JSON(http.StatusInternalServerError, API500Error)
239 | }
240 |
241 | // send a separate email to the organizer
242 | // so the event gets created on his calendar
243 | // (Only if an invitation is created)
244 | if calendarICSFileOrganizer != "" {
245 |
246 | var organizer []string
247 | organizer = append(organizer, payload.Invitation.OrganizerEmail)
248 |
249 | // send email to organizer
250 | if settings.SendUsing == "MAILGUN" {
251 | _, err = sendEmailMailgun(payload.EmailSubject, payload.EmailBody, payload.EmailIsHTML, organizer, calendarICSFileOrganizer)
252 | } else {
253 | err = sendEmailSMTP(organizer, payload.EmailSubject, payload.EmailBody, payload.EmailIsHTML, calendarICSFileOrganizer)
254 | }
255 | }
256 |
257 | if err != nil {
258 | return c.JSON(http.StatusInternalServerError, API500Error)
259 | }
260 |
261 | var res models.APIResponse
262 |
263 | res = models.APIResponse{
264 | Message: "SENT_OK",
265 | StatusCode: http.StatusOK,
266 | }
267 |
268 | return c.JSON(res.StatusCode, res)
269 |
270 | }
271 |
272 | func sendEmailMailgun(subject string, body string, isHTML bool, recipients []string, attachment string) (string, error) {
273 |
274 | mg := mailgun.NewMailgun(settings.Mailgun.Domain, settings.Mailgun.SecretKey)
275 |
276 | sender := settings.SenderAddress
277 |
278 | message := mg.NewMessage(sender, subject, body, recipients[0])
279 |
280 | if isHTML {
281 | message.SetHtml(body)
282 | }
283 |
284 | if attachment != "" {
285 | message.AddAttachment(attachment)
286 | }
287 |
288 | for _, e := range recipients {
289 | message.AddCC(e)
290 | }
291 |
292 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
293 | defer cancel()
294 |
295 | // Send the message with a 10 second timeout
296 | _, id, err := mg.Send(ctx, message)
297 |
298 | if err != nil {
299 | log.Println(err)
300 | return "", err
301 | }
302 |
303 | return id, nil
304 | }
305 |
306 | func sendEmailSMTP(recipients []string, subject string, body string, isHTML bool, attachment string) error {
307 |
308 | m := gomail.NewMessage()
309 |
310 | m.SetHeader("From", settings.SenderAddress)
311 | m.SetHeader("Subject", subject)
312 |
313 | for _, email := range recipients {
314 | m.SetHeader("To", email)
315 | }
316 |
317 | if isHTML {
318 | m.SetBody("text/html", body)
319 | } else {
320 | m.SetBody("text/plain", body)
321 | }
322 |
323 | m.Attach(attachment)
324 |
325 | // Settings for SMTP server
326 | port, err := strconv.Atoi(settings.SMTP.Port)
327 |
328 | if err != nil {
329 | fmt.Println(err)
330 | return err
331 | }
332 |
333 | d := gomail.NewDialer(settings.SMTP.Host, port, settings.SMTP.User, settings.SMTP.Password)
334 |
335 | // Now send E-Mail
336 | if err := d.DialAndSend(m); err != nil {
337 | fmt.Println(err)
338 | }
339 |
340 | return err
341 | }
342 |
343 | func createICS(startAt time.Time, endAt time.Time, summary string, description string, location string, organizerEmail string, organizerFullName string, attendees map[string]string, methodRequest ics.Method) string {
344 |
345 | cal := ics.NewCalendar()
346 | cal.SetMethod(methodRequest)
347 | event := cal.AddEvent(fmt.Sprintf("%s%s", uuid.New(), organizerEmail))
348 | event.SetTimeTransparency(ics.TransparencyOpaque)
349 | event.SetCreatedTime(time.Now())
350 | event.SetDtStampTime(time.Now())
351 | event.SetStartAt(startAt)
352 | event.SetEndAt(endAt)
353 | event.SetSummary(fmt.Sprintf("%s", summary))
354 | event.SetDescription(fmt.Sprintf("%s", description))
355 | event.SetLocation(fmt.Sprintf("%s", location))
356 | event.SetOrganizer(organizerEmail, ics.WithCN(organizerFullName))
357 |
358 | for email, fullName := range attendees {
359 | event.AddAttendee(email, ics.WithCN(fullName), ics.CalendarUserTypeIndividual, ics.ParticipationStatusNeedsAction, ics.ParticipationRoleReqParticipant, ics.WithRSVP(true))
360 | }
361 |
362 | return cal.Serialize()
363 | }
364 |
365 | func main() {
366 |
367 | // Echo instance
368 | e := echo.New()
369 | e.Use(middleware.Logger())
370 | e.Use(middleware.Recover())
371 | e.Use(middleware.GzipWithConfig(middleware.GzipConfig{
372 | Level: 5,
373 | }))
374 |
375 | e.GET("/healthcheck", HealthcheckHandler)
376 | e.POST("/invite/", InviteHandler)
377 |
378 | // set default port
379 | if settings.ServicePort == "" {
380 | settings.ServicePort = "8000"
381 | }
382 |
383 | // Start server
384 | go func() {
385 | if err := e.Start(fmt.Sprintf(":%s", settings.ServicePort)); err != nil && err != http.ErrServerClosed {
386 | e.Logger.Fatal("shutting down the server")
387 | }
388 | }()
389 |
390 | // Wait for interrupt signal to gracefully shutdown the server with a timeout of 10 seconds.
391 | // Use a buffered channel to avoid missing signals as recommended for signal.Notify
392 | quit := make(chan os.Signal, 1)
393 | signal.Notify(quit, os.Interrupt)
394 | <-quit
395 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
396 | defer cancel()
397 | if err := e.Shutdown(ctx); err != nil {
398 | e.Logger.Fatal(err)
399 | }
400 |
401 | }
402 |
--------------------------------------------------------------------------------
/main_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "net/http"
5 | "net/http/httptest"
6 | "testing"
7 |
8 | "github.com/labstack/echo/v4"
9 | "github.com/stretchr/testify/assert"
10 | )
11 |
12 | func TestHealthcheckNoEnvVarSet(t *testing.T) {
13 | // Setup
14 | e := echo.New()
15 | req := httptest.NewRequest(http.MethodGet, "/healthcheck", nil)
16 | req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
17 | rec := httptest.NewRecorder()
18 |
19 | e.GET("/healthcheck", HealthcheckHandler)
20 |
21 | e.ServeHTTP(rec, req)
22 |
23 | assert.Equal(t, http.StatusInternalServerError, rec.Code)
24 | }
25 |
26 | func TestHealthcheckSendUsingInvalidValue(t *testing.T) {
27 | // Setup
28 | t.Setenv("CALENVITE_SVC_SEND_USING", "invalid_value")
29 |
30 | e := echo.New()
31 | req := httptest.NewRequest(http.MethodGet, "/healthcheck", nil)
32 | req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
33 | rec := httptest.NewRecorder()
34 |
35 | e.GET("/healthcheck", HealthcheckHandler)
36 |
37 | e.ServeHTTP(rec, req)
38 |
39 | assert.Equal(t, http.StatusInternalServerError, rec.Code)
40 | }
41 |
42 | func TestHealthcheckSendUsingMailgunOK(t *testing.T) {
43 | // Setup
44 | t.Setenv("CALENVITE_SVC_SEND_USING", "MAILGUN")
45 | t.Setenv("CALENVITE_SVC_MAILGUN_KEY", "key")
46 | t.Setenv("CALENVITE_SVC_MAILGUN_DOMAIN", "domain")
47 |
48 | t.Setenv("CALENVITE_SVC_EMAIL_SENDER_ADDRESS", "me@mail.com")
49 |
50 | e := echo.New()
51 | req := httptest.NewRequest(http.MethodGet, "/healthcheck", nil)
52 | req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
53 | rec := httptest.NewRecorder()
54 |
55 | e.GET("/healthcheck", HealthcheckHandler)
56 |
57 | e.ServeHTTP(rec, req)
58 |
59 | assert.Equal(t, http.StatusOK, rec.Code)
60 | }
61 |
62 | func TestHealthcheckSendUsingSMTPOK(t *testing.T) {
63 | // Setup
64 | t.Setenv("CALENVITE_SVC_SEND_USING", "SMTP")
65 | t.Setenv("CALENVITE_SVC_SMTP_HOST", "host")
66 | t.Setenv("CALENVITE_SVC_SMTP_PORT", "123")
67 | t.Setenv("CALENVITE_SVC_SMTP_USERNAME", "user")
68 | t.Setenv("CALENVITE_SVC_SMTP_PASSWORD", "pass")
69 |
70 | t.Setenv("CALENVITE_SVC_EMAIL_SENDER_ADDRESS", "me@mail.com")
71 |
72 | e := echo.New()
73 | req := httptest.NewRequest(http.MethodGet, "/healthcheck", nil)
74 | req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
75 | rec := httptest.NewRecorder()
76 |
77 | e.GET("/healthcheck", HealthcheckHandler)
78 |
79 | e.ServeHTTP(rec, req)
80 |
81 | assert.Equal(t, http.StatusOK, rec.Code)
82 | }
83 |
84 | func TestHealthcheckSendUsingMailgunMissingKeyError(t *testing.T) {
85 | // Setup
86 | t.Setenv("CALENVITE_SVC_SEND_USING", "MAILGUN")
87 | t.Setenv("CALENVITE_SVC_MAILGUN_DOMAIN", "domain")
88 |
89 | t.Setenv("CALENVITE_SVC_EMAIL_SENDER_ADDRESS", "me@mail.com")
90 |
91 | e := echo.New()
92 | req := httptest.NewRequest(http.MethodGet, "/healthcheck", nil)
93 | req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
94 | rec := httptest.NewRecorder()
95 |
96 | e.GET("/healthcheck", HealthcheckHandler)
97 |
98 | e.ServeHTTP(rec, req)
99 |
100 | assert.Equal(t, http.StatusInternalServerError, rec.Code)
101 | }
102 |
103 | func TestHealthcheckSendUsingMailgunMissingDomainError(t *testing.T) {
104 | // Setup
105 | t.Setenv("CALENVITE_SVC_SEND_USING", "MAILGUN")
106 | t.Setenv("CALENVITE_SVC_MAILGUN_KEY", "key")
107 |
108 | t.Setenv("CALENVITE_SVC_EMAIL_SENDER_ADDRESS", "me@mail.com")
109 |
110 | e := echo.New()
111 | req := httptest.NewRequest(http.MethodGet, "/healthcheck", nil)
112 | req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
113 | rec := httptest.NewRecorder()
114 |
115 | e.GET("/healthcheck", HealthcheckHandler)
116 |
117 | e.ServeHTTP(rec, req)
118 |
119 | assert.Equal(t, http.StatusInternalServerError, rec.Code)
120 | }
121 |
122 | func TestHealthcheckSendUsingSMTPMissingHostError(t *testing.T) {
123 | // Setup
124 | t.Setenv("CALENVITE_SVC_SEND_USING", "SMTP")
125 | t.Setenv("CALENVITE_SVC_SMTP_PORT", "123")
126 | t.Setenv("CALENVITE_SVC_SMTP_USERNAME", "user")
127 | t.Setenv("CALENVITE_SVC_SMTP_PASSWORD", "pass")
128 |
129 | t.Setenv("CALENVITE_SVC_EMAIL_SENDER_ADDRESS", "me@mail.com")
130 |
131 | e := echo.New()
132 | req := httptest.NewRequest(http.MethodGet, "/healthcheck", nil)
133 | req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
134 | rec := httptest.NewRecorder()
135 |
136 | e.GET("/healthcheck", HealthcheckHandler)
137 |
138 | e.ServeHTTP(rec, req)
139 |
140 | assert.Equal(t, http.StatusInternalServerError, rec.Code)
141 | }
142 |
143 | func TestHealthcheckSendUsingSMTPMissingUserError(t *testing.T) {
144 | // Setup
145 | t.Setenv("CALENVITE_SVC_SEND_USING", "SMTP")
146 | t.Setenv("CALENVITE_SVC_SMTP_HOST", "host")
147 | t.Setenv("CALENVITE_SVC_SMTP_PORT", "123")
148 | t.Setenv("CALENVITE_SVC_SMTP_PASSWORD", "pass")
149 |
150 | t.Setenv("CALENVITE_SVC_EMAIL_SENDER_ADDRESS", "me@mail.com")
151 |
152 | e := echo.New()
153 | req := httptest.NewRequest(http.MethodGet, "/healthcheck", nil)
154 | req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
155 | rec := httptest.NewRecorder()
156 |
157 | e.GET("/healthcheck", HealthcheckHandler)
158 |
159 | e.ServeHTTP(rec, req)
160 |
161 | assert.Equal(t, http.StatusInternalServerError, rec.Code)
162 | }
163 | func TestHealthcheckSendUsingSMTPMissingPortError(t *testing.T) {
164 | // Setup
165 | t.Setenv("CALENVITE_SVC_SEND_USING", "SMTP")
166 | t.Setenv("CALENVITE_SVC_SMTP_HOST", "host")
167 | t.Setenv("CALENVITE_SVC_SMTP_USERNAME", "user")
168 | t.Setenv("CALENVITE_SVC_SMTP_PASSWORD", "pass")
169 |
170 | t.Setenv("CALENVITE_SVC_EMAIL_SENDER_ADDRESS", "me@mail.com")
171 |
172 | e := echo.New()
173 | req := httptest.NewRequest(http.MethodGet, "/healthcheck", nil)
174 | req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
175 | rec := httptest.NewRecorder()
176 |
177 | e.GET("/healthcheck", HealthcheckHandler)
178 |
179 | e.ServeHTTP(rec, req)
180 |
181 | assert.Equal(t, http.StatusInternalServerError, rec.Code)
182 | }
183 |
184 | func TestHealthcheckSendUsingSMTPMissingPasswordError(t *testing.T) {
185 | // Setup
186 | t.Setenv("CALENVITE_SVC_SEND_USING", "SMTP")
187 | t.Setenv("CALENVITE_SVC_SMTP_HOST", "host")
188 | t.Setenv("CALENVITE_SVC_SMTP_PORT", "123")
189 | t.Setenv("CALENVITE_SVC_SMTP_USERNAME", "user")
190 |
191 | t.Setenv("CALENVITE_SVC_EMAIL_SENDER_ADDRESS", "me@mail.com")
192 |
193 | e := echo.New()
194 | req := httptest.NewRequest(http.MethodGet, "/healthcheck", nil)
195 | req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
196 | rec := httptest.NewRecorder()
197 |
198 | e.GET("/healthcheck", HealthcheckHandler)
199 |
200 | e.ServeHTTP(rec, req)
201 |
202 | assert.Equal(t, http.StatusInternalServerError, rec.Code)
203 | }
204 |
205 | func TestHealthcheckMissingEmailSenderAddressError(t *testing.T) {
206 | // Setup
207 | t.Setenv("CALENVITE_SVC_SEND_USING", "MAILGUN")
208 | t.Setenv("CALENVITE_SVC_MAILGUN_KEY", "key")
209 | t.Setenv("CALENVITE_SVC_MAILGUN_DOMAIN", "domain")
210 |
211 | e := echo.New()
212 | req := httptest.NewRequest(http.MethodGet, "/healthcheck", nil)
213 | req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
214 | rec := httptest.NewRecorder()
215 |
216 | e.GET("/healthcheck", HealthcheckHandler)
217 |
218 | e.ServeHTTP(rec, req)
219 |
220 | assert.Equal(t, http.StatusInternalServerError, rec.Code)
221 | }
222 |
--------------------------------------------------------------------------------
/models/models.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | // Settings ...
4 | type Settings struct {
5 | Mailgun Mailgun
6 | SMTP SMTP
7 | SenderAddress string
8 | SendUsing string
9 | ServicePort string
10 | }
11 |
12 | // Mailgun ...
13 | type Mailgun struct {
14 | Domain string
15 | SecretKey string
16 | }
17 |
18 | // SMTP ...
19 | type SMTP struct {
20 | Host string
21 | Port string
22 | User string
23 | Password string
24 | }
25 |
26 | // APIError ...
27 | type APIError struct {
28 | Message string `json:"message"`
29 | StatusCode int `json:"status_code"`
30 | }
31 |
32 | // ErrorFields ...
33 | type ErrorFields struct {
34 | Field string `json:"field"`
35 | Message string `json:"message"`
36 | Code string `json:"code"`
37 | }
38 |
39 | // APIResponse ...
40 | type APIResponse struct {
41 | Message string `json:"message"`
42 | StatusCode int `json:"status_code"`
43 | ErrorFields []ErrorFields `json:"error_fields"`
44 | }
45 |
46 | // RequestPayload ...
47 | type RequestPayload struct {
48 | Users []*InviteUser `json:"users" validate:"required,dive"`
49 | Invitation *CalendarEvent `json:"invitation" validate:"omitempty"`
50 | EmailSubject string `json:"email_subject" validate:"required"`
51 | EmailBody string `json:"email_body" validate:"required"`
52 | EmailIsHTML bool `json:"email_is_html"`
53 | }
54 |
55 | // InviteUser ..
56 | type InviteUser struct {
57 | FullName string `json:"full_name"`
58 | Email string `json:"email" validate:"required,email"`
59 | }
60 |
61 | // CalendarEvent ...
62 | type CalendarEvent struct {
63 | StartAt string `json:"start_at" validate:"required"`
64 | EndAt string `json:"end_at" validate:"required"`
65 | EventSummary string `json:"summary"`
66 | Description string `json:"description"`
67 | Location string `json:"location"`
68 | OrganizerFullName string `json:"organizer_full_name" validate:"required"`
69 | OrganizerEmail string `json:"organizer_email" validate:"required,email"`
70 | }
71 |
--------------------------------------------------------------------------------
/smtp-docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.9"
2 | services:
3 | app_backend:
4 | image: calenvite_svc:latest
5 | ports:
6 | - "8080:8000"
7 | environment:
8 | - CALENVITE_SVC_SMTP_HOST=
9 | - CALENVITE_SVC_SMTP_PORT=587
10 | - CALENVITE_SVC_SMTP_USER=
11 | - CALENVITE_SVC_SMTP_PASSWORD=
12 | - CALENVITE_SVC_EMAIL_SENDER_ADDRESS=
13 | - CALENVITE_SVC_SEND_USING=SMTP
14 | - CALENVITE_SVC_PORT=8000
--------------------------------------------------------------------------------