├── .gitignore
├── .github
└── FUNDING.yml
├── Makefile
├── config-example.lisp
├── sendgrid.asd
├── src
├── test-sendgrid.lisp
└── sendgrid.lisp
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | *.fasl
2 | /config-test.lisp
3 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # github: [vindarel,]
2 | ko_fi: vindarel
3 | liberapay: vindarel
4 | patreon: vindarel
5 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | LISP := sbcl
2 |
3 | test:
4 | $(LISP) --disable-debugger \
5 | --load sendgrid.asd \
6 | --eval "(ql:quickload :sendgrid)" \
7 | --load src/test-sendgrid.lisp
8 |
--------------------------------------------------------------------------------
/config-example.lisp:
--------------------------------------------------------------------------------
1 | ;; Example sendgrid config.
2 | (setf *email-config*
3 | '(:|api-key| "SG.xyzSecretxyz"
4 | :|from| "you+test@mail.com" ;; the email registered on Sendgrid.
5 | :|to| "you@mail.com"
6 | :|reply-to| ("you+test@mail.com" "Me (sendgrid)")))
7 |
--------------------------------------------------------------------------------
/sendgrid.asd:
--------------------------------------------------------------------------------
1 | (asdf:defsystem "sendgrid"
2 | :version "0.1"
3 | :author "vindarel"
4 | :license "MIT"
5 | :depends-on (:dexador
6 | :jonathan
7 | :qbase64
8 | :alexandria)
9 | :components ((:module "src"
10 | :components
11 | ((:file "sendgrid"))))
12 |
13 | :description "Wrapper to SendGrid's API."
14 | ;; :long-description
15 | ;; #.(read-file-string
16 | ;; (subpathname *load-pathname* "README.md"))
17 | )
18 |
--------------------------------------------------------------------------------
/src/test-sendgrid.lisp:
--------------------------------------------------------------------------------
1 |
2 | (defparameter *email-config* nil)
3 |
4 | ;; Fill config-test.lisp with your credential data.
5 | (load "config-test.lisp")
6 |
7 | (format t "Sending test email with config: ~%~%~{~a: ~s~^~&~}~&" *email-config*)
8 |
9 | (format t "~%Continue? [enter any letter to continue or C-c]~&")
10 |
11 | (handler-case
12 | (read)
13 | #+sbcl
14 | (sb-sys:interactive-interrupt ()
15 | (format t "Aborting.~&")
16 | (uiop:quit 1)))
17 |
18 | (uiop:format! t "Sending… ")
19 | (sendgrid:send-email :api-key (getf *email-config* :|api-key|)
20 | :to (getf *email-config* :|to|)
21 | :from (getf *email-config* :|from|)
22 | :from-name (getf *email-config* :|from-name|)
23 | :subject "Sending emails from SendGrid is fun!"
24 | :content-type "text/html"
25 | :content "
A title
Sending emails from SendGrid is fun!"
26 | :attachments t
27 | :file "README.md"
28 | :filename "sendgrid-readme.markdown"
29 | )
30 |
31 |
32 | (format t "done.~&")
33 | (uiop:quit)
34 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Send emails with the SendGrid API
2 |
3 | https://sendgrid.com/
4 |
5 | # Installation
6 | On Quicklisp (2020-10):
7 |
8 | (ql:quickload "sendgrid")
9 |
10 | and on [Ultralisp](https://ultralisp.org/).
11 |
12 | # Current usage (Please jump to [Updated usage](#updated-usage) to see upcoming changes)
13 | Create a SendGrid account and set these variables:
14 |
15 | ```lisp
16 | (setf *email-config*
17 | '(:|api-key| "your private api key"
18 | :|from| "the default 'from' address."))
19 | ```
20 |
21 | Send an email with `send-email`:
22 |
23 | ```lisp
24 | (sendgrid:send-email &key to
25 | (from (getf *email-config* :|from|))
26 | (reply-to (getf *email-config* :|reply-to|))
27 | subject
28 | content
29 | (verbose *verbose*))
30 |
31 | ;; #()
32 | ;; 202
33 | ;; #
34 | ;; #
35 | ;; #>
36 | ```
37 |
38 | It takes the time of a POST request.
39 |
40 | # Updated usage
41 | ## API KEY
42 | After obtaining an API key from [SendGrid](https://sendgrid.com/), you can either
43 | ### Provide the API key via an Operating System environment variable
44 | ```lisp
45 | CL-USER> sendgrid:*api-key-environment-variable-name*
46 | "SENDGRID_API_KEY" ; default environment variable name, you can change it.
47 | ```
48 | Then you can do it by OS built-in facility, such as
49 | ```bash
50 | export SENDGRID_API_KEY=your_sendgrid_api_key_value
51 | ```
52 | Or set it using UIOP
53 | ```lisp
54 | CL-USER> (setf (uiop:getenv sendgrid:*api-key-environment-variable-name*) your-api-key-value)
55 | ```
56 | ### Provide the API key when calling `sendgrid:send-mail`
57 | `api-key` is one of the parameter of `sendgrid:send-mail`
58 | ```lisp
59 | (sendgrid:send-email &rest rest
60 | &key
61 | to
62 | from
63 | subject
64 | content
65 | reply-to ; if not nil, should be an alist as (("email" . string) ("name" . string))
66 | (content-type "text/plain")
67 | (api-key (uiop:getenv *api-key-environment-variable-name*))
68 | &allow-other-keys)
69 | ```
70 | ## Sample plain text email
71 | ### Minimum
72 | ```lisp
73 | (sendgrid:send-email :to "recipient@example.com"
74 | :from "noreply@example.com"
75 | :subject "Sending emails from SendGrid is fun!"
76 | :content "Sending emails from SendGrid is fun!")
77 | ```
78 | ### With `reply-to`
79 | ```lisp
80 | (sendgrid:send-email :to "recipient@example.com"
81 | :from "team@example.com"
82 | :reply-to '(("email" . "noreply@example.com") ("name" . "No Reply"))
83 | :subject "Sending emails from SendGrid is fun!"
84 | :content "Sending emails from SendGrid is fun!")
85 | ```
86 | ## Sample HTML email
87 | ```lisp
88 | (sendgrid:send-email :to "recipient@example.com"
89 | :from "noreply@example.com"
90 | :subject "Sending emails from SendGrid is fun!"
91 | :content-type "text/html"
92 | :content "A title
Sending emails from SendGrid is fun!")
93 | ```
94 | ## Verbose mode example
95 | ```
96 | (let ((sendgrid:*verbose* t))
97 | (sendgrid:send-email :to "recipient@example.com"
98 | :from "noreply@example.com"
99 | :subject "Sending emails from SendGrid is fun!"
100 | :content-type "text/html"
101 | :content "A title
Sending emails from SendGrid is fun!"))
102 | ```
103 |
104 | ## Optional - Add sender name
105 |
106 | Without a from name, your sending email will be the name of the email. Use the optional `:from-name` key to add a more appropriate sender name.
107 |
108 | ```lisp
109 | (sendgrid:send-email :to "recipient@example.com"
110 | :from "noreply@example.com"
111 | :from-name "Jane Doe"
112 | :subject "Sending emails from SendGrid is fun!"
113 | :content-type "text/html"
114 | :content "A title
Sending emails from SendGrid is fun!")
115 | ```
116 | ## Email scheduling - :send-at key
117 |
118 | The :send-at key takes a unix epoch time and sends your email at that specific time.
119 |
120 | Free sendgrid accounts (at this time - 4th Oct 23) are limited to sending up to 3 days in the future. Therefore the `:send-at` key is limited to sending emails between 1-3 days in the future.
121 |
122 | ### Send email now
123 |
124 | To send an email now, you can omit the `:send-at` key (see examples above).
125 |
126 | ### Send email in the future
127 |
128 | Use the function `(now-plus-n-days n)` where `n` can be 1, 2, or 3 days in the future. This function will return the epoch time for that future date.
129 |
130 | ```lisp
131 | (sendgrid:send-email :to "recipient@example.com"
132 | :from "noreply@example.com"
133 | :from-name "Jane Doe"
134 | ;; send email 2 days in the future
135 | :send-at (now-plus-n-days 2)
136 | :subject "Sending emails from SendGrid is fun!"
137 | :content-type "text/html"
138 | :content "A title
Sending emails from SendGrid is fun!")
139 | ```
140 |
141 | ## Sending emails with .txt attachments
142 |
143 | You can use the `:attachments` key to send attachments. Set attachments to `t` and then you can use the `:file` and `:filename` keys to provide your attachement.
144 |
145 | `:file` takes the path to the txt file you want to attach.
146 | `:filename` takes a string that represents the file name of the attachment.
147 |
148 | Example:
149 |
150 | ```lisp
151 | (sendgrid:send-email :to "recipient@example.com"
152 | :from "noreply@example.com"
153 | :from-name "Jane Doe"
154 | :send-at (now-plus-n-days 2)
155 | :subject "Sending emails from SendGrid is fun!"
156 | :content-type "text/html"
157 | :content "A title
Sending emails from SendGrid is fun!"
158 | :attachments t
159 | :file "/path/to/txt/file"
160 | :filename "filename.txt")
161 | ```
162 |
163 | ## Sending emails with `pdf` attachments
164 |
165 | Ensure that the `:file` is a path to your pdf file and the `:filename` ends with `.pdf`.
166 |
167 |
168 | Example:
169 |
170 | ```lisp
171 | (sendgrid:send-email :to "recipient@example.com"
172 | :from "noreply@example.com"
173 | :from-name "Jane Doe"
174 | :send-at (now-plus-n-days 2)
175 | :subject "Sending emails from SendGrid is fun!"
176 | :content-type "text/html" ;; this is the content type of the email, not the attachments.
177 | :content "A title
Sending emails from SendGrid is fun!"
178 | :attachments t
179 | :file "/path/to/txt/pdf/file"
180 | :filename "filename.pdf")
181 | ```
182 |
183 |
184 | You will receive an email with the attachment `filename.pdf`. The content of the file will match that of `/path/to/pdf/file`.
185 |
186 |
187 | ## Triggering email automations with add-contact-to-list
188 |
189 | Inside sendgrid, if you navigate to Marketing --> Contacts, you can create a contact list. A contact list is a specific segment of people that did, or will do a specific action. For example, you can have a contact list for new subscribers to your product.
190 |
191 | A list can have automatic actions attached to it. For example, you can trigger an email automation based on somebody subscribing to the list (This is managed in Marketing --> Automations).
192 |
193 | With the `add-contact-to-list` function, you can add a single contact to a specifc contact list by using the list-id.
194 |
195 | Note - you can find the list-id at the end of the contact list url. For example `https://mc.sendgrid.com/contacts/lists/ac32607e-e253-490b-a171-d84d88xxxxxx`, in this case `ac32607e-e253-490b-a171-d84d88xxxxxx` is the list id.
196 |
197 | ```lisp
198 | (add-contact-to-list :email "vinn@kev.com"
199 | :firstname "Vinn"
200 | :list-id "ac32607e-e253-490b-a171-d84d88xxxxxx")
201 | ```
202 |
203 | # See also
204 |
205 | * https://github.com/40ants/mailgun (Mailgun: just a bit more overhead to getting started, a free plan a bit less free)
206 | * https://github.com/CodyReichert/awesome-cl#email
207 | * [spinneret](https://github.com/ruricolist/spinneret) (recommended) or [cl-who](https://edicl.github.io/cl-who/) for generating HTML strings.
208 | # Licence
209 |
210 | MIT.
211 |
--------------------------------------------------------------------------------
/src/sendgrid.lisp:
--------------------------------------------------------------------------------
1 | (defpackage sendgrid
2 | (:use :cl)
3 | (:export #:send-email
4 | #:*api-key-environment-variable-name*
5 | #:*verbose*
6 | #:now-plus-n-days
7 | #:add-contact-to-list))
8 | (in-package :sendgrid)
9 |
10 | ;;; Send an email with SendGrid's API.
11 |
12 | (defparameter *sendgrid-api* "https://api.sendgrid.com/v3/mail/send")
13 |
14 | (defvar *api-key-environment-variable-name*
15 | "SENDGRID_API_KEY")
16 |
17 | (defvar *verbose* nil)
18 |
19 | (defparameter *one-day-in-seconds* 85400 "one day less 1000 seconds")
20 |
21 | (defun now()
22 | "Unix time now"
23 | (local-time:timestamp-to-unix (local-time:now)))
24 |
25 | (defun now-plus-n-days(days)
26 | "Sendgrid api allows you to send emails up to 3 days (72 hours) in the future
27 |
28 | Specify n days in the future where n is either 1, 2, or 3 days.
29 |
30 | A unix time for that date will be returned. If any other number of days is provided, the now function will return."
31 | (if (< 0 days 4)
32 | (+ (now) (* days *one-day-in-seconds*))
33 | (warn "Please pass in 1-3 days. Sendgrid only allows future sending of emails between 1-3 days.")))
34 |
35 | #|
36 | The JSON looks like:
37 | {
38 | "content": [
39 | {
40 | "type": "text/plain",
41 | "value": "and easy to do anywhere, even with cURL"
42 | }
43 | ],
44 | "from": {
45 | "email": "test@example.com"
46 | "name": "Test Name"
47 | },
48 | "reply_to": {
49 | "email": "sam.smith@example.com",
50 | "name": "Sam Smith"
51 | },
52 | "personalizations": [
53 | {
54 | "to": [
55 | {
56 | "email": "test@example.com",
57 | "name": "John Doe"
58 | }
59 | ]
60 | }
61 | ],
62 | "subject": "Sending with SendGrid is Fun"
63 | }
64 | |#
65 |
66 | (defun ensure-list (list)
67 | "If LIST is a list, it is returned. Otherwise returns the list designated by LIST.
68 | (from Alexandria)"
69 | (if (listp list)
70 | list
71 | (list list)))
72 |
73 | (defun sendgrid-json (&key
74 | to
75 | from
76 | from-name
77 | reply-to
78 | subject
79 | send-at
80 | (attachments nil)
81 | file
82 | filename
83 | (content-type "text/plain") ; this duplication is a-must. &rest doesn't pass the default value of caller's keys.
84 | content-value
85 | &allow-other-keys)
86 | "Build the data json.
87 | `to': one email address or a list.
88 | `reply-to': a pair of email and name.
89 | `from': the sending email
90 | `from-name': the sending name that shows up in the inbox"
91 | (if attachments
92 | (assert (and to
93 | from
94 | subject
95 | content-value
96 | file
97 | filename))
98 | (assert (and to
99 | from
100 | subject
101 | content-value)))
102 | (unless (or (null reply-to)
103 | (and (stringp (cdr (assoc "email"
104 | reply-to
105 | :test #'string=)))
106 | (stringp (cdr (assoc "name"
107 | reply-to
108 | :test #'string=)))))
109 | (error "\"reply-to\" must be an alist pair as ((\"email\" . string) (\"name\" . string))"))
110 | (let* ((to (ensure-list to))
111 | (json-alist
112 | (append `(("personalizations"
113 | ,(loop for dest in to
114 | collect `("to" (("email" . ,dest)))))
115 | ,(if (null from-name)
116 | `("from" ("email" . ,from))
117 | `("from" ("email" . ,from)
118 | ("name" . ,from-name))))
119 | (when reply-to
120 | `(,(cons "reply_to" reply-to)))
121 |
122 | (when attachments
123 | `(("attachments" (("content" . ,(create-attachment-base64 file))
124 | ("type" . "text/html")
125 | ("filename" . ,filename)
126 | ("disposition" . "attachment")))))
127 | (when send-at
128 | `(("send_at" . ,send-at)))
129 | `(("subject" . ,subject)
130 | ("content" (("type" . ,content-type)
131 | ("value" . ,content-value)))))))
132 | (jonathan:to-json json-alist :from :alist)))
133 |
134 | ;; test:
135 | #+nil
136 | (progn
137 | ;; Base case:
138 | (assert (string-equal (sendgrid-json :to "to@mail" :from "me@mail" :subject "hello" :content-value "yo" :reply-to '(("email" . "@") ("name" . "me")))
139 | "{\"personalizations\":[{\"to\":[{\"email\":\"to@mail\"}]}],\"from\":{\"email\":\"me@mail\"},\"reply_to\":{\"email\":\"@\",\"name\":\"me\"},\"subject\":\"hello\",\"content\":[{\"type\":\"text/plain\",\"value\":\"yo\"}]}"))
140 |
141 | ;; With two receivers:
142 | (assert (string-equal (sendgrid-json :to '("to@mail" "to-two@mail") :from "me@mail" :subject "hello" :content-value "yo" :reply-to '(("email" . "@") ("name" . "me")))
143 | "{\"personalizations\":[{\"to\":[{\"email\":\"to@mail\"}],\"to\":[{\"email\":\"to-two@mail\"}]}],\"from\":{\"email\":\"me@mail\"},\"reply_to\":{\"email\":\"@\",\"name\":\"me\"},\"subject\":\"hello\",\"content\":[{\"type\":\"text/plain\",\"value\":\"yo\"}]}")))
144 |
145 |
146 | ;; Logic For Sending Attachments
147 |
148 | (defun file-to-seq (file)
149 | "Convert a file into a sequence of bytes. Works with PDF and TXT files"
150 | (alexandria:read-file-into-byte-vector file))
151 |
152 | (defun seq-to-base64 (seqs)
153 | "Convert a sequence of bytes into a base64 string."
154 | (qbase64:encode-bytes seqs))
155 |
156 | (defun create-attachment-base64 (file)
157 | "A function that converts a file into a base64 string. Base64 is the required format for sending attachments in emails."
158 | (let* ((seqs (file-to-seq file))
159 | (base (seq-to-base64 seqs)))
160 | base))
161 |
162 | ;; Main Function
163 |
164 | (defun send-email (&rest rest
165 | &key
166 | to
167 | from
168 | subject
169 | content
170 | reply-to
171 | (content-type "text/plain")
172 | (attachments nil)
173 | file
174 | filename
175 | (api-key (uiop:getenv *api-key-environment-variable-name*))
176 | &allow-other-keys) ; &allow-other-keys can help gradual API updates.
177 | "Send an email with SendGrid's API. https://docs.sendgrid.com/api-reference/mail-send/mail-send#body Currently only supporting basic parameters for 80% use cases.
178 |
179 | Notice that `reply-to', if not nil, should be an alist as ((\"email\" . string) (\"name\" . string))"
180 | (dex:post *sendgrid-api*
181 | :headers `(("Authorization" . ,(concatenate
182 | 'string
183 | "Bearer "
184 | api-key))
185 | ("content-Type" . "application/json"))
186 | :verbose *verbose*
187 | :content (apply #'sendgrid-json
188 | (append `(:content-value ,content)
189 | rest)))) ; The compiler might warn variables defined but never used. But keeping the warnings should be better for future code modification. E.g., suppressing the warnings by (declare (ignorable ...) could result in debugging difficulties once the API changes.
190 |
191 |
192 | ;;===================================================
193 | ;;-- Add a contact
194 | ;;===================================================
195 |
196 | (defparameter *sendgrid-contact-api-url* "https://api.sendgrid.com/v3/marketing/contacts"
197 | "The API URL for adding contacts to marketing lists within Sendgrid.")
198 |
199 | (defun contact-json (&key email firstname list-id)
200 | "The JSON object sent as a POST request for subscribing a single contact to a marketing list."
201 | (assert (and email firstname list-id))
202 | (let* ((firstname-only (first (uiop:split-string firstname :separator ".")))
203 | (json (append `(("list_ids" ,list-id))
204 | `(("contacts" (("email" . ,email)
205 | ("first_name" . ,firstname-only)))))))
206 | (jonathan:to-json json :from :alist)))
207 |
208 | (defun add-contact-to-list (&key
209 | email
210 | firstname
211 | list-id
212 | (api-key (uiop:getenv *api-key-environment-variable-name*)))
213 | "Add a single contact to a sendgrid list.
214 |
215 | To trigger an automation, login to Sendgrid and, in the marketing -> automation section, create an automation that triggers on adding a contact to a list."
216 | (let ((json (contact-json :email email
217 | :firstname firstname
218 | :list-id list-id)))
219 | (dex:put *sendgrid-contact-api-url*
220 | :headers `(("Authorization" . ,(concatenate
221 | 'string
222 | "Bearer "
223 | api-key))
224 | ("content-Type" . "application/json"))
225 | :content json)))
226 |
--------------------------------------------------------------------------------