├── .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 --------------------------------------------------------------------------------