├── .github └── FUNDING.yml ├── .gitignore ├── Makefile ├── README.md ├── config-example.lisp ├── sendgrid.asd └── src ├── sendgrid.lisp └── test-sendgrid.lisp /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # github: [vindarel,] 2 | ko_fi: vindarel 3 | liberapay: vindarel 4 | patreon: vindarel 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.fasl 2 | /config-test.lisp 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------