├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── docs ├── 404.html ├── EMail.html ├── EMail │ ├── Client.html │ ├── Client │ │ ├── Config.html │ │ ├── OnFailedProc.html │ │ ├── OnFatalErrorProc.html │ │ └── TLSMode.html │ ├── ConcurrentSender.html │ ├── Error.html │ ├── Error │ │ ├── AddressError.html │ │ ├── ClientConfigError.html │ │ ├── ClientError.html │ │ ├── ContentError.html │ │ ├── HeaderError.html │ │ ├── MessageError.html │ │ └── SenderError.html │ ├── Message.html │ └── Sender.html ├── css │ └── style.css ├── index.html ├── index.json ├── js │ └── doc.js └── search-index.js ├── shard.yml ├── spec ├── email │ ├── address_spec.cr │ └── client_spec.cr ├── email_spec.cr └── spec_helper.cr └── src ├── email.cr └── email ├── address.cr ├── client.cr ├── client └── config.cr ├── concurrent_sender.cr ├── content.cr ├── error.cr ├── header.cr ├── message.cr └── mimetype.cr /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /libs/ 3 | /lib/ 4 | /.crystal/ 5 | /.shards/ 6 | /test/ 7 | 8 | # Libraries don't need dependency lock 9 | # Dependencies will be locked in application that uses them 10 | /shard.lock 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: crystal 2 | 3 | services: 4 | - docker 5 | 6 | before_install: 7 | - docker pull feathj/postfix-blackhole 8 | - docker run -d -it -p 25:25 -p 80:80 feathj/postfix-blackhole 9 | 10 | script: 11 | - crystal spec 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 ʕ·ᴥ·ʔAKJ 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EMail for Crystal 2 | 3 | [![Build Status](https://travis-ci.org/arcage/crystal-email.svg?branch=master)](https://travis-ci.org/arcage/crystal-email) 4 | 5 | Simple email sending library for the [Crystal programming language](https://crystal-lang.org). 6 | 7 | You can: 8 | 9 | - construct an email with a plain text message, a HTML message and/or some attachment files. 10 | - include resources(e.g. images) used in the HTML message. 11 | - set multiple recipients to the email. 12 | - use multibyte characters(only UTF-8) in the email. 13 | - send the email by using local or remote SMTP server. 14 | - use TLS connection by **SMTP orver SSL/TLS**(new) or `STARTTLS` command. 15 | - use SMTP-AUTH by `AUTH PLAIN` or `AUTH LOGIN` when using TLS. 16 | - send multiple emails concurrently by using multiple smtp connections. 17 | 18 | You can not: 19 | 20 | - use ESMTP features except those mentioned above. 21 | 22 | ## Installation 23 | 24 | First, add the dependency to your `shard.yml`: 25 | 26 | ```yaml 27 | dependencies: 28 | email: 29 | github: arcage/crystal-email 30 | ``` 31 | 32 | Then, run `shards install` 33 | 34 | ### Library requirement 35 | 36 | When using STARTTLS or SMTPS, this shard require **libssl** and **libcrypto** for TLS handling. 37 | 38 | You may have to install those libraries to your system. 39 | 40 | ## Usage 41 | 42 | To send a minimal email message: 43 | 44 | **NOTE: Since v0.7.0, EMail::Client::Config object require `helo_domain` argument at initializing.** 45 | 46 | ```crystal 47 | require "email" 48 | 49 | # Create email message 50 | email = EMail::Message.new 51 | email.from "your_addr@example.com" 52 | email.to "to@example.com" 53 | email.subject "Subject of the mail" 54 | email.message <<-EOM 55 | Message body of the mail. 56 | 57 | -- 58 | Your Signature 59 | EOM 60 | 61 | # Set SMTP client configuration 62 | config = EMail::Client::Config.new("your.mx.example.com", 25, helo_domain: "your.host.example.com") 63 | 64 | # Create SMTP client object 65 | client = EMail::Client.new(config) 66 | 67 | client.start do 68 | # In this block, default receiver is client 69 | send(email) 70 | end 71 | ``` 72 | 73 | This code will output log entries to `STDOUT` as follows: 74 | 75 | ```text 76 | 2018/01/25 20:35:09 [e_mail.client/12347] INFO [EMail_Client] Start TCP session to your.mx.example.com:25 77 | 2018/01/25 20:35:10 [e_mail.client/12347] INFO [EMail_Client] Successfully sent a message from to 1 recipient(s) 78 | 2018/01/25 20:35:10 [e_mail.client/12347] INFO [EMail_Client] Close TCP session to your.mx.example.com:25 79 | ``` 80 | 81 | ### Client configs 82 | 83 | You can set some connection settings to `EMail::Client::Config` object. 84 | 85 | That can make SMTP connection to use TLS / SMTP AUTH, or output more detailed log message. 86 | 87 | See [EMail::Client::Config](https://www.denchu.org/crystal-email/EMail/Client/Config.html) for more details. 88 | 89 | ### Email message 90 | 91 | You can set more email headers to `EMail::Message` object. 92 | 93 | And, you can also send emails including attachment files, HTML message, and/or resource files related message body(e.g. image file for HTML message). 94 | 95 | See [EMail::Message](https://www.denchu.org/crystal-email/EMail/Message.html) for more details. 96 | 97 | ### Concurrent sending 98 | 99 | **Note: this feature supports the _concurrent_(not parallel) sending with only one thread.** 100 | 101 | By using `EMail::ConcurrentSender` object, you can concurrently send multiple messages by multiple connections. 102 | 103 | ```crystal 104 | rcpt_list = ["a@example.com", "b@example.com", "c@example.com", "d@example.com"] 105 | 106 | # Set SMTP client configuration 107 | config = EMail::Client::Config.new("your.mx.example.com", 25, helo_domain: "your.host.example.com") 108 | 109 | # Create concurrent sender object 110 | sender = EMail::ConcurrentSender.new(config) 111 | 112 | # Sending emails with concurrently 3 connections. 113 | sender.number_of_connections = 3 114 | 115 | # Sending max 10 emails by 1 connection. 116 | sender.messages_per_connection = 10 117 | 118 | # Start email sending. 119 | sender.start do 120 | # In this block, default receiver is sender 121 | rcpts_list.each do |rcpt_to| 122 | # Create email message 123 | mail = EMail::Message.new 124 | mail.from "your_addr@example.com" 125 | mail.to rcpt_to 126 | mail.subject "Concurrent email sending" 127 | mail.message "message to #{rcpt_to}" 128 | # Enqueue the email to sender 129 | enqueue mail 130 | end 131 | end 132 | ``` 133 | 134 | See [EMail::ConcurrentSender](https://www.denchu.org/crystal-email/EMail/ConcurrentSender.html) for more details. 135 | 136 | ## Logging 137 | 138 | The v0.34.0 of Crystal language has drastic changes in the logging functions. To fit it, the v0.5.0 of this shard also changes the logging behaviour. 139 | 140 | You can use two kinds of logger(`Log` type object), the **default logger** and the **client specific logger**. 141 | 142 | The **default logger** is declered on the `EMail::Client` type. It can be got by `EMail::Client.log`, and change its behavior by `EMail::Client.log_***=` methods. 143 | 144 | On the other hand, the **client specific logger** will be set to `EMail::Client` instance itself by `EMail::Client::Config` setting. With this, you can use your own logger for the `EMail::Client` object. 145 | 146 | If the `EMail::Client` object has the **client specific logger**, the client use it to output the log entries. Otherwise, the client use the **default logger**. 147 | 148 | See [EMail::Client](https://www.denchu.org/crystal-email/EMail/Client.html) and [EMail::Client::Config](https://www.denchu.org/crystal-email/EMail/Client/Config.html) for more details. 149 | 150 | ### Debug log 151 | 152 | When you set the log level to `Log::Severity::Debug`, you can see all of the SMTP commands and the resposes in the log entries. 153 | 154 | ```crystal 155 | EMail::Client.log_level = Log::Severity::Debug 156 | ``` 157 | 158 | Debug log are very useful to check how SMTP session works. 159 | 160 | But, in the case of using SMTP AUTH, the debug log includes Base64 encoded user ID and passowrd. You should remenber that anyone can decode the authentication information from the debug log. And, you should use that **very carefully**. 161 | 162 | ## Owner 163 | 164 | - [arcage](https://github.com/arcage) ʕ·ᴥ·ʔAKJ - creator, maintainer 165 | 166 | ## Contributors 167 | 168 | Thank you for valuable contributions. 169 | 170 | - [Contributors](https://github.com/arcage/crystal-email/graphs/contributors) 171 | -------------------------------------------------------------------------------- /docs/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | email 0.7.0 17 | 20 | 21 | 22 | 23 | 28 | 158 | 159 | 160 |
161 |

162 | 404 Not Found 163 |

164 | 165 |

166 | This page is unavailable in this version of the API docs. 167 |

168 | 169 |

170 | You can use the sidebar to search for your page, or try a different 171 | Crystal version. 172 |

173 | 174 |
175 | 176 | 177 | -------------------------------------------------------------------------------- /docs/EMail.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | EMail - email 0.7.0 17 | 20 | 21 | 22 | 23 | 28 | 158 | 159 | 160 |
161 |

162 | 163 | module EMail 164 | 165 |

166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 |

185 | 186 | 189 | 190 | Defined in: 191 |

192 | 193 | 194 | 195 | email.cr 196 | 197 | 198 |
199 | 200 | 201 | 202 | email/address.cr 203 | 204 | 205 |
206 | 207 | 208 | 209 | email/concurrent_sender.cr 210 | 211 | 212 |
213 | 214 | 215 | 216 | email/mimetype.cr 217 | 218 | 219 |
220 | 221 | 222 | 223 | 224 | 225 |

226 | 227 | 230 | 231 | Constant Summary 232 |

233 | 234 |
235 | 236 |
237 | DEFAULT_SMTP_PORT = 25 238 |
239 | 240 | 241 |
242 | VERSION = "0.6.5" 243 |
244 | 245 | 246 |
247 | 248 | 249 | 250 | 251 | 252 |

253 | 254 | 257 | 258 | Class Method Summary 259 |

260 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 |
285 | 286 |
287 | 288 | 289 | 290 | 291 |

292 | 293 | 296 | 297 | Class Method Detail 298 |

299 | 300 |
301 |
302 | 303 | def self.send(config : EMail::Client::Config, &) 304 | 305 | # 306 |
307 | 308 |
309 | 310 |

Sends one email with given client settings as EMail::Client::Config object.

311 |
config = EMail::Client::Config.new("your.mx.server.name", 587)
312 | config.use_tls
313 | config.use_auth("your_id", "your_password")
314 | 
315 | EMail.send(config) do
316 |   # In this block, default receiver is EMail::Message object
317 |   from "your@mail.addr"
318 |   to "to@some.domain"
319 |   subject "Subject of the mail"
320 | 
321 |   message <<-EOM
322 |     Message body of the mail.
323 | 
324 |     --
325 |     Your Signature
326 |     EOM
327 | end
328 |
329 | 330 |
331 |
332 | 333 | [View source] 334 | 335 |
336 |
337 | 338 |
339 |
340 | 341 | def self.send(*args, **named_args, &) 342 | 343 | # 344 |
345 | 346 |
347 | 348 |

Sends one email with given client settings as several arguments.

349 |

Avairable arguments are same as EMail::Client::Conifg.create method.

350 |
EMail.send("your.mx.server.name", 578,
351 |   use_tle: true,
352 |   auth: {"your_id", "your_password"}) do
353 |   # In this block, default receiver is EMail::Message object
354 |   from "your@mail.addr"
355 |   to "to@some.domain"
356 |   subject "Subject of the mail"
357 | 
358 |   message <<-EOM
359 |     Message body of the mail.
360 | 
361 |     --
362 |     Your Signature
363 |     EOM
364 | end
365 |
366 | 367 |
368 |
369 | 370 | [View source] 371 | 372 |
373 |
374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 |
382 | 383 | 384 | 385 | -------------------------------------------------------------------------------- /docs/EMail/Client/OnFailedProc.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | EMail::Client::OnFailedProc - email 0.7.0 17 | 20 | 21 | 22 | 23 | 28 | 158 | 159 | 160 |
161 |

162 | 163 | alias EMail::Client::OnFailedProc 164 | 165 |

166 | 167 | 168 | 169 | 170 | 171 |

172 | 173 | 176 | 177 | Overview 178 |

179 | 180 |

SMTP error handler.

181 |

Called when the SMTP server returns 4XX or 5XX responce during sending email.

182 | 183 | 184 | 185 |

186 | 187 | 190 | 191 | Alias Definition 192 |

193 | EMail::Message, Array(String) -> Nil 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 |

207 | 208 | 211 | 212 | Defined in: 213 |

214 | 215 | 216 | 217 | email/client/config.cr 218 | 219 | 220 |
221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 |
236 | 237 |
238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 |
248 | 249 | 250 | 251 | -------------------------------------------------------------------------------- /docs/EMail/Client/OnFatalErrorProc.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | EMail::Client::OnFatalErrorProc - email 0.7.0 17 | 20 | 21 | 22 | 23 | 28 | 158 | 159 | 160 |
161 |

162 | 163 | alias EMail::Client::OnFatalErrorProc 164 | 165 |

166 | 167 | 168 | 169 | 170 | 171 |

172 | 173 | 176 | 177 | Overview 178 |

179 | 180 |

Fatal error handler.

181 |

Called when the exception is raised during sending email.

182 | 183 | 184 | 185 |

186 | 187 | 190 | 191 | Alias Definition 192 |

193 | Exception -> Nil 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 |

207 | 208 | 211 | 212 | Defined in: 213 |

214 | 215 | 216 | 217 | email/client/config.cr 218 | 219 | 220 |
221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 |
236 | 237 |
238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 |
248 | 249 | 250 | 251 | -------------------------------------------------------------------------------- /docs/EMail/Client/TLSMode.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | EMail::Client::TLSMode - email 0.7.0 17 | 20 | 21 | 22 | 23 | 28 | 158 | 159 | 160 |
161 |

162 | 163 | enum EMail::Client::TLSMode 164 | 165 |

166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 |

185 | 186 | 189 | 190 | Defined in: 191 |

192 | 193 | 194 | 195 | email/client/config.cr 196 | 197 | 198 |
199 | 200 | 201 | 202 | 203 | 204 |

205 | 206 | 209 | 210 | Enum Members 211 |

212 | 213 |
214 | 215 |
216 | NONE = 0 217 |
218 | 219 | 220 |
221 | STARTTLS = 1 222 |
223 | 224 | 225 |
226 | SMTPS = 2 227 |
228 | 229 | 230 |
231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 |

239 | 240 | 243 | 244 | Instance Method Summary 245 |

246 | 264 | 265 | 266 | 267 | 268 | 269 |
270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 |
312 | 313 | 314 | 315 | 316 | 317 | 318 |

319 | 320 | 323 | 324 | Instance Method Detail 325 |

326 | 327 |
328 |
329 | 330 | def none? 331 | 332 | # 333 |
334 | 335 |
336 |
337 | 338 | [View source] 339 | 340 |
341 |
342 | 343 |
344 |
345 | 346 | def smtps? 347 | 348 | # 349 |
350 | 351 |
352 |
353 | 354 | [View source] 355 | 356 |
357 |
358 | 359 |
360 |
361 | 362 | def starttls? 363 | 364 | # 365 |
366 | 367 |
368 |
369 | 370 | [View source] 371 | 372 |
373 |
374 | 375 | 376 | 377 | 378 | 379 |
380 | 381 | 382 | 383 | -------------------------------------------------------------------------------- /docs/EMail/Error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | EMail::Error - email 0.7.0 17 | 20 | 21 | 22 | 23 | 28 | 158 | 159 | 160 |
161 |

162 | 163 | class EMail::Error 164 | 165 |

166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 |

182 | 183 | 186 | 187 | Direct Known Subclasses 188 |

189 | 206 | 207 | 208 | 209 | 210 | 211 | 212 |

213 | 214 | 217 | 218 | Defined in: 219 |

220 | 221 | 222 | 223 | email/error.cr 224 | 225 | 226 |
227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 |
242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 |
274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 |
284 | 285 | 286 | 287 | -------------------------------------------------------------------------------- /docs/EMail/Error/AddressError.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | EMail::Error::AddressError - email 0.7.0 17 | 20 | 21 | 22 | 23 | 28 | 158 | 159 | 160 |
161 |

162 | 163 | class EMail::Error::AddressError 164 | 165 |

166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 |

187 | 188 | 191 | 192 | Defined in: 193 |

194 | 195 | 196 | 197 | email/error.cr 198 | 199 | 200 |
201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 |
216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 |
258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 |
268 | 269 | 270 | 271 | -------------------------------------------------------------------------------- /docs/EMail/Error/ClientConfigError.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | EMail::Error::ClientConfigError - email 0.7.0 17 | 20 | 21 | 22 | 23 | 28 | 158 | 159 | 160 |
161 |

162 | 163 | class EMail::Error::ClientConfigError 164 | 165 |

166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 |

187 | 188 | 191 | 192 | Defined in: 193 |

194 | 195 | 196 | 197 | email/error.cr 198 | 199 | 200 |
201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 |
216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 |
258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 |
268 | 269 | 270 | 271 | -------------------------------------------------------------------------------- /docs/EMail/Error/ClientError.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | EMail::Error::ClientError - email 0.7.0 17 | 20 | 21 | 22 | 23 | 28 | 158 | 159 | 160 |
161 |

162 | 163 | class EMail::Error::ClientError 164 | 165 |

166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 |

187 | 188 | 191 | 192 | Defined in: 193 |

194 | 195 | 196 | 197 | email/error.cr 198 | 199 | 200 |
201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 |
216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 |
258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 |
268 | 269 | 270 | 271 | -------------------------------------------------------------------------------- /docs/EMail/Error/ContentError.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | EMail::Error::ContentError - email 0.7.0 17 | 20 | 21 | 22 | 23 | 28 | 158 | 159 | 160 |
161 |

162 | 163 | class EMail::Error::ContentError 164 | 165 |

166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 |

187 | 188 | 191 | 192 | Defined in: 193 |

194 | 195 | 196 | 197 | email/error.cr 198 | 199 | 200 |
201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 |
216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 |
258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 |
268 | 269 | 270 | 271 | -------------------------------------------------------------------------------- /docs/EMail/Error/HeaderError.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | EMail::Error::HeaderError - email 0.7.0 17 | 20 | 21 | 22 | 23 | 28 | 158 | 159 | 160 |
161 |

162 | 163 | class EMail::Error::HeaderError 164 | 165 |

166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 |

187 | 188 | 191 | 192 | Defined in: 193 |

194 | 195 | 196 | 197 | email/error.cr 198 | 199 | 200 |
201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 |
216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 |
258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 |
268 | 269 | 270 | 271 | -------------------------------------------------------------------------------- /docs/EMail/Error/MessageError.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | EMail::Error::MessageError - email 0.7.0 17 | 20 | 21 | 22 | 23 | 28 | 158 | 159 | 160 |
161 |

162 | 163 | class EMail::Error::MessageError 164 | 165 |

166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 |

187 | 188 | 191 | 192 | Defined in: 193 |

194 | 195 | 196 | 197 | email/error.cr 198 | 199 | 200 |
201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 |
216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 |
258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 |
268 | 269 | 270 | 271 | -------------------------------------------------------------------------------- /docs/EMail/Error/SenderError.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | EMail::Error::SenderError - email 0.7.0 17 | 20 | 21 | 22 | 23 | 28 | 158 | 159 | 160 |
161 |

162 | 163 | class EMail::Error::SenderError 164 | 165 |

166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 |

187 | 188 | 191 | 192 | Defined in: 193 |

194 | 195 | 196 | 197 | email/error.cr 198 | 199 | 200 |
201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 |
216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 |
258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 |
268 | 269 | 270 | 271 | -------------------------------------------------------------------------------- /docs/EMail/Sender.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | EMail::Sender - email 0.7.0 17 | 20 | 21 | 22 | 23 | 28 | 158 | 159 | 160 |
161 |

162 | 163 | alias EMail::Sender 164 | 165 |

166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 |

174 | 175 | 178 | 179 | Alias Definition 180 |

181 | EMail::ConcurrentSender 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 |

195 | 196 | 199 | 200 | Defined in: 201 |

202 | 203 | 204 | 205 | email/concurrent_sender.cr 206 | 207 | 208 |
209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 |
224 | 225 |
226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 |
236 | 237 | 238 | 239 | -------------------------------------------------------------------------------- /docs/css/style.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | background: #FFFFFF; 3 | position: relative; 4 | margin: 0; 5 | padding: 0; 6 | width: 100%; 7 | height: 100%; 8 | overflow: hidden; 9 | } 10 | 11 | body { 12 | font-family: "Avenir", "Tahoma", "Lucida Sans", "Lucida Grande", Verdana, Arial, sans-serif; 13 | color: #333; 14 | line-height: 1.5; 15 | } 16 | 17 | a { 18 | color: #263F6C; 19 | } 20 | 21 | a:visited { 22 | color: #112750; 23 | } 24 | 25 | h1, h2, h3, h4, h5, h6 { 26 | margin: 35px 0 25px; 27 | color: #444444; 28 | } 29 | 30 | h1.type-name { 31 | color: #47266E; 32 | margin: 20px 0 30px; 33 | background-color: #F8F8F8; 34 | padding: 10px 12px; 35 | border: 1px solid #EBEBEB; 36 | border-radius: 2px; 37 | } 38 | 39 | h2 { 40 | border-bottom: 1px solid #E6E6E6; 41 | padding-bottom: 5px; 42 | } 43 | 44 | body { 45 | display: flex; 46 | } 47 | 48 | .sidebar, .main-content { 49 | overflow: auto; 50 | } 51 | 52 | .sidebar { 53 | width: 30em; 54 | color: #F8F4FD; 55 | background-color: #2E1052; 56 | padding: 0 0 30px; 57 | box-shadow: inset -3px 0 4px rgba(0,0,0,.35); 58 | line-height: 1.2; 59 | z-index: 0; 60 | } 61 | 62 | .sidebar .search-box { 63 | padding: 13px 9px; 64 | } 65 | 66 | .sidebar input { 67 | display: block; 68 | box-sizing: border-box; 69 | margin: 0; 70 | padding: 5px; 71 | font: inherit; 72 | font-family: inherit; 73 | line-height: 1.2; 74 | width: 100%; 75 | border: 0; 76 | outline: 0; 77 | border-radius: 2px; 78 | box-shadow: 0px 3px 5px rgba(0,0,0,.25); 79 | transition: box-shadow .12s; 80 | } 81 | 82 | .sidebar input:focus { 83 | box-shadow: 0px 5px 6px rgba(0,0,0,.5); 84 | } 85 | 86 | .sidebar input::-webkit-input-placeholder { /* Chrome/Opera/Safari */ 87 | color: #757575; 88 | font-size: 14px; 89 | text-indent: 2px; 90 | } 91 | 92 | .sidebar input::-moz-placeholder { /* Firefox 19+ */ 93 | color: #757575; 94 | font-size: 14px; 95 | text-indent: 2px; 96 | } 97 | 98 | .sidebar input:-ms-input-placeholder { /* IE 10+ */ 99 | color: #757575; 100 | font-size: 14px; 101 | text-indent: 2px; 102 | } 103 | 104 | .sidebar input:-moz-placeholder { /* Firefox 18- */ 105 | color: #757575; 106 | font-size: 14px; 107 | text-indent: 2px; 108 | } 109 | 110 | .project-summary { 111 | padding: 9px 15px 30px 30px; 112 | } 113 | 114 | .project-name { 115 | font-size: 1.4rem; 116 | margin: 0; 117 | color: #f4f4f4; 118 | font-weight: 600; 119 | } 120 | 121 | .project-version { 122 | margin-top: 5px; 123 | display: inline-block; 124 | position: relative; 125 | } 126 | 127 | .project-version > form::after { 128 | position: absolute; 129 | right: 0; 130 | top: 0; 131 | content: "\25BC"; 132 | font-size: .6em; 133 | line-height: 1.2rem; 134 | z-index: -1; 135 | } 136 | 137 | .project-versions-nav { 138 | cursor: pointer; 139 | margin: 0; 140 | padding: 0 .9em 0 0; 141 | border: none; 142 | -moz-appearance: none; 143 | -webkit-appearance: none; 144 | appearance: none; 145 | background-color: transparent; 146 | color: inherit; 147 | font-family: inherit; 148 | font-size: inherit; 149 | line-height: inherit; 150 | } 151 | .project-versions-nav:focus { 152 | outline: none; 153 | } 154 | 155 | .project-versions-nav > option { 156 | color: initial; 157 | } 158 | 159 | .sidebar ul { 160 | margin: 0; 161 | padding: 0; 162 | list-style: none outside; 163 | } 164 | 165 | .sidebar li { 166 | display: block; 167 | position: relative; 168 | } 169 | 170 | .types-list li.hide { 171 | display: none; 172 | } 173 | 174 | .sidebar a { 175 | text-decoration: none; 176 | color: inherit; 177 | transition: color .14s; 178 | } 179 | .types-list a { 180 | display: block; 181 | padding: 5px 15px 5px 30px; 182 | } 183 | 184 | .types-list { 185 | display: block; 186 | } 187 | 188 | .sidebar a:focus { 189 | outline: 1px solid #D1B7F1; 190 | } 191 | 192 | .types-list a { 193 | padding: 5px 15px 5px 30px; 194 | } 195 | 196 | .sidebar .current > a, 197 | .sidebar a:hover { 198 | color: #866BA6; 199 | } 200 | 201 | .types-list li ul { 202 | overflow: hidden; 203 | height: 0; 204 | max-height: 0; 205 | transition: 1s ease-in-out; 206 | } 207 | 208 | .types-list li.parent { 209 | padding-left: 30px; 210 | } 211 | 212 | .types-list li.parent::before { 213 | box-sizing: border-box; 214 | content: "▼"; 215 | display: block; 216 | width: 30px; 217 | height: 30px; 218 | position: absolute; 219 | top: 0; 220 | left: 0; 221 | text-align: center; 222 | color: white; 223 | font-size: 8px; 224 | line-height: 30px; 225 | transform: rotateZ(-90deg); 226 | cursor: pointer; 227 | transition: .2s linear; 228 | } 229 | 230 | 231 | .types-list li.parent > a { 232 | padding-left: 0; 233 | } 234 | 235 | .types-list li.parent.open::before { 236 | transform: rotateZ(0); 237 | } 238 | 239 | .types-list li.open > ul { 240 | height: auto; 241 | max-height: 1000em; 242 | } 243 | 244 | .main-content { 245 | padding: 0 30px 30px 30px; 246 | width: 100%; 247 | } 248 | 249 | .kind { 250 | font-size: 60%; 251 | color: #866BA6; 252 | } 253 | 254 | .superclass-hierarchy { 255 | margin: -15px 0 30px 0; 256 | padding: 0; 257 | list-style: none outside; 258 | font-size: 80%; 259 | } 260 | 261 | .superclass-hierarchy .superclass { 262 | display: inline-block; 263 | margin: 0 7px 0 0; 264 | padding: 0; 265 | } 266 | 267 | .superclass-hierarchy .superclass + .superclass::before { 268 | content: "<"; 269 | margin-right: 7px; 270 | } 271 | 272 | .other-types-list li { 273 | display: inline-block; 274 | } 275 | 276 | .other-types-list, 277 | .list-summary { 278 | margin: 0 0 30px 0; 279 | padding: 0; 280 | list-style: none outside; 281 | } 282 | 283 | .entry-const { 284 | font-family: Menlo, Monaco, Consolas, 'Courier New', Courier, monospace; 285 | } 286 | 287 | .entry-const code { 288 | white-space: pre-wrap; 289 | } 290 | 291 | .entry-summary { 292 | padding-bottom: 4px; 293 | } 294 | 295 | .superclass-hierarchy .superclass a, 296 | .other-type a, 297 | .entry-summary .signature { 298 | padding: 4px 8px; 299 | margin-bottom: 4px; 300 | display: inline-block; 301 | background-color: #f8f8f8; 302 | color: #47266E; 303 | border: 1px solid #f0f0f0; 304 | text-decoration: none; 305 | border-radius: 3px; 306 | font-family: Menlo, Monaco, Consolas, 'Courier New', Courier, monospace; 307 | transition: background .15s, border-color .15s; 308 | } 309 | 310 | .superclass-hierarchy .superclass a:hover, 311 | .other-type a:hover, 312 | .entry-summary .signature:hover { 313 | background: #D5CAE3; 314 | border-color: #624288; 315 | } 316 | 317 | .entry-summary .summary { 318 | padding-left: 32px; 319 | } 320 | 321 | .entry-summary .summary p { 322 | margin: 12px 0 16px; 323 | } 324 | 325 | .entry-summary a { 326 | text-decoration: none; 327 | } 328 | 329 | .entry-detail { 330 | padding: 30px 0; 331 | } 332 | 333 | .entry-detail .signature { 334 | position: relative; 335 | padding: 5px 15px; 336 | margin-bottom: 10px; 337 | display: block; 338 | border-radius: 5px; 339 | background-color: #f8f8f8; 340 | color: #47266E; 341 | border: 1px solid #f0f0f0; 342 | font-family: Menlo, Monaco, Consolas, 'Courier New', Courier, monospace; 343 | transition: .2s ease-in-out; 344 | } 345 | 346 | .entry-detail:target .signature { 347 | background-color: #D5CAE3; 348 | border: 1px solid #624288; 349 | } 350 | 351 | .entry-detail .signature .method-permalink { 352 | position: absolute; 353 | top: 0; 354 | left: -35px; 355 | padding: 5px 15px; 356 | text-decoration: none; 357 | font-weight: bold; 358 | color: #624288; 359 | opacity: .4; 360 | transition: opacity .2s; 361 | } 362 | 363 | .entry-detail .signature .method-permalink:hover { 364 | opacity: 1; 365 | } 366 | 367 | .entry-detail:target .signature .method-permalink { 368 | opacity: 1; 369 | } 370 | 371 | .methods-inherited { 372 | padding-right: 10%; 373 | line-height: 1.5em; 374 | } 375 | 376 | .methods-inherited h3 { 377 | margin-bottom: 4px; 378 | } 379 | 380 | .methods-inherited a { 381 | display: inline-block; 382 | text-decoration: none; 383 | color: #47266E; 384 | } 385 | 386 | .methods-inherited a:hover { 387 | text-decoration: underline; 388 | color: #6C518B; 389 | } 390 | 391 | .methods-inherited .tooltip>span { 392 | background: #D5CAE3; 393 | padding: 4px 8px; 394 | border-radius: 3px; 395 | margin: -4px -8px; 396 | } 397 | 398 | .methods-inherited .tooltip * { 399 | color: #47266E; 400 | } 401 | 402 | pre { 403 | padding: 10px 20px; 404 | margin-top: 4px; 405 | border-radius: 3px; 406 | line-height: 1.45; 407 | overflow: auto; 408 | color: #333; 409 | background: #fdfdfd; 410 | font-size: 14px; 411 | border: 1px solid #eee; 412 | } 413 | 414 | code { 415 | font-family: Menlo, Monaco, Consolas, 'Courier New', Courier, monospace; 416 | } 417 | 418 | :not(pre) > code { 419 | background-color: rgba(40,35,30,0.05); 420 | padding: 0.2em 0.4em; 421 | font-size: 85%; 422 | border-radius: 3px; 423 | } 424 | 425 | span.flag { 426 | padding: 2px 4px 1px; 427 | border-radius: 3px; 428 | margin-right: 3px; 429 | font-size: 11px; 430 | border: 1px solid transparent; 431 | } 432 | 433 | span.flag.orange { 434 | background-color: #EE8737; 435 | color: #FCEBDD; 436 | border-color: #EB7317; 437 | } 438 | 439 | span.flag.yellow { 440 | background-color: #E4B91C; 441 | color: #FCF8E8; 442 | border-color: #B69115; 443 | } 444 | 445 | span.flag.green { 446 | background-color: #469C14; 447 | color: #E2F9D3; 448 | border-color: #34700E; 449 | } 450 | 451 | span.flag.red { 452 | background-color: #BF1919; 453 | color: #F9ECEC; 454 | border-color: #822C2C; 455 | } 456 | 457 | span.flag.purple { 458 | background-color: #2E1052; 459 | color: #ECE1F9; 460 | border-color: #1F0B37; 461 | } 462 | 463 | span.flag.lime { 464 | background-color: #a3ff00; 465 | color: #222222; 466 | border-color: #00ff1e; 467 | } 468 | 469 | .tooltip>span { 470 | position: absolute; 471 | opacity: 0; 472 | display: none; 473 | pointer-events: none; 474 | } 475 | 476 | .tooltip:hover>span { 477 | display: inline-block; 478 | opacity: 1; 479 | } 480 | 481 | .c { 482 | color: #969896; 483 | } 484 | 485 | .n { 486 | color: #0086b3; 487 | } 488 | 489 | .t { 490 | color: #0086b3; 491 | } 492 | 493 | .s { 494 | color: #183691; 495 | } 496 | 497 | .i { 498 | color: #7f5030; 499 | } 500 | 501 | .k { 502 | color: #a71d5d; 503 | } 504 | 505 | .o { 506 | color: #a71d5d; 507 | } 508 | 509 | .m { 510 | color: #795da3; 511 | } 512 | 513 | .hidden { 514 | display: none; 515 | } 516 | .search-results { 517 | font-size: 90%; 518 | line-height: 1.3; 519 | } 520 | 521 | .search-results mark { 522 | color: inherit; 523 | background: transparent; 524 | font-weight: bold; 525 | } 526 | .search-result { 527 | padding: 5px 8px 5px 5px; 528 | cursor: pointer; 529 | border-left: 5px solid transparent; 530 | transform: translateX(-3px); 531 | transition: all .2s, background-color 0s, border .02s; 532 | min-height: 3.2em; 533 | } 534 | .search-result.current { 535 | border-left-color: #ddd; 536 | background-color: rgba(200,200,200,0.4); 537 | transform: translateX(0); 538 | transition: all .2s, background-color .5s, border 0s; 539 | } 540 | .search-result.current:hover, 541 | .search-result.current:focus { 542 | border-left-color: #866BA6; 543 | } 544 | .search-result:not(.current):nth-child(2n) { 545 | background-color: rgba(255,255,255,.06); 546 | } 547 | .search-result__title { 548 | font-size: 105%; 549 | word-break: break-all; 550 | line-height: 1.1; 551 | padding: 3px 0; 552 | } 553 | .search-result__title strong { 554 | font-weight: normal; 555 | } 556 | .search-results .search-result__title > a { 557 | padding: 0; 558 | display: block; 559 | } 560 | .search-result__title > a > .args { 561 | color: #dddddd; 562 | font-weight: 300; 563 | transition: inherit; 564 | font-size: 88%; 565 | line-height: 1.2; 566 | letter-spacing: -.02em; 567 | } 568 | .search-result__title > a > .args * { 569 | color: inherit; 570 | } 571 | 572 | .search-result a, 573 | .search-result a:hover { 574 | color: inherit; 575 | } 576 | .search-result:not(.current):hover .search-result__title > a, 577 | .search-result:not(.current):focus .search-result__title > a, 578 | .search-result__title > a:focus { 579 | color: #866BA6; 580 | } 581 | .search-result:not(.current):hover .args, 582 | .search-result:not(.current):focus .args { 583 | color: #6a5a7d; 584 | } 585 | 586 | .search-result__type { 587 | color: #e8e8e8; 588 | font-weight: 300; 589 | } 590 | .search-result__doc { 591 | color: #bbbbbb; 592 | font-size: 90%; 593 | } 594 | .search-result__doc p { 595 | margin: 0; 596 | text-overflow: ellipsis; 597 | display: -webkit-box; 598 | -webkit-box-orient: vertical; 599 | -webkit-line-clamp: 2; 600 | overflow: hidden; 601 | line-height: 1.2em; 602 | max-height: 2.4em; 603 | } 604 | 605 | .js-modal-visible .modal-background { 606 | display: flex; 607 | } 608 | .main-content { 609 | position: relative; 610 | } 611 | .modal-background { 612 | position: absolute; 613 | display: none; 614 | height: 100%; 615 | width: 100%; 616 | background: rgba(120,120,120,.4); 617 | z-index: 100; 618 | align-items: center; 619 | justify-content: center; 620 | } 621 | .usage-modal { 622 | max-width: 90%; 623 | background: #fff; 624 | border: 2px solid #ccc; 625 | border-radius: 9px; 626 | padding: 5px 15px 20px; 627 | min-width: 50%; 628 | color: #555; 629 | position: relative; 630 | transform: scale(.5); 631 | transition: transform 200ms; 632 | } 633 | .js-modal-visible .usage-modal { 634 | transform: scale(1); 635 | } 636 | .usage-modal > .close-button { 637 | position: absolute; 638 | right: 15px; 639 | top: 8px; 640 | color: #aaa; 641 | font-size: 27px; 642 | cursor: pointer; 643 | } 644 | .usage-modal > .close-button:hover { 645 | text-shadow: 2px 2px 2px #ccc; 646 | color: #999; 647 | } 648 | .modal-title { 649 | margin: 0; 650 | text-align: center; 651 | font-weight: normal; 652 | color: #666; 653 | border-bottom: 2px solid #ddd; 654 | padding: 10px; 655 | } 656 | .usage-list { 657 | padding: 0; 658 | margin: 13px; 659 | } 660 | .usage-list > li { 661 | padding: 5px 2px; 662 | overflow: auto; 663 | padding-left: 100px; 664 | min-width: 12em; 665 | } 666 | .usage-modal kbd { 667 | background: #eee; 668 | border: 1px solid #ccc; 669 | border-bottom-width: 2px; 670 | border-radius: 3px; 671 | padding: 3px 8px; 672 | font-family: monospace; 673 | margin-right: 2px; 674 | display: inline-block; 675 | } 676 | .usage-key { 677 | float: left; 678 | clear: left; 679 | margin-left: -100px; 680 | margin-right: 12px; 681 | } 682 | .doc-inherited { 683 | font-weight: bold; 684 | } 685 | 686 | .anchor { 687 | float: left; 688 | padding-right: 4px; 689 | margin-left: -20px; 690 | } 691 | 692 | .main-content .anchor .octicon-link { 693 | width: 16px; 694 | height: 16px; 695 | } 696 | 697 | .main-content .anchor:focus { 698 | outline: none 699 | } 700 | 701 | .main-content h1:hover .anchor, 702 | .main-content h2:hover .anchor, 703 | .main-content h3:hover .anchor, 704 | .main-content h4:hover .anchor, 705 | .main-content h5:hover .anchor, 706 | .main-content h6:hover .anchor { 707 | text-decoration: none 708 | } 709 | 710 | .main-content h1 .octicon-link, 711 | .main-content h2 .octicon-link, 712 | .main-content h3 .octicon-link, 713 | .main-content h4 .octicon-link, 714 | .main-content h5 .octicon-link, 715 | .main-content h6 .octicon-link { 716 | visibility: hidden 717 | } 718 | 719 | .main-content h1:hover .anchor .octicon-link, 720 | .main-content h2:hover .anchor .octicon-link, 721 | .main-content h3:hover .anchor .octicon-link, 722 | .main-content h4:hover .anchor .octicon-link, 723 | .main-content h5:hover .anchor .octicon-link, 724 | .main-content h6:hover .anchor .octicon-link { 725 | visibility: visible 726 | } 727 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | email 0.7.0 17 | 20 | 21 | 22 | 23 | 28 | 158 | 159 | 160 |
161 |

164 | EMail for Crystal

165 |

Build Status

166 |

Simple email sending library for the Crystal programming language.

167 |

You can:

168 | 178 |

You can not:

179 | 182 |

183 | 186 | Installation

187 |

First, add the dependency to your shard.yml:

188 |
dependencies:
189 |   email:
190 |     github: arcage/crystal-email
191 |

Then, run shards install

192 |

193 | 196 | Library requirement

197 |

When using STARTTLS or SMTPS, this shard require libssl and libcrypto for TLS handling.

198 |

You may have to install those libraries to your system.

199 |

200 | 203 | Usage

204 |

To send a minimal email message:

205 |
require "email"
206 | 
207 | # Create email message
208 | email = EMail::Message.new
209 | email.from    "your_addr@example.com"
210 | email.to      "to@example.com"
211 | email.subject "Subject of the mail"
212 | email.message <<-EOM
213 |   Message body of the mail.
214 | 
215 |   --
216 |   Your Signature
217 |   EOM
218 | 
219 | # Set SMTP client configuration
220 | config = EMail::Client::Config.new("your.mx.example.com", 25)
221 | 
222 | # Create SMTP client object
223 | client = EMail::Client.new(config)
224 | 
225 | client.start do
226 |   # In this block, default receiver is client
227 |   send(email)
228 | end
229 |

This code will output log entries to STDOUT as follows:

230 |
2018/01/25 20:35:09 [e_mail.client/12347] INFO [EMail_Client] Start TCP session to your.mx.example.com:25
231 | 2018/01/25 20:35:10 [e_mail.client/12347] INFO [EMail_Client] Successfully sent a message from <your_addr@example.com> to 1 recipient(s)
232 | 2018/01/25 20:35:10 [e_mail.client/12347] INFO [EMail_Client] Close TCP session to your.mx.example.com:25
233 |

234 | 237 | Client configs

238 |

You can set some connection settings to EMail::Client::Config object.

239 |

That can make SMTP connection to use TLS / SMTP AUTH, or output more detailed log message.

240 |

See EMail::Client::Config for more details.

241 |

242 | 245 | Email message

246 |

You can set more email headers to EMail::Message object.

247 |

And, you can also send emails including attachment files, HTML message, and/or resource files related message body(e.g. image file for HTML message).

248 |

See EMail::Message for more details.

249 |

250 | 253 | Concurrent sending

254 |

Note: this feature supports the concurrent(not parallel) sending with only one thread.

255 |

By using EMail::ConcurrentSender object, you can concurrently send multiple messages by multiple connections.

256 |
rcpt_list = ["a@example.com", "b@example.com", "c@example.com", "d@example.com"]
257 | 
258 | # Set SMTP client configuration
259 | config = EMail::Client::Config.new("your.mx.example.com", 25)
260 | 
261 | # Create concurrent sender object
262 | sender = EMail::ConcurrentSender.new(config)
263 | 
264 | # Sending emails with concurrently 3 connections.
265 | sender.number_of_connections = 3
266 | 
267 | # Sending max 10 emails by 1 connection.
268 | sender.messages_per_connection = 10
269 | 
270 | # Start email sending.
271 | sender.start do
272 |   # In this block, default receiver is sender
273 |   rcpts_list.each do |rcpt_to|
274 |     # Create email message
275 |     mail = EMail::Message.new
276 |     mail.from "your_addr@example.com"
277 |     mail.to rcpt_to
278 |     mail.subject "Concurrent email sending"
279 |     mail.message "message to #{rcpt_to}"
280 |     # Enqueue the email to sender
281 |     enqueue mail
282 |   end
283 | end
284 |

See EMail::ConcurrentSender for more details.

285 |

286 | 289 | Logging

290 |

The v0.34.0 of Crystal language has drastic changes in the logging functions. To fit it, the v0.5.0 of this shard also changes the logging behaviour.

291 |

You can use two kinds of logger(Log type object), the default logger and the client specific logger.

292 |

The default logger is declered on the EMail::Client type. It can be got by EMail::Client.log, and change its behavior by EMail::Client.log_***= methods.

293 |

On the other hand, the client specific logger will be set to EMail::Client instance itself by EMail::Client::Config setting. With this, you can use your own logger for the EMail::Client object.

294 |

If the EMail::Client object has the client specific logger, the client use it to output the log entries. Otherwise, the client use the default logger.

295 |

See EMail::Client and EMail::Client::Config for more details.

296 |

297 | 300 | Debug log

301 |

When you set the log level to Log::Severity::Debug, you can see all of the SMTP commands and the resposes in the log entries.

302 |
EMail::Client.log_level = Log::Severity::Debug
303 |

Debug log are very useful to check how SMTP session works.

304 |

But, in the case of using SMTP AUTH, the debug log includes Base64 encoded user ID and passowrd. You should remenber that anyone can decode the authentication information from the debug log. And, you should use that very carefully.

305 |

306 | 309 | Owner

310 | 313 |

314 | 317 | Contributors

318 |

Thank you for valuable contributions.

319 | 322 |
323 | 324 | 325 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: email 2 | version: 0.7.1 3 | 4 | authors: 5 | - ʕ·ᴥ·ʔAKJ 6 | 7 | crystal: ">= 1.2.0, < 2.0.0" 8 | 9 | license: MIT 10 | -------------------------------------------------------------------------------- /spec/email/address_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe EMail::Address do 4 | describe ".valid_address!" do 5 | it "returns argument when it seems to be a valid email address" do 6 | EMail::Address.valid_address!("aa@bb.cc").should eq "aa@bb.cc" 7 | end 8 | 9 | it "accepts domain part without \".\"" do 10 | EMail::Address.valid_address!("aa@localhost").should eq "aa@localhost" 11 | end 12 | 13 | it "raises Email::Error::AddressError when argument seems to be invalid as a email address" do 14 | expect_raises(EMail::Error::AddressError) { 15 | EMail::Address.valid_address!("aa@bb,cc") 16 | } 17 | end 18 | end 19 | 20 | describe ".valid_name!" do 21 | it "returns argument when it inclued no line breaks" do 22 | EMail::Address.valid_name!("John Doe").should eq "John Doe" 23 | end 24 | 25 | it "raises Email::Error::AddressError when argument includes line break" do 26 | expect_raises(EMail::Error::AddressError) { 27 | EMail::Address.valid_name!("John\nDoe") 28 | } 29 | end 30 | end 31 | 32 | describe ".new" do 33 | it "rejects provably invalid email address" do 34 | expect_raises(EMail::Error::AddressError) { 35 | EMail::Address.new("aa@bb,cc") 36 | } 37 | end 38 | 39 | it "rejects sender name that includes line break" do 40 | expect_raises(EMail::Error::AddressError) { 41 | EMail::Address.new("aa@bb.cc", "John\nDoe") 42 | } 43 | end 44 | end 45 | 46 | describe "#to_s" do 47 | it "returns only address string when without sender name" do 48 | EMail::Address.new("aa@bb.cc").to_s.should eq "aa@bb.cc" 49 | end 50 | 51 | it "returns sender name and angled address string when sender name exists" do 52 | EMail::Address.new("aa@bb.cc", "John Doe").to_s.should eq "John Doe " 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /spec/email/client_spec.cr: -------------------------------------------------------------------------------- 1 | describe EMail::Client do 2 | email = EMail::Message.new 3 | email.from "from@example.com" 4 | email.to "to@example.com" 5 | email.subject "Subject" 6 | email.message "Message" 7 | 8 | describe "#send" do 9 | it "try to send an email to SMTP server, but recipient refused." do 10 | log = String.build do |io| 11 | EMail::Client.log_io = io 12 | config = EMail::Client::Config.new("localhost", 25, helo_domain: "example.com") 13 | client = EMail::Client.new(config) 14 | client.start do 15 | send(email).should be_false 16 | end 17 | end 18 | log.should match(/ RCPT 454 /) 19 | end 20 | 21 | it "send an email with SMTP auth." do 22 | log = String.build do |io| 23 | EMail::Client.log_io = io 24 | config = EMail::Client::Config.new("localhost", 25, helo_domain: "example.com") 25 | config.use_tls(:starttls) 26 | config.tls_context.verify_mode = OpenSSL::SSL::VerifyMode::NONE 27 | config.use_auth("from@example.com", "password") 28 | client = EMail::Client.new(config) 29 | client.start do 30 | send(email).should be_true 31 | end 32 | end 33 | log.should match(/ Successfully sent /) 34 | end 35 | 36 | it "try to send an email with invalid password, but authentication refused." do 37 | log = String.build do |io| 38 | EMail::Client.log_io = io 39 | config = EMail::Client::Config.new("localhost", 25, helo_domain: "example.com") 40 | config.use_tls(:starttls) 41 | config.tls_context.verify_mode = OpenSSL::SSL::VerifyMode::NONE 42 | config.use_auth("from@example.com", "invalid") 43 | client = EMail::Client.new(config) 44 | client.start do 45 | send(email).should be_false 46 | end 47 | end 48 | log.should match(/ AUTH 535 /) 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/email_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | require "./email/*" 4 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/email" 3 | -------------------------------------------------------------------------------- /src/email.cr: -------------------------------------------------------------------------------- 1 | require "base64" 2 | require "socket" 3 | {% if !flag?(:without_openssl) %} 4 | require "openssl" 5 | {% end %} 6 | require "uri" 7 | require "./email/*" 8 | 9 | module EMail 10 | VERSION = "0.7.1" 11 | DEFAULT_SMTP_PORT = 25 12 | 13 | # :nodoc: 14 | DOMAIN_FORMAT = /\A[a-zA-Z0-9\!\#\$\%\&\'\*\+\-\/\=\?\^\_\`^{\|\}\~]+(\.[a-zA-Z0-9\!\#\$\%\&\'\*\+\-\/\=\?\^\_\`^{\|\}\~]+)+\z/ 15 | 16 | # Sends one email with given client settings as EMail::Client::Config object. 17 | # 18 | # ``` 19 | # config = EMail::Client::Config.new("your.mx.server.name", 587) 20 | # config.use_tls 21 | # config.use_auth("your_id", "your_password") 22 | # 23 | # EMail.send(config) do 24 | # # In this block, default receiver is EMail::Message object 25 | # from "your@mail.addr" 26 | # to "to@some.domain" 27 | # subject "Subject of the mail" 28 | # 29 | # message <<-EOM 30 | # Message body of the mail. 31 | # 32 | # -- 33 | # Your Signature 34 | # EOM 35 | # end 36 | # ``` 37 | def self.send(config : EMail::Client::Config) 38 | message = Message.new 39 | with message yield 40 | EMail::Client.new(config).start do 41 | send(message) 42 | end 43 | end 44 | 45 | # Sends one email with given client settings as several arguments. 46 | # 47 | # Avairable arguments are same as `EMail::Client::Conifg.create` method. 48 | # ``` 49 | # EMail.send("your.mx.server.name", 578, 50 | # use_tle: true, 51 | # auth: {"your_id", "your_password"}) do 52 | # # In this block, default receiver is EMail::Message object 53 | # from "your@mail.addr" 54 | # to "to@some.domain" 55 | # subject "Subject of the mail" 56 | # 57 | # message <<-EOM 58 | # Message body of the mail. 59 | # 60 | # -- 61 | # Your Signature 62 | # EOM 63 | # end 64 | # ``` 65 | def self.send(*args, **named_args) 66 | config = EMail::Client::Config.create(*args, **named_args) 67 | message = Message.new 68 | with message yield 69 | EMail::Client.new(config).start do 70 | send(message) 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /src/email/address.cr: -------------------------------------------------------------------------------- 1 | # :nodoc: 2 | class EMail::Address 3 | # email address 4 | getter addr 5 | # mailbox name 6 | getter name 7 | 8 | # :nodoc: 9 | ADDRESS_FORMAT = /\A[a-zA-Z0-9\!\#\$\%\&\'\*\+\-\/\=\?\^\_\`\^\{\|\}\~]+(\.[a-zA-Z0-9\!\#\$\%\&\'\*\+\-\/\=\?\^\_\`\^\{\|\}\~]+)*@[a-zA-Z0-9\!\#\$\%\&\'\*\+\-\/\=\?\^\_\`\^\{\|\}\~]+(\.[a-zA-Z0-9\!\#\$\%\&\'\*\+\-\/\=\?\^\_\`\^\{\|\}\~]+)*\z/ 10 | 11 | # :nodoc: 12 | NAME_FORMAT = /\A[a-zA-Z0-9\!\#\$\%\&\'\*\+\-\/\=\?\^\_\`\{\|\}\~ \t]+\z/ 13 | 14 | # raise `EMail::Error::AddressError` when the given email address is invalid. 15 | def self.valid_address!(mail_address : String) 16 | raise EMail::Error::AddressError.new("#{mail_address.inspect} is invalid as a mail address.") unless mail_address =~ ADDRESS_FORMAT 17 | mail_address 18 | end 19 | 20 | # raise `EMail::Error::AddressError` when the given mailbox name is invalid. 21 | def self.valid_name!(mailbox_name : String?) 22 | if mailbox_name 23 | raise EMail::Error::AddressError.new("#{mailbox_name.inspect} is invalid as a sender name") if mailbox_name =~ /[\r\n]/ 24 | end 25 | mailbox_name 26 | end 27 | 28 | @addr : String 29 | @name : String? = nil 30 | 31 | def initialize(mail_address : String, mailbox_name : String? = nil) 32 | @addr = Address.valid_address!(mail_address) 33 | @name = Address.valid_name!(mailbox_name) 34 | end 35 | 36 | def to_s(io : IO) 37 | if mailbox_name = @name 38 | io << (mailbox_name =~ Header::FIELD_BODY ? mailbox_name : Header::ENCODE_DEFINITION_HEAD + Base64.strict_encode(mailbox_name.to_slice) + Header::ENCODE_DEFINITION_TAIL) 39 | io << " <" << @addr << '>' 40 | else 41 | io << @addr 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /src/email/client.cr: -------------------------------------------------------------------------------- 1 | require "./client/*" 2 | require "log" 3 | 4 | # SMTP client object. 5 | # 6 | # ### Client configuration 7 | # 8 | # EMail::Client::Config object is used to set client configrations. 9 | # 10 | # ### Logging 11 | # 12 | # Without client specific logger in EMail::Client::Config, all EMail::Client objects use the default logger. 13 | # 14 | # The default logger can be got by EMail::Client.log 15 | # You can set log level, output IO, and log formatter for the default logger by using EMail::Client.log_level=, EMail::Client.log_io=, EMail::Client.log_formatter= methods respectively. 16 | # 17 | class EMail::Client 18 | # :nodoc: 19 | DEFAULT_NAME = "EMail_Client" 20 | 21 | # :nodoc: 22 | LOG_FORMATTER = Log::Formatter.new do |entry, io| 23 | io << entry.timestamp.to_s("%Y/%m/%d %T") << " [" << entry.source << "/" << Process.pid << "] " 24 | io << entry.severity << " " << entry.message 25 | if entry.context.size > 0 26 | io << " -- " << entry.context 27 | end 28 | if ex = entry.exception 29 | io << " -- " << ex.class << ": " << ex 30 | end 31 | end 32 | 33 | @@log : Log = create_logger 34 | 35 | # Gets default logger([Log](https://crystal-lang.org/api/OpenSSL/Log.html) type object) to output SMTP log. 36 | def self.log : Log 37 | @@log 38 | end 39 | 40 | # Sets log level for default logger. 41 | def self.log_level=(new_level : Log::Severity) 42 | @@log.level = new_level 43 | nil 44 | end 45 | 46 | # Sets log io for default logger. 47 | def self.log_io=(new_io : IO) 48 | if (log_backend = @@log.backend).is_a?(Log::IOBackend) 49 | log_backend.io = new_io 50 | end 51 | nil 52 | end 53 | 54 | # Sets log formatter for default logger. 55 | def self.log_formatter=(new_formatter : Log::Formatter) 56 | if (log_backend = @@log.backend).is_a?(Log::IOBackend) 57 | log_backend.formatter = new_formatter 58 | end 59 | nil 60 | end 61 | 62 | # :nodoc: 63 | private def self.create_logger : Log 64 | log = Log.for(self, Log::Severity::Info) 65 | log_backend = Log::IOBackend.new(STDOUT) 66 | log_backend.formatter = LOG_FORMATTER 67 | log.backend = log_backend 68 | log 69 | end 70 | 71 | @helo_domain : String? 72 | @started : Bool = false 73 | @first_send : Bool = true 74 | @tcp_socket : TCPSocket? = nil 75 | {% if flag?(:without_openssl) %} 76 | @socket : TCPSocket? = nil 77 | {% else %} 78 | @socket : TCPSocket | OpenSSL::SSL::Socket::Client | Nil = nil 79 | {% end %} 80 | @command_history = [] of String 81 | @esmtp_commands = Hash(String, Array(String)).new { |h, k| h[k] = Array(String).new } 82 | # :nodoc: 83 | property number : Int32? 84 | 85 | # Gets cliet config object. 86 | getter config : EMail::Client::Config 87 | 88 | # Creates smtp client object by EMail::Client::Config object. 89 | def initialize(@config : EMail::Client::Config, @number = nil) 90 | end 91 | 92 | private def helo_domain : String 93 | @helo_domain ||= @config.helo_domain 94 | end 95 | 96 | private def socket 97 | if _socket = @socket 98 | _socket 99 | else 100 | raise EMail::Error::ClientError.new("Client socket not opened.") 101 | end 102 | end 103 | 104 | # Starts SMTP session. 105 | # 106 | # In the block, the default receiver will be `self`. 107 | def start 108 | ready_to_send 109 | status_code, _ = smtp_responce("CONN") 110 | if status_code == "220" && smtp_helo && smtp_starttls && smtp_auth 111 | @started = true 112 | with self yield 113 | @started = false 114 | else 115 | log_error("Failed in connecting for some reason") 116 | end 117 | smtp_quit 118 | rescue error 119 | fatal_error(error) 120 | ensure 121 | begin 122 | close_socket 123 | rescue error 124 | fatal_error(error) 125 | end 126 | end 127 | 128 | private def wrap_socket_tls 129 | {% if flag?(:without_openssl) %} 130 | log_error("TLS is disabled because `-D without_openssl` was passed at compile time") 131 | false 132 | {% else %} 133 | case tcp_socket = @socket 134 | when TCPSocket 135 | log_info("create openssl socket.client") 136 | tls_socket = OpenSSL::SSL::Socket::Client.new(tcp_socket, @config.tls_context, sync_close: true, hostname: @config.host) 137 | tls_socket.sync = false 138 | log_info("Start SMTPS session with #{tls_socket.tls_version}") 139 | @socket = tls_socket 140 | true 141 | when OpenSSL::SSL::Socket::Client 142 | log_info("socket is already useing TLS.") 143 | true 144 | else 145 | raise Error::ClientError.new("socket is not opened.") 146 | false 147 | end 148 | {% end %} 149 | end 150 | 151 | private def ready_to_send 152 | log_info("Start TCP session to #{@config.host}:#{@config.port}") 153 | tcp_socket = TCPSocket.new(@config.host, @config.port, @config.dns_timeout, @config.connect_timeout) 154 | if read_timeout = @config.read_timeout 155 | tcp_socket.read_timeout = read_timeout 156 | end 157 | if write_timeout = @config.write_timeout 158 | tcp_socket.write_timeout = write_timeout 159 | end 160 | @socket = @tcp_socket = tcp_socket 161 | wrap_socket_tls if @config.use_smtps? 162 | end 163 | 164 | private def mail_validate!(mail : EMail::Message) : EMail::Message 165 | timestamp = Time.local 166 | mail.date timestamp 167 | mail.message_id String.build { |io| 168 | io << '<' << timestamp.to_unix_ms << '.' << Process.pid 169 | io << '.' << @config.client_name << '@' << helo_domain << '>' 170 | } 171 | mail.validate! 172 | end 173 | 174 | # Sends a email message 175 | # 176 | # You can call this only in the block of the `EMail::Client#start` method. 177 | # This retruns sending result as Bool(`true` for success, `false` for fail). 178 | def send(mail : EMail::Message) : Bool 179 | raise EMail::Error::ClientError.new("Email client has not been started") unless @started 180 | @command_history.clear 181 | mail = mail_validate!(mail) 182 | mail_from = mail.mail_from 183 | recipients = mail.recipients 184 | if smtp_rset && smtp_mail(mail_from) && smtp_rcpt(recipients) && smtp_data(mail.data) 185 | log_info("Successfully sent a message from <#{mail_from.addr}> to #{recipients.size} recipient(s)") 186 | return true 187 | else 188 | log_error("Failed sending message for some reason") 189 | if on_failed = @config.on_failed 190 | on_failed.call(mail, @command_history) 191 | end 192 | return false 193 | end 194 | end 195 | 196 | private def smtp_command(command : String, parameter : String? = nil) 197 | command_and_parameter = command 198 | command_and_parameter += " " + parameter if parameter 199 | @command_history << command_and_parameter 200 | log_debug("--> #{command_and_parameter}") 201 | socket << command_and_parameter << "\r\n" 202 | socket.flush 203 | smtp_responce(command) 204 | end 205 | 206 | private def log : Log 207 | @config.log || EMail::Client.log 208 | end 209 | 210 | private def smtp_responce(command : String) 211 | status_code = "" 212 | status_messages = [] of String 213 | while (line = socket.gets) 214 | @command_history << line 215 | if line =~ /\A(\d{3})((( |-)(.*))?)\z/ 216 | continue = false 217 | unless $2.empty? 218 | continue = ($4 == "-") 219 | status_messages << $5.to_s unless $5.empty? 220 | end 221 | unless continue 222 | status_code = $1 223 | break 224 | end 225 | else 226 | raise EMail::Error::ClientError.new("Invalid responce \"#{line}\" received.") 227 | end 228 | end 229 | status_message = status_messages.join(" / ") 230 | logging_message = "<-- #{command} #{status_code} #{status_message}" 231 | case status_code[0] 232 | when '4', '5' 233 | log_error(logging_message) 234 | else 235 | log_debug(logging_message) 236 | end 237 | {status_code, status_messages} 238 | end 239 | 240 | private def smtp_helo 241 | status_code, status_messages = smtp_command("EHLO", helo_domain) 242 | if status_code == "250" 243 | status_messages.each do |status_message| 244 | message_parts = status_message.strip.split(' ') 245 | command = message_parts.shift 246 | @esmtp_commands[command] = message_parts 247 | end 248 | true 249 | elsif status_code == "502" 250 | status_code, _ = smtp_command("HELO", helo_domain) 251 | status_code == "250" 252 | end 253 | end 254 | 255 | private def smtp_starttls 256 | if @config.use_starttls? 257 | status_code, _ = smtp_command("STARTTLS") 258 | if (status_code == "220") 259 | wrap_socket_tls 260 | @esmtp_commands.clear 261 | return smtp_helo 262 | else 263 | false 264 | end 265 | else 266 | true 267 | end 268 | end 269 | 270 | private def smtp_auth 271 | if login_credential = @config.use_auth? 272 | login_id = @config.auth_id.not_nil! 273 | login_password = @config.auth_password.not_nil! 274 | if socket.is_a?(OpenSSL::SSL::Socket::Client) 275 | if @esmtp_commands["AUTH"].includes?("PLAIN") 276 | smtp_auth_plain(login_id, login_password) 277 | elsif @esmtp_commands["AUTH"].includes?("LOGIN") 278 | smtp_auth_login(login_id, login_password) 279 | else 280 | log_error("cannot found supported authentication methods") 281 | false 282 | end 283 | else 284 | log_error("AUTH command cannot be used without TLS") 285 | false 286 | end 287 | else 288 | true 289 | end 290 | end 291 | 292 | private def smtp_auth_login(login_id : String, login_password : String) 293 | status_code, _ = smtp_command("AUTH", "LOGIN") 294 | if status_code == "334" 295 | log_debug("--> Sending login id") 296 | socket << Base64.strict_encode(login_id) << "\r\n" 297 | socket.flush 298 | status_code_id, _ = smtp_responce("AUTH") 299 | if status_code_id == "334" 300 | log_debug("--> Sending login password") 301 | socket << Base64.strict_encode(login_password) << "\r\n" 302 | socket.flush 303 | status_code_password, _ = smtp_responce("AUTH") 304 | if status_code_password == "235" 305 | log_info("Authentication success with #{login_id} / ********") 306 | true 307 | else 308 | false 309 | end 310 | else 311 | false 312 | end 313 | else 314 | false 315 | end 316 | end 317 | 318 | private def smtp_auth_plain(login_id : String, login_password : String) 319 | credential = Base64.strict_encode("\0#{login_id}\0#{login_password}") 320 | status_code, _ = smtp_command("AUTH", "PLAIN #{credential}") 321 | if status_code == "235" 322 | log_info("Authentication success with #{login_id} / ********") 323 | true 324 | else 325 | false 326 | end 327 | end 328 | 329 | private def smtp_rset 330 | if @first_send 331 | @first_send = false 332 | true 333 | else 334 | status_code, _ = smtp_command("RSET") 335 | status_code == "250" 336 | end 337 | end 338 | 339 | private def smtp_mail(mail_from : EMail::Address) 340 | status_code, _ = smtp_command("MAIL", "FROM:<#{mail_from.addr}>") 341 | status_code == "250" 342 | end 343 | 344 | private def smtp_rcpt(recipients : Array(EMail::Address)) 345 | succeed = true 346 | recipients.each do |recipient| 347 | status_code, status_message = smtp_command("RCPT", "TO:<#{recipient.addr}>") 348 | succeed = false unless status_code[0] == '2' 349 | end 350 | succeed 351 | end 352 | 353 | private def smtp_data(mail_data : String) 354 | status_code, _ = smtp_command("DATA") 355 | if status_code == "354" 356 | log_debug("--> Sending mail data") 357 | socket << mail_data 358 | socket.flush 359 | status_code, _ = smtp_responce("DATA") 360 | status_code[0] == '2' 361 | else 362 | false 363 | end 364 | end 365 | 366 | private def smtp_quit 367 | smtp_command("QUIT") 368 | end 369 | 370 | private def close_socket 371 | if _socket = @socket 372 | _socket.close 373 | log_info("Close session to #{@config.host}:#{@config.port}") 374 | end 375 | @socket = nil 376 | end 377 | 378 | private def fatal_error(error : Exception) 379 | log_fatal(error) 380 | if on_fatal_error = @config.on_fatal_error 381 | on_fatal_error.call(error) 382 | end 383 | end 384 | 385 | private def log_format(message : String) 386 | String.build do |str| 387 | str << '[' << @config.client_name 388 | str << '_' << @number if @number 389 | str << "] " << message 390 | end 391 | end 392 | 393 | private def log_debug(message : String) 394 | message = log_format(message) 395 | log.debug { message } 396 | end 397 | 398 | private def log_info(message : String) 399 | message = log_format(message) 400 | log.info { message } 401 | end 402 | 403 | private def log_error(message : String) 404 | message = log_format(message) 405 | log.error { message } 406 | end 407 | 408 | private def log_fatal(error : Exception) 409 | message = log_format("Exception raised") 410 | log.fatal(exception: error) { message } 411 | end 412 | end 413 | -------------------------------------------------------------------------------- /src/email/client/config.cr: -------------------------------------------------------------------------------- 1 | class EMail::Client 2 | enum TLSMode 3 | NONE 4 | STARTTLS 5 | SMTPS 6 | end 7 | # SMTP error handler. 8 | # 9 | # Called when the SMTP server returns **4XX** or **5XX** responce during sending email. 10 | alias OnFailedProc = Message, Array(String) -> 11 | 12 | # Fatal error handler. 13 | # 14 | # Called when the exception is raised during sending email. 15 | alias OnFatalErrorProc = Exception -> 16 | 17 | # SMTP client setting object. 18 | # 19 | # ``` 20 | # # Create config object with the SMTP server FQDN(or IP address), port number, and helo domain. 21 | # config = EMail::Client::Config.new("your.mx.example.com", 587, helo_domain: "your.host.example.com") 22 | # ``` 23 | # 24 | # ### TLS settings 25 | # 26 | # ``` 27 | # # Use SMTP over SSL/TLS 28 | # config.use_tls(TLSMode::SMTPS) 29 | # 30 | # # Use STARTTLS command to send email 31 | # config.use_tls(TLSMode::STARTTLS) 32 | # 33 | # # OpenSSL::SSL::Context::Client object for STARTTLS commands. 34 | # config.tls_context 35 | # 36 | # # Disable TLS1.1 or lower protocols. 37 | # config.tls_context.add_options(OpenSSL::SSL::Options::NO_SSL_V2 | OpenSSL::SSL::Options::NO_SSL_V3 | OpenSSL::SSL::Options::NO_TLS_V1 | OpenSSL::SSL::Options::NO_TLS_V1_1) 38 | # 39 | # # Set OpenSSL verification mode to skip certificate verification. 40 | # config.tls_context.verify_mode = OpenSSL::SSL::VerifyMode::NONE 41 | # ``` 42 | # 43 | # ### SMTP authentication 44 | # 45 | # ``` 46 | # config.use_auth("id", "password") 47 | # ``` 48 | # 49 | # ### Logging 50 | # 51 | # ``` 52 | # # Use the client specific(non-default) logger. 53 | # config.log = Log.for("your_log_source") 54 | # ``` 55 | # 56 | # ### Error handling 57 | # 58 | # ``` 59 | # # Set SMTP error handler. 60 | # # Default: nil 61 | # config.on_failed = EMail::Client::OnFailedProc.new do |mail, command_history| 62 | # puts mail.data 63 | # puts "" 64 | # puts command_history.join("\n") 65 | # end 66 | # 67 | # # Set fatal error handler. 68 | # # Default: nil 69 | # config.on_fatal_error = EMail::Client::OnFatalErrorProc.new do |error| 70 | # puts error 71 | # end 72 | # ``` 73 | # 74 | # ### Connection timeouts 75 | # 76 | # ``` 77 | # config.connect_timeout = 1 # sec 78 | # config.read_timeout = 1 # sec 79 | # config.write_timeout = 1 # sec 80 | # config.dns_timeout = 1 # sec 81 | # ``` 82 | # 83 | # ### Misc 84 | # 85 | # ``` 86 | # # Set email client name, used in log entries and Message-ID headers. 87 | # # Default: "EMail_Client" 88 | # config.name = "your_app_name" 89 | # ``` 90 | # 91 | class Config 92 | # SMTP server hostname or IP address. 93 | property host : String 94 | 95 | # Port number of SMTP server. 96 | property port : Int32 97 | 98 | # Client name used in **Message-Id** header. 99 | getter client_name = EMail::Client::DEFAULT_NAME 100 | 101 | # Domain name for SMTP **HELO** / **EHLO** command. 102 | getter helo_domain : String 103 | 104 | # Callback function to be called when the SMTP server returns **4XX** or **5XX** response. 105 | # 106 | # This will be called with email message object that tried to send, and SMTP commands and responses history. In this function, you can do something to handle errors: e.g. "investigating the causes of the fail", "notifying you of the fail", and so on.Fatal error handler. 107 | property on_failed : EMail::Client::OnFailedProc? 108 | 109 | # Callback function to be calld when an exception is raised during SMTP session. 110 | # 111 | # It will be called with the raised Exception instance. 112 | property on_fatal_error : EMail::Client::OnFatalErrorProc = EMail::Client::OnFatalErrorProc.new { |e| raise e } 113 | 114 | # OpenSSL context for the TLS connection 115 | # 116 | # See [OpenSSL::SSL::Context::Client](https://crystal-lang.org/api/OpenSSL/SSL/Context/Client.html). 117 | getter tls_context = OpenSSL::SSL::Context::Client.new 118 | 119 | # Client specific(non-default) logger. 120 | # 121 | # Even without this, email clients can use the default logger of the EMail::Client type to output log entries. 122 | # 123 | # See [Log](https://crystal-lang.org/api/OpenSSL/Log.html). 124 | property log : Log? 125 | 126 | # DNS timeout for the socket. 127 | getter dns_timeout : Time::Span? 128 | 129 | # CONNECT timeout for the socket. 130 | getter connect_timeout : Time::Span? 131 | 132 | # READ timeout for the socket. 133 | getter read_timeout : Time::Span? 134 | 135 | # WRITE timeout for the socket. 136 | getter write_timeout : Time::Span? 137 | 138 | @tls : TLSMode = TLSMode::NONE 139 | @auth : NamedTuple(id: String, password: String)? 140 | 141 | # Returns `EMail::Client::Config` object with given settings. 142 | # 143 | # - `use_tls: tls_mode` -> `#use_tls(tls_mode)` 144 | # - `auth: {"id", "password"}` -> `#use_auth("id", "password")` 145 | # 146 | # Other optional arguments set value to the property that has the same name. 147 | # 148 | # **Notice:** Since v0.7.0, helo_domain argument is required. 149 | def self.create(host, port = EMail::DEFAULT_SMTP_PORT, *, 150 | client_name : String? = nil, helo_domain : String, 151 | on_failed : EMail::Client::OnFailedProc? = nil, 152 | on_fatal_error : EMail::Client::OnFatalErrorProc? = nil, 153 | tls_verify_mode : OpenSSL::SSL::VerifyMode? = nil, 154 | use_tls : TLSMode = TLSMode::NONE, 155 | auth : Tuple(String, String)? = nil, 156 | log : Log? = nil, 157 | dns_timeout : Int32? = nil, connect_timeout : Int32? = nil, 158 | read_timeout : Int32? = nil, write_timeout : Int32? = nil) 159 | config = new(host, port, helo_domain: helo_domain) 160 | config.client_name = client_name if client_name 161 | config.on_failed = on_failed 162 | config.on_fatal_error = on_fatal_error if on_fatal_error 163 | config.tls_context.verify_mode = tls_verify_mode if tls_verify_mode 164 | config.use_tls(use_tls) 165 | config.log = log 166 | config.use_auth(auth[0], auth[1]) if auth 167 | config.dns_timeout = dns_timeout if dns_timeout 168 | config.connect_timeout = connect_timeout if connect_timeout 169 | config.read_timeout = read_timeout if read_timeout 170 | config.write_timeout = write_timeout if write_timeout 171 | config 172 | end 173 | 174 | # Creates instance with minimam setting. 175 | # 176 | # **Notice:** Since v0.7.0, helo_domain argument is required. 177 | def initialize(@host, @port = EMail::DEFAULT_SMTP_PORT, *, @helo_domain) 178 | end 179 | 180 | # Domain name for SMTP **HELO** or **EHLO** command. 181 | # 182 | # Only FQDN format is acceptable. 183 | def helo_domain=(new_domain : String) 184 | raise EMail::Error::ClientConfigError.new("Invalid HELO domain \"#{helo_domain}\"") unless new_domain =~ DOMAIN_FORMAT 185 | @helo_domain = new_domain 186 | end 187 | 188 | # Enables TLS function to encrypt the SMTP session. 189 | def use_tls(tls_mode : TLSMode) 190 | {% if flag?(:without_openssl) %} 191 | raise EMail::Error::ClientConfigError.new("TLS is disabled because `-D without_openssl` was passed at compile time") 192 | {% end %} 193 | @tls = tls_mode 194 | end 195 | 196 | # Returns `true` when using SMTPS. 197 | def use_smtps? 198 | @tls.smtps? 199 | end 200 | 201 | # Returns `true` when using STARTTLS. 202 | def use_starttls? 203 | @tls.starttls? 204 | end 205 | 206 | # Client name used in **Message-ID** header and log entry. 207 | # 208 | # Only alphabets(`a`-`z`, `A`-`Z`), numbers(`0`-`9`), and underscore(`_`) are acceptable. 209 | def client_name=(new_name : String) 210 | raise EMail::Error::ClientConfigError.new("Invalid client name \"#{new_name}\"") if new_name.empty? || new_name =~ /\W/ 211 | @client_name = new_name 212 | end 213 | 214 | # Sets the client to authenticate with SMTP **AUTH** command by using given id and password. 215 | # 216 | # Only **AUTH PLAIN** and **AUTH LOGIN** commands are supported. 217 | # 218 | # **NOTE: SMTP authentication can be used only under TLS encryption.** 219 | def use_auth(id, password) 220 | @auth = {id: id, password: password} 221 | end 222 | 223 | # Returns authentication id when using SMTP AUTH. 224 | def auth_id 225 | @auth.try &.[](:id) 226 | end 227 | 228 | # Returns authentication password when using SMTP AUTH. 229 | def auth_password 230 | @auth.try &.[](:password) 231 | end 232 | 233 | # Returns `true` when using SMTP AUTH. 234 | def use_auth? 235 | !@auth.nil? 236 | end 237 | 238 | {% for name in ["dns", "connect", "read", "write"] %} 239 | # {{name.id.upcase}} timeout for the socket. 240 | def {{name.id}}_timeout=(sec : Int32) 241 | raise EMail::Error::ClientConfigError.new("{{name.id}}_timeout must be greater than 0.") unless sec > 0 242 | @{{name.id}}_timeout = sec.second 243 | end 244 | {% end %} 245 | end 246 | end 247 | -------------------------------------------------------------------------------- /src/email/concurrent_sender.cr: -------------------------------------------------------------------------------- 1 | # Utility object for concurrent email sending. 2 | # 3 | # ``` 4 | # rcpt_list = ["a@example.com", "b@example.com", "c@example.com", "d@example.com"] 5 | # 6 | # # Set SMTP client configuration 7 | # config = EMail::Client::Config.new("your.mx.example.com", 25) 8 | # 9 | # # Create concurrent sender object 10 | # sender = EMail::ConcurrentSender.new(config) 11 | # 12 | # # Sending emails with concurrently 3 connections. 13 | # sender.number_of_connections = 3 14 | # 15 | # # Sending max 10 emails by 1 connection. 16 | # sender.messages_per_connection = 10 17 | # 18 | # # Start email sending. 19 | # sender.start do 20 | # # In this block, default receiver is sender 21 | # rcpt_list.each do |rcpt_to| 22 | # # Create email message 23 | # mail = EMail::Message.new 24 | # mail.from "your_addr@example.com" 25 | # mail.to rcpt_to 26 | # mail.subject "Concurrent email sending" 27 | # mail.message "message to #{rcpt_to}" 28 | # # Enqueue the email to sender 29 | # enqueue mail 30 | # end 31 | # end 32 | # ``` 33 | class EMail::ConcurrentSender 34 | @config : EMail::Client::Config 35 | @queue : Array(Message) = Array(Message).new 36 | @connections : Array(Fiber) = Array(Fiber).new 37 | @connection_count : Int32 = 0 38 | @started : Bool = false 39 | @finished : Bool = false 40 | @number_of_connections : Int32 = 1 41 | @messages_per_connection : Int32 = 10 42 | @connection_interval : Int32 = 200 43 | 44 | # Creates sender object with given client settings as EMail::Client::Config object. 45 | def initialize(@config) 46 | end 47 | 48 | # Sends one email with given client settings as several arguments. 49 | # 50 | # Avairable arguments are same as `EMail::Client::Conifg.create` method. 51 | def initialize(*args, **named_args) 52 | initialize(EMail::Client::Config.create(*args, **named_args)) 53 | end 54 | 55 | # Sets the maximum number of SMTP connections established at the same time. 56 | def number_of_connections=(new_value : Int32) 57 | raise EMail::Error::SenderError.new("Parameters cannot be set after start sending") if @started 58 | raise EMail::Error::SenderError.new("Number of connections must be 1 or greater(given: #{new_value})") if new_value < 1 59 | @number_of_connections = new_value 60 | end 61 | 62 | # Sets the maximum number of email messages sent by one SMTP connection. 63 | # 64 | # When the number of sent emails by some SMTP connection reaches this parameter, the current connection will be closed and new one will be opened. 65 | def messages_per_connection=(new_value : Int32) 66 | raise EMail::Error::SenderError.new("Parameters cannot be set after start sending") if @started 67 | raise EMail::Error::SenderError.new("Messages per connection must be 1 or greater(given: #{new_value})") if new_value < 1 68 | @messages_per_connection = new_value 69 | end 70 | 71 | # Sets the interval milliseconds between some connection is closed and new one is opened. 72 | def connection_interval=(new_interval : Int32) 73 | raise EMail::Error::SenderError.new("Parameters cannot be set after start sending") if @started 74 | raise EMail::Error::SenderError.new("Connection interval must be 0 or greater(given: #{new_interval})") if new_interval < 0 75 | @connection_interval = new_interval 76 | end 77 | 78 | # Enqueues a email message. 79 | def enqueue(message : Message) 80 | @queue << message.validate! 81 | Fiber.yield 82 | end 83 | 84 | # Encueues email messages at the same time. 85 | def enqueue(messages : Array(Message)) 86 | messages.each do |message| 87 | enqueue(message) 88 | end 89 | end 90 | 91 | # Starts sending emails. 92 | # 93 | # In the block of this method, the default receiver is `self` 94 | def start 95 | raise EMail::Error::SenderError.new("Email sending is already started") if @started 96 | @started = true 97 | spawn_sender 98 | with self yield 99 | @finished = true 100 | until @queue.empty? && @connections.empty? 101 | Fiber.yield 102 | end 103 | @started = false 104 | @finished = false 105 | end 106 | 107 | # Starts sending emails with given parameters. 108 | def start(number_of_connections : Int32? = nil, messages_per_connection : Int32? = nil, connection_interval : Int32? = nil) 109 | raise EMail::Error::SenderError.new("Email sending is already started") if @started 110 | self.number_of_connections = number_of_connections if number_of_connections 111 | self.messages_per_connection = messages_per_connection if messages_per_connection 112 | self.connection_interval = connection_interval if connection_interval 113 | @started = true 114 | spawn_sender 115 | with self yield 116 | @finished = true 117 | until @queue.empty? && @connections.empty? 118 | Fiber.yield 119 | end 120 | end 121 | 122 | private def spawn_sender 123 | spawn do 124 | until @finished && @queue.empty? 125 | spawn_client if !@queue.empty? && @connections.size < @number_of_connections 126 | Fiber.yield 127 | end 128 | end 129 | end 130 | 131 | private def spawn_client 132 | spawn do 133 | @connections << Fiber.current 134 | message = @queue.shift? 135 | while message 136 | @connection_count += 1 137 | client = Client.new(@config, @connection_count) 138 | client.number = @connection_count 139 | client.start do 140 | sent_messages = 0 141 | while message && sent_messages < @messages_per_connection 142 | send(message) 143 | sent_messages += 1 144 | Fiber.yield 145 | message = @queue.shift? 146 | end 147 | end 148 | sleep(@connection_interval.milliseconds) if @connection_interval > 0 149 | end 150 | @connections.delete(Fiber.current) 151 | end 152 | end 153 | end 154 | 155 | module EMail 156 | alias Sender = EMail::ConcurrentSender 157 | end 158 | -------------------------------------------------------------------------------- /src/email/content.cr: -------------------------------------------------------------------------------- 1 | # :nodoc: 2 | abstract class EMail::Content 3 | @mime_type : String 4 | @data : String = "" 5 | @content_type : Header::ContentType 6 | @other_headers = Array(Header).new 7 | @content_transfer_encoding = Header::ContentTransferEncoding.new("7bit") 8 | 9 | # :nodoc: 10 | def initialize(@mime_type : String) 11 | @content_type = Header::ContentType.new(@mime_type) 12 | end 13 | 14 | # Returns the list of email header of this content. 15 | def headers 16 | [content_type, content_transfer_encoding] + @other_headers 17 | end 18 | 19 | private def encode_data(str : String) 20 | encode_data(IO::Memory.new(str)) 21 | end 22 | 23 | private def encode_data(io : IO) 24 | @content_transfer_encoding.set("base64") 25 | line_size = 54 26 | buf = Bytes.new(line_size) 27 | lines = [] of String 28 | while ((bytes = io.read(buf)) > 0) 29 | unless bytes == line_size 30 | rest_buf = Bytes.new(line_size - bytes) 31 | if (rest_bytes = io.read(rest_buf)) > 0 32 | (0..rest_bytes - 1).each do |i| 33 | buf[bytes + i] = rest_buf[i] 34 | end 35 | bytes += rest_bytes 36 | end 37 | end 38 | lines << Base64.strict_encode(buf[0, bytes]) 39 | end 40 | lines.join('\n') 41 | end 42 | 43 | private def content_type 44 | @content_type 45 | end 46 | 47 | private def content_transfer_encoding 48 | @content_transfer_encoding 49 | end 50 | 51 | # Write content data to `io`. 52 | def data(io : IO, with_header : Bool) 53 | if with_header 54 | headers.each do |header| 55 | io << header << '\n' unless header.empty? 56 | end 57 | io << '\n' 58 | end 59 | io << @data 60 | end 61 | 62 | # Returns content data as String. 63 | def data(with_header : Bool = false) 64 | String.build do |io| 65 | data(io, with_header) 66 | end 67 | end 68 | 69 | def to_s(io : IO) 70 | io << data(with_header: true) 71 | end 72 | 73 | # Returns `true` when this content has no data. 74 | def empty? 75 | @data.empty? 76 | end 77 | 78 | # :nodoc: 79 | class TextContent < Content 80 | # Create content with given MIME subtype of text. 81 | # 82 | # When `text_type` is `plain`, the Mediatype of this content is `text/plain`. 83 | def initialize(text_type : String) 84 | super("text/#{text_type}") 85 | @content_type.set_charset("UTF-8") 86 | end 87 | 88 | # Set content text. 89 | def data=(message_body : String) 90 | encoded = !message_body.ascii_only? || message_body.split(/\r?\n/).map(&.bytesize).max > 998 91 | @data = (encoded ? encode_data(message_body) : message_body) 92 | end 93 | end 94 | 95 | # :nodoc: 96 | class AttachmentFile < Content 97 | # :nodoc: 98 | NAME_TO_ENCODE = /[^\w\_\-\. ]/ 99 | 100 | def initialize(file_path : String, file_id : String? = nil, file_name : String? = nil, mime_type : String? = nil) 101 | file_name ||= file_path.split(/\//).last 102 | raise EMail::Error::ContentError.new("Attached file #{file_path} is not exist.") unless File.file?(file_path) 103 | File.open(file_path) do |io| 104 | initialize(io, file_id: file_id, file_name: file_name, mime_type: mime_type) 105 | end 106 | end 107 | 108 | def initialize(io : IO, @file_id : String?, @file_name : String, mime_type : String? = nil) 109 | extname = if @file_name =~ /(\.[^\.]+)\z/ 110 | $1 111 | else 112 | "" 113 | end 114 | mime_type ||= (EMail::MIME_TYPE[extname]? || "application/octet-stream") 115 | super(mime_type) 116 | @content_type.set_fname(@file_name) 117 | @other_headers << Header::ContentDisposition.new(@file_name) 118 | if file_id = @file_id 119 | content_id = Header::ContentID.new 120 | content_id.set(file_id) 121 | @other_headers << content_id 122 | end 123 | @data = encode_data(io) 124 | end 125 | end 126 | 127 | class Multipart < Content 128 | @@boundaries = Set(String).new 129 | 130 | def self.boundary 131 | boundary_string = "" 132 | while boundary_string.empty? || @@boundaries.includes?(boundary_string) 133 | boundary_string = String.build do |str| 134 | str << "Multipart_Boundary_" 135 | str << Time.local.to_unix_ms 136 | str << '_' 137 | str << rand(UInt32::MAX) 138 | end 139 | end 140 | @@boundaries << boundary_string 141 | boundary_string 142 | end 143 | 144 | @contents = Array(Content).new 145 | @boundary : String 146 | 147 | def initialize(multipart_type : String) 148 | super("multipart/#{multipart_type}") 149 | @boundary = Multipart.boundary 150 | @content_type.set_boundary(@boundary) 151 | end 152 | 153 | def add(content : Content) 154 | @contents << content 155 | self 156 | end 157 | 158 | def <<(content : Content) 159 | add(content) 160 | end 161 | 162 | def data(with_header : Bool = false) 163 | String.build do |io| 164 | if with_header 165 | headers.each do |header| 166 | io << header << '\n' unless header.empty? 167 | end 168 | io << '\n' 169 | end 170 | @contents.each do |content| 171 | io << "\n--" << @boundary << '\n' 172 | io << content << '\n' 173 | end 174 | io << "\n--" << @boundary << "--" 175 | end 176 | end 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /src/email/error.cr: -------------------------------------------------------------------------------- 1 | class EMail::Error < Exception 2 | class AddressError < Error; end 3 | 4 | class ContentError < Error; end 5 | 6 | class HeaderError < Error; end 7 | 8 | class ClientError < Error; end 9 | 10 | class ClientConfigError < Error; end 11 | 12 | class MessageError < Error; end 13 | 14 | class SenderError < Error; end 15 | end 16 | -------------------------------------------------------------------------------- /src/email/header.cr: -------------------------------------------------------------------------------- 1 | # :nodoc: 2 | abstract class EMail::Header 3 | # :nodoc: 4 | FIELD_NAME = /\A[\x{21}-\x{39}\x{3b}-\x{7e}]+\z/ 5 | # :nodoc: 6 | FIELD_BODY = /\A[\x{1}-\x{9}\x{b}\x{c}\x{e}-\x{1f}\x{20}-\x{7f}]+\z/ 7 | 8 | # :nodoc: 9 | NON_VCHAR = /[^\x{9}\x{20}-\x{7e}]/ 10 | # :nodoc: 11 | LINE_LENGTH = 78 12 | # :nodoc: 13 | ENCODE_DEFINITION_SIZE = 13 14 | # :nodoc: 15 | ENCODE_DEFINITION_HEAD = " =?UTF-8?B?" 16 | # :nodoc: 17 | ENCODE_DEFINITION_TAIL = "?=" 18 | 19 | # :nodoc: 20 | def self.base64_encode(src_string : String, offset : Int32) : Tuple(String, Int32) 21 | encoded_lines = [] of String 22 | encoded_line = "" 23 | src_chars = Char::Reader.new(src_string) 24 | until src_chars.current_char == '\u{0}' 25 | encoded_size = base64_encoded_size(encoded_line.bytesize + src_chars.current_char_width) 26 | if offset + ENCODE_DEFINITION_SIZE + encoded_size > LINE_LENGTH 27 | if encoded_line.empty? 28 | encoded_lines << "" 29 | else 30 | encoded_lines << ENCODE_DEFINITION_HEAD + Base64.strict_encode(encoded_line.to_slice) + ENCODE_DEFINITION_TAIL 31 | end 32 | encoded_line = "" 33 | offset = 0 34 | end 35 | encoded_line += src_chars.current_char 36 | src_chars.next_char 37 | end 38 | encoded_lines << ENCODE_DEFINITION_HEAD + Base64.strict_encode(encoded_line.to_slice) + ENCODE_DEFINITION_TAIL unless encoded_line.empty? 39 | if last_line = encoded_lines.last? 40 | offset = last_line.size 41 | end 42 | {encoded_lines.join("\n"), offset} 43 | end 44 | 45 | # :nodoc: 46 | def self.base64_encoded_size(bytesize : Int32) 47 | ((((bytesize.to_f * 8 / 6).ceil) / 4).ceil * 4).to_i 48 | end 49 | 50 | # Converts header field name to Capitalized-Kebab-Case string. 51 | # 52 | # ``` 53 | # Header.normaliz_name("x-mailer") # => "X-Mailer" 54 | # ``` 55 | def self.normalize_name(name : String) : String 56 | name = name.split("-").map(&.capitalize).join("-") 57 | raise EMail::Error::HeaderError.new("#{name.inspect} is invalid as a header field name.") unless name =~ FIELD_NAME 58 | name 59 | end 60 | 61 | # Returns header name. 62 | getter name 63 | 64 | @name : String 65 | 66 | # Creates email header with given header name. 67 | def initialize(field_name : String) 68 | @name = Header.normalize_name(field_name) 69 | end 70 | 71 | private def body 72 | "" 73 | end 74 | 75 | # Returns `true` when the header body has no data. 76 | def empty? 77 | body.empty? 78 | end 79 | 80 | def to_s(io : IO) 81 | header_body = body 82 | raise EMail::Error::HeaderError.new("Header #{@name} includes invalid line break(s).") if header_body =~ /\n[^\x{9}\x{20}]/ 83 | io << @name << ":" 84 | offset = @name.size + 1 85 | if header_body =~ FIELD_BODY 86 | splited_body = header_body.split(/\s+/) 87 | while (body_part = splited_body.shift?) 88 | unless offset + body_part.size < LINE_LENGTH 89 | io << '\n' 90 | offset = 0 91 | end 92 | io << ' ' << body_part 93 | offset += body_part.size + 1 94 | end 95 | else 96 | encoded_part, offset = Header.base64_encode(header_body, offset) 97 | io << encoded_part 98 | end 99 | end 100 | 101 | # Email header including multiple email addresses such as **From**, **To**, and so on. 102 | class AddressList < Header 103 | # Returns internal email address list. 104 | getter list 105 | 106 | @list = [] of Address 107 | 108 | private def body 109 | @list.join(", ") 110 | end 111 | 112 | # Returns `true` when the list has no email address. 113 | def empty? 114 | @list.empty? 115 | end 116 | 117 | # Returns the number of included email addresses. 118 | def size 119 | @list.size 120 | end 121 | 122 | # Adds email address. 123 | def add(mail_address : String, sender_name : String? = nil) 124 | @list << Address.new(mail_address, sender_name) 125 | end 126 | 127 | # Adds email address. 128 | def add(mail_address : Address) 129 | @list << mail_address 130 | end 131 | 132 | # Removes all email addrersses 133 | def clear 134 | @list.clear 135 | end 136 | end 137 | 138 | # Email header including only one email addresses such as **Sender**. 139 | class SingleAddress < Header 140 | @addr : Address? = nil 141 | 142 | private def body 143 | addr.to_s 144 | end 145 | 146 | # Returns `true` when the email address is not set. 147 | def empty? 148 | @addr.nil? 149 | end 150 | 151 | # Returns set email address. 152 | # 153 | # When empty, raises an excepotion. 154 | def addr 155 | @addr.not_nil! 156 | end 157 | 158 | # Sets email address. 159 | def set(mail_address : String, sender_name : String? = nil) 160 | @addr = Address.new(mail_address, sender_name) 161 | end 162 | 163 | # Sets email address. 164 | def set(mail_address : Address) 165 | @addr = mail_address 166 | end 167 | 168 | # Removes email address 169 | def clear 170 | @addr = nil 171 | end 172 | end 173 | 174 | # **Date** header. 175 | class Date < Header 176 | RFC2822_FORMAT = "%a, %d %b %Y %T %z" 177 | 178 | @timestamp : Time? = nil 179 | 180 | def initialize 181 | super("Date") 182 | end 183 | 184 | # Sets date-time. 185 | def time=(time : Time) 186 | @timestamp = time 187 | end 188 | 189 | # Returns `true` when the date-time is not set. 190 | def empty? 191 | @timestamp.nil? 192 | end 193 | 194 | private def body 195 | @timestamp.not_nil!.to_s(RFC2822_FORMAT) 196 | end 197 | end 198 | 199 | # Email headers that has no specific format such as **Subject**. 200 | class Unstructured < Header 201 | @text : String = "" 202 | 203 | private def body 204 | @text 205 | end 206 | 207 | # Sets header body text. 208 | def set(body_text : String) 209 | @text = body_text 210 | end 211 | end 212 | 213 | # **Mime-Version** header. 214 | class MimeVersion < Header 215 | def initialize(@version : String = "1.0") 216 | super("Mime-Version") 217 | end 218 | 219 | private def body 220 | @version 221 | end 222 | end 223 | 224 | # **Content-Type** header 225 | class ContentType < Header 226 | @mime_type : String 227 | @params : Hash(String, String) 228 | 229 | def initialize(@mime_type : String, @params = Hash(String, String).new) 230 | super("Content-Type") 231 | end 232 | 233 | # Sets Media type parameter 234 | def set_parameter(name : String, value : String) 235 | @params[name] = value 236 | end 237 | 238 | # Sets MIME type and subtype. 239 | def set_mime_type(mime_type : String) 240 | @mime_type = mime_type 241 | end 242 | 243 | # Sets "charset" parameter. 244 | def set_charset(charset : String) 245 | @params["charset"] = charset 246 | end 247 | 248 | # Sets "file_name" parameter. 249 | def set_fname(file_name : String) 250 | @params["file_name"] = file_name 251 | end 252 | 253 | # Sets "boundary" parameter. 254 | def set_boundary(boundary : String) 255 | @params["boundary"] = boundary 256 | end 257 | 258 | private def body 259 | String.build do |body_text| 260 | body_text << @mime_type << ';' 261 | if charset = @params["charset"]? 262 | body_text << " charset=" << charset << ';' 263 | end 264 | if fname = @params["file_name"]? 265 | body_text << " name=\"" 266 | encoded_fname, _ = Header.base64_encode(fname, 6) 267 | body_text << encoded_fname.strip.gsub(/\n +/, ' ') << "\";" 268 | end 269 | if boundary = @params["boundary"]? 270 | body_text << " boundary=\"" << boundary << "\";" 271 | end 272 | end 273 | end 274 | end 275 | 276 | # **Content-Trandfer-Encoding** Header 277 | class ContentTransferEncoding < Header 278 | def initialize(@encoding : String) 279 | super("Content-Transfer-Encoding") 280 | end 281 | 282 | # Sets endoding. 283 | def set(encoding : String) 284 | @encoding = encoding 285 | end 286 | 287 | private def body 288 | @encoding 289 | end 290 | end 291 | 292 | # **Content-Disposition** header. 293 | class ContentDisposition < Header 294 | @file_name : String 295 | 296 | def initialize(@file_name : String) 297 | super("Content-Disposition") 298 | end 299 | 300 | private def body 301 | String.build do |body_text| 302 | body_text << "attachment; " << encoded_fname(@file_name) 303 | end 304 | end 305 | 306 | private def encoded_fname(file_name : String) 307 | encoded_lines = [] of String 308 | fname_chars = Char::Reader.new(file_name) 309 | encoded_line = " filename*#{encoded_lines.size}*=UTF-8''" 310 | until fname_chars.current_char == '\u{0}' 311 | fname_char = URI.encode_path(fname_chars.current_char.to_s) 312 | line_size = encoded_line.size + fname_chars.current_char_width * 3 313 | unless line_size < LINE_LENGTH 314 | encoded_lines << encoded_line + ";" 315 | encoded_line = " filename*#{encoded_lines.size}*=" 316 | end 317 | encoded_line += fname_char 318 | fname_chars.next_char 319 | end 320 | encoded_lines << encoded_line + ";" unless encoded_line =~ /\=\z/ 321 | encoded_lines.join 322 | end 323 | end 324 | 325 | # **Content-ID** header. 326 | class ContentID < SingleAddress 327 | def initialize 328 | super("Content-Id") 329 | end 330 | end 331 | end 332 | -------------------------------------------------------------------------------- /src/email/message.cr: -------------------------------------------------------------------------------- 1 | # Email message object. 2 | # 3 | # ### Minimal email with plain text message. 4 | # 5 | # ``` 6 | # email = EMail::Message.new 7 | # 8 | # # Email headers 9 | # email.from "your_addr@example.com" 10 | # email.to "to@example.com" 11 | # email.subject "Subject of the mail" 12 | # 13 | # # Email plain text email body 14 | # email.message <<-EOM 15 | # Message body of the mail. 16 | # 17 | # -- 18 | # Your Signature 19 | # EOM 20 | # ``` 21 | # 22 | # You can set following preset headers and your own `#custom_header`s: 23 | # 24 | # - **[*][!]** `#from` 25 | # - **[*][?]** `#to` 26 | # - **[*][?]** `#cc` 27 | # - **[*][?]** `#bcc` 28 | # - **[*]** `#reply_to` 29 | # - `#return_path` 30 | # - `#sender` 31 | # - `#envelope_from` 32 | # - **[!]** `#subject` 33 | # 34 | # **[!]** required. 35 | # 36 | # **[*]** callable multiple times. 37 | # 38 | # **[?]** required at least one of these recipients. 39 | # 40 | # ### Add multiple email addresses at once 41 | # 42 | # For **From**, **To**, **Cc**, **Bcc**, and **Reply-To** headers, you can add multiple email addresses at once with array of **String** or **EMail::Address**. 43 | # 44 | # ``` 45 | # # Add two email addresses with array of email address strings 46 | # email.to ["to1@example.com", "to2@example.com"] 47 | # 48 | # # Notice: Following code will not add two email addresses 49 | # # but only one email address "to1@example.com" 50 | # # that has mailbox name "to2@example.com" 51 | # email.to "to1@example.com", "to2@example.com" 52 | # ``` 53 | # 54 | # or 55 | # 56 | # ``` 57 | # # Add two email addresses with array of EMail::Address ojects 58 | # addr_list = [] of EMail::Address 59 | # addr_list << EMail::Address.new("to1@example.com", "to1 name") 60 | # addr_list << EMail::Address.new("to2@example.com", "to2 name") 61 | # email.to addr_list 62 | # ``` 63 | # 64 | # ### Set custom header 65 | # 66 | # ``` 67 | # email.custom_header "X-Mailer", "Your APP Name" 68 | # ``` 69 | # ### Set mailbox name with email address 70 | # 71 | # ``` 72 | # email.from "your_addr@example.com", "your name" 73 | # ``` 74 | # 75 | # Also, `#to`, `#cc`, `#bcc`, etc... 76 | # 77 | # ### HTML email with altanative plain text message. 78 | # 79 | # ``` 80 | # email = EMail::Message.new 81 | # 82 | # # Email headers 83 | # email.from "your_addr@example.com" 84 | # email.to "to@example.com" 85 | # email.subject "Subject of the mail" 86 | # 87 | # # Email plain text email body 88 | # email.message <<-EOM 89 | # Message body of the mail. 90 | # 91 | # -- 92 | # Your Signature 93 | # EOM 94 | # 95 | # # Email HTML email body 96 | # email.message_html <<-EOM 97 | # 98 | # 99 | #

Subject of the mail

100 | #

Message body of the mail.

101 | #
102 | # Your Signature 103 | #
104 | # 105 | # 106 | # EOM 107 | # ``` 108 | # 109 | # ### Attache files 110 | # 111 | # ``` 112 | # email = EMail::Message.new 113 | # 114 | # # Email headers 115 | # email.from "your_addr@example.com" 116 | # email.to "to@example.com" 117 | # email.subject "Subject of the mail" 118 | # 119 | # # Email plain text email body 120 | # email.message <<-EOM 121 | # Message body of the mail. 122 | # 123 | # -- 124 | # Your Signature 125 | # EOM 126 | # 127 | # # Attach file to email 128 | # email.attach "./photo.jpeg" 129 | # ``` 130 | # 131 | # #### Set alternative file name for recipient 132 | # 133 | # ``` 134 | # email.attach "./photo.jpeg", file_name: "last_year.jpeg" 135 | # ``` 136 | # 137 | # #### Set specific MIME type 138 | # 139 | # ``` 140 | # email.attach "./data", mime_type: "text/csv" 141 | # ``` 142 | # 143 | # #### Read attachment file data from IO 144 | # 145 | # ``` 146 | # email.attach io, file_name: "photo.jpeg" 147 | # ``` 148 | # In this case, `file_name` argument is required. 149 | # 150 | # ### Add message resouces 151 | # 152 | # ``` 153 | # email = EMail::Message.new 154 | # 155 | # # Email headers 156 | # email.from "your_addr@example.com" 157 | # email.to "to@example.com" 158 | # email.subject "Subject of the mail" 159 | # 160 | # # Email plain text email body 161 | # email.message <<-EOM 162 | # Message body of the mail. 163 | # 164 | # -- 165 | # Your Signature 166 | # EOM 167 | # 168 | # # Email HTML email body 169 | # email.message_html <<-EOM 170 | # 171 | # 172 | # 173 | #

Subject of the mail

174 | #

Message body of the mail.

175 | # 178 | # 179 | # 180 | # EOM 181 | # 182 | # # Add message resource 183 | # email.message_resource "./logo.png", cid: "logo@some.domain" 184 | # ``` 185 | # 186 | # `#message_resource` is lmost same as `#attach` expect it requires `cid` argument. 187 | class EMail::Message 188 | @preset_headers = { 189 | return_path: EMail::Header::SingleAddress.new("Return-Path"), 190 | sender: EMail::Header::SingleAddress.new("Sender"), 191 | from: EMail::Header::AddressList.new("From"), 192 | reply_to: EMail::Header::AddressList.new("Reply-To"), 193 | to: EMail::Header::AddressList.new("To"), 194 | cc: EMail::Header::AddressList.new("Cc"), 195 | bcc: EMail::Header::AddressList.new("Bcc"), 196 | subject: EMail::Header::Unstructured.new("Subject"), 197 | message_id: EMail::Header::Unstructured.new("Message-Id"), 198 | date: EMail::Header::Date.new, 199 | } 200 | 201 | @custom_headers = Array(EMail::Header::Unstructured).new 202 | 203 | @body = EMail::Content::TextContent.new("plain") 204 | @body_html = EMail::Content::TextContent.new("html") 205 | @body_resources = Hash(String, EMail::Content::AttachmentFile).new 206 | @attachments = Array(EMail::Content::AttachmentFile).new 207 | @envelope_from : EMail::Address? = nil 208 | 209 | # :nodoc: 210 | def validate! 211 | raise EMail::Error::MessageError.new("Message has no subject.") if @preset_headers[:subject].empty? 212 | raise EMail::Error::MessageError.new("Message has no From address.") if @preset_headers[:from].empty? 213 | raise EMail::Error::MessageError.new("Message has no To addresses.") if @preset_headers[:to].empty? 214 | raise EMail::Error::MessageError.new("Message has no contents.") if @body.empty? && @body_html.empty? && @attachments.empty? 215 | raise EMail::Error::MessageError.new("Message has related resoures, but no text message") if message_has_resource? && !has_message? 216 | if @preset_headers[:sender].empty? && @preset_headers[:from].size > 1 217 | sender @preset_headers[:from].list.first 218 | end 219 | if @preset_headers[:return_path].empty? 220 | return_path @envelope_from || (@preset_headers[:sender].empty? ? @preset_headers[:from].list.first : @preset_headers[:sender].addr) 221 | end 222 | self 223 | end 224 | 225 | # :nodoc: 226 | def recipients 227 | @preset_headers[:to].list + @preset_headers[:cc].list + @preset_headers[:bcc].list 228 | end 229 | 230 | # :nodoc: 231 | def mail_from 232 | @envelope_from ||= @preset_headers[:return_path].addr 233 | end 234 | 235 | # Set envelope from address. 236 | def envelope_from(mail_address : String) 237 | @envelope_from = Address.new(mail_address) 238 | end 239 | 240 | # :nodoc: 241 | def data 242 | to_s.gsub(/\r?\n/, "\r\n").gsub(/\r\n\./, "\r\n..") + "\r\n.\r\n" 243 | end 244 | 245 | # :nodoc: 246 | def has_text_message? 247 | !@body.empty? 248 | end 249 | 250 | # :nodoc: 251 | def has_html_message? 252 | !@body_html.empty? 253 | end 254 | 255 | # :nodoc: 256 | def has_message? 257 | has_text_message? || has_html_message? 258 | end 259 | 260 | # :nodoc: 261 | def message_has_resource? 262 | !@body_resources.empty? 263 | end 264 | 265 | # :nodoc: 266 | def has_multipart_message? 267 | has_text_message? && has_html_message? 268 | end 269 | 270 | # :nodoc: 271 | def has_attache? 272 | !@attachments.empty? 273 | end 274 | 275 | # :nodoc: 276 | def content_count 277 | count = has_message? ? 1 : 0 278 | count += @attachments.size 279 | count 280 | end 281 | 282 | # :nodoc: 283 | def has_multipart_body? 284 | content_count > 1 285 | end 286 | 287 | # :nodoc: 288 | def message_text_content 289 | if has_multipart_message? 290 | EMail::Content::Multipart.new("alternative") << @body << @body_html 291 | elsif has_text_message? 292 | @body 293 | elsif has_html_message? 294 | @body_html 295 | else 296 | raise EMail::Error::MessageError.new("Message doesn't have both of text and html message.") 297 | end 298 | end 299 | 300 | # :nodoc: 301 | def message_content 302 | if message_has_resource? 303 | content = EMail::Content::Multipart.new("related") 304 | content << message_text_content 305 | @body_resources.each_value do |resource| 306 | content << resource 307 | end 308 | content 309 | else 310 | message_text_content 311 | end 312 | end 313 | 314 | # :nodoc: 315 | def body_content 316 | if has_multipart_body? 317 | content = EMail::Content::Multipart.new("mixed") 318 | content << message_content if has_message? 319 | @attachments.each do |attachment| 320 | content << attachment 321 | end 322 | content 323 | else 324 | if has_attache? 325 | @attachments.first 326 | else 327 | message_content 328 | end 329 | end 330 | end 331 | 332 | def to_s(io : IO) 333 | @preset_headers.each_value do |header| 334 | io << header << '\n' unless header.name == "Bcc" || header.empty? 335 | end 336 | @custom_headers.each do |header| 337 | io << header << '\n' 338 | end 339 | io << EMail::Header::MimeVersion.new << '\n' 340 | io << body_content 341 | end 342 | 343 | # Sets plain text message body. 344 | def message(message_body : String) 345 | @body.data = message_body 346 | end 347 | 348 | # Sets html text message body. 349 | def message_html(message_body : String) 350 | @body_html.data = message_body 351 | end 352 | 353 | # Attaches the file from given file path. 354 | # 355 | # You can set another `file_name` for recipients and sprcific `mime_type`. 356 | # By default, MIME type will be inferred from extname of the file name. 357 | def attach(file_path : String, file_name : String? = nil, mime_type : String? = nil) 358 | @attachments << Content::AttachmentFile.new(file_path, file_id: nil, file_name: file_name, mime_type: mime_type) 359 | end 360 | 361 | # Attaches the file read from given IO. 362 | # 363 | # In this case, `file_name` argument is required. 364 | def attach(io : IO, file_name : String, mime_type : String? = nil) 365 | @attachments << Content::AttachmentFile.new(io, file_id: nil, file_name: file_name, mime_type: mime_type) 366 | end 367 | 368 | # Adds message resource file, such as images or stylesheets for the html message, from given file path. 369 | # 370 | # Almost same as `#attach` expect this require `cid` argument. 371 | def message_resource(file_path : String, cid : String, file_name : String? = nil, mime_type : String? = nil) 372 | raise EMail::Error::MessageError.new("CID #{cid} already exists.") if @body_resources.has_key?(cid) 373 | @body_resources[cid] = EMail::Content::AttachmentFile.new(file_path, file_id: cid, file_name: file_name, mime_type: mime_type) 374 | end 375 | 376 | # Adds message resource file, such as images or stylesheets for the html message, read from given IO. 377 | # 378 | # Almost same as `#attach` expect this require `cid` argument. 379 | def message_resource(io : IO, cid : String, file_name : String, mime_type : String? = nil) 380 | raise EMail::Error::MessageError.new("CID #{cid} already exists.") if @body_resources.has_key?(cid) 381 | @body_resources[cid] = EMail::Content::AttachmentFile.new(io, file_id: cid, file_name: file_name, mime_type: mime_type) 382 | end 383 | 384 | # Sets cuntome header you want to set to the message. 385 | def custom_header(name : String, value : String) 386 | normalized_name = name.downcase.gsub('-', '_') 387 | raise EMail::Error::MessageError.new("Mime-Version header is automatically set to 1.0, and cannot be overwritten.") if normalized_name == "mime_version" 388 | raise EMail::Error::MessageError.new("#{name} header must be set by using ##{normalized_name} method") if @preset_headers.keys.map(&.to_s).includes?(normalized_name) 389 | opt_hdr = EMail::Header::Unstructured.new(name) 390 | opt_hdr.set(value) 391 | @custom_headers << opt_hdr 392 | end 393 | 394 | # Removes all custom headers with specific name. 395 | def clear_custom_header(name : String) 396 | normalized_name = Header.normalize_name(name) 397 | @custom_headers.reject! { |opt_hdr| opt_hdr.name == normalized_name } 398 | end 399 | 400 | # :nodoc: 401 | def date(timestamp : Time) 402 | @preset_headers[:date].time = timestamp 403 | end 404 | 405 | # :nodoc: 406 | macro set_text(header_type) 407 | # Set **{{header_type.id.split("_").map(&.capitalize).join("-").id}}** header. 408 | def {{header_type.id}}(header_body : String) 409 | @preset_headers[{{header_type}}].set(header_body) 410 | end 411 | end 412 | 413 | set_text :subject 414 | set_text :message_id 415 | 416 | # :nodoc: 417 | macro set_address(header_type) 418 | # Sets email address to **{{header_type.id.split("_").map(&.capitalize).join("-").id}}** header. 419 | def {{header_type.id}}(mail_address : String, mailbox_name : String? = nil) 420 | @preset_headers[{{header_type}}].set(mail_address, mailbox_name) 421 | end 422 | 423 | # :ditto: 424 | def {{header_type.id}}(mail_address : EMail::Address) 425 | @preset_headers[{{header_type}}].set(mail_address) 426 | end 427 | end 428 | 429 | set_address :sender 430 | set_address :return_path 431 | 432 | # :nodoc: 433 | macro add_address(header_type) 434 | # Adds email address to **{{header_type.id.split("_").map(&.capitalize).join("-").id}}** header. 435 | # 436 | # Call this multiple times to set multiple addresses. 437 | def {{header_type.id}}(mail_address : String, mailbox_name : String? = nil) 438 | @preset_headers[{{header_type}}].add(mail_address, mailbox_name) 439 | end 440 | 441 | # :ditto: 442 | def {{header_type.id}}(mail_address : EMail::Address) 443 | @preset_headers[{{header_type}}].add(mail_address) 444 | end 445 | 446 | # Adds multiple email addresses to **{{header_type.id.split("_").map(&.capitalize).join("-").id}}** header at once. 447 | # 448 | # In this method, you cannot set mailbox name. 449 | def {{header_type.id}}(mail_addresses : Array(String)) 450 | mail_addresses.each do |mail_address| 451 | @preset_headers[{{header_type}}].add(mail_address) 452 | end 453 | end 454 | 455 | # Adds multiple email addresses to **{{header_type.id.split("_").map(&.capitalize).join("-").id}}** header at once. 456 | def {{header_type.id}}(mail_addresses : Array(EMail::Address)) 457 | mail_addresses.each do |mail_address| 458 | @preset_headers[{{header_type}}].add(mail_address) 459 | end 460 | end 461 | 462 | # Removes all email addresses from **{{header_type.id.split("_").map(&.capitalize).join("-").id}}** header. 463 | def clear_{{header_type.id}} 464 | @preset_headers[{{header_type}}].clear 465 | end 466 | 467 | end 468 | 469 | add_address :from 470 | add_address :to 471 | add_address :cc 472 | add_address :bcc 473 | add_address :reply_to 474 | end 475 | --------------------------------------------------------------------------------