diff --git a/.env b/.env deleted file mode 100644 index e611e02..0000000 --- a/.env +++ /dev/null @@ -1,6 +0,0 @@ -HOSTNAME=smtp.163.com -MAIL_USER=deno_smtp@163.com -MAIL_TO_USER=manyuanrong@qq.com -MAIL_PASS=HDZXIALWAWGFPYCO -PORT=465 -TLS=true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 9e605e5..49b6e67 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -8,27 +8,37 @@ assignees: mathe42 --- -**Describe the bug** + +## Describe the bug + A clear and concise description of what the bug is. -**To Reproduce** -Provide a code example (without your actual password etc.) make it as minimal as you can. +## To Reproduce + +Provide a code example (without your actual password etc.) make it as minimal as +you can. + +## Expected behavior -**Expected behavior** A clear and concise description of what you expected to happen. -**Logs** +## Logs + Provide the output of `deno --version` + ``` Put output here ``` -Provide the output of your code snippet (with console_debug set to true see https://github.com/EC-Nordbund/denomailer#configuring-your-client ) + +Provide the output of your code snippet (with console_debug set to true see +https://github.com/EC-Nordbund/denomailer#configuring-your-client ) ``` Put log here ``` -If and only if you have problems with TLS or STARTTLS please provide the output of the following commands: +If and only if you have problems with TLS or STARTTLS please provide the output +of the following commands: ``` # STARTTLS @@ -38,5 +48,7 @@ openssl s_client -debug -starttls smtp -crlf -connect your-host.de:25 openssl s_client -debug -crlf -connect your-host.de:25 ``` -**Additional context** -Add any other context about the problem here. Is there a older version you know where this was working? +## Additional context + +Add any other context about the problem here. Is there a older version you know +where this was working? diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 7cf3867..d497c70 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -7,14 +7,20 @@ assignees: mathe42 --- -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] +## Is your feature request related to a problem? Please describe. + +A clear and concise description of what the problem is. Ex. I'm always +frustrated when [...] + +## Describe the solution you'd like -**Describe the solution you'd like** A clear and concise description of what you want to happen. -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. +## Describe alternatives you've considered + +A clear and concise description of any alternative solutions or features you've +considered. + +## Additional context -**Additional context** Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/ci2.yml b/.github/workflows/ci2.yml new file mode 100644 index 0000000..7d9c5c4 --- /dev/null +++ b/.github/workflows/ci2.yml @@ -0,0 +1,19 @@ +name: test + +on: + push: + branches: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - uses: denoland/setup-deno@v1 + with: + deno-version: v1.x + - run: docker run -d -p 1080:1080 -p 1025:1025 reachfive/fake-smtp-server + - run: deno test --unstable -A ./test/e2e/basic.test.ts + - run: deno fmt --check + - run: deno lint diff --git a/LICENSE b/LICENSE index 7099986..6fc6dc8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License Copyright (c) 2019 EnokMan +Copyright (c) 2022 Sebastian Krüger (@mathe42) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 61d168a..f4c2628 100644 --- a/README.md +++ b/README.md @@ -1,182 +1,418 @@ -## Deno SMTP mail client +# Denomailer a SMTP-client for Deno -### IMPORTANT SECURITY INFORMATION +> This was forked from https://github.com/manyuanrong/deno-smtp but now is much +> more advanced! -PLEASE update to a version >= 0.8! 0.8 has a problem where malformed mails could -potentialy allow attackers to create a mail (with linebreaks) to send unwanted -SMTP commands. This could result in authentic phishing attacks! With no way for -the user to identify that this is a phishing mail! Or that this mail contains a -dangerous attachment! +## Quickstart with a simple example -Also make sure that Mails are sent one after the other as they can corrupt each -others data! +```ts +// please use this line and change the version to the latest version! +// import { SMTPClient } from 'https://deno.land/x/denomailer@x.x.x/mod.ts' +import { SMTPClient } from "https://deno.land/x/denomailer/mod.ts"; + +const client = new SMTPClient({ + connection: { + hostname: "smtp.example.com", + port: 465, + tls: true, + auth: { + username: "example", + password: "password", + }, + }, +}); -### Allowed Mail Formats +await client.send({ + from: "me@example.com", + to: "you@example.com", + subject: "example", + content: "...", + html: "

...

", +}); -A single Mail `mail@example.de` with a name `NAME` can be encoded in the -following ways: +await client.close(); +``` -1. `"name@example.de"` -2. `""` -3. `"NAME "` -4. `{mail: "name@example.de"}` -5. `{mail: "name@example.de", name: "NAME"}` +## Client -Where 1-3 is called a "MailString". +You can create a new client with +`const client = new SMTPClient(/* client options */)`. -Multiple Mails can be an Array of the above OR a object that maps names to mails -for example: +### Options -`{"P1": "p1@example.de", "P2": "p2@example.de"}` we call this a MailObject. +The only required option is `connection.hostname` but in most cases you want to +set `connection.,auth`. -For the fields +Here are the full options available: -1. `from`, `replyTo` we only allow a "MailString". -2. `to`, `cc`, `bcc` we allow a MailObject a Array of single Mails or a single - Mail. +```ts +export interface ClientOptions { + debug?: { + /** + * USE WITH COUTION AS THIS WILL LOG YOUR USERDATA AND ALL MAIL CONTENT TO STDOUT! + * @default false + */ + log?: boolean; + /** + * USE WITH COUTION AS THIS WILL POSIBLY EXPOSE YOUR USERDATA AND ALL MAIL CONTENT TO ATTACKERS! + * @default false + */ + allowUnsecure?: boolean; + /** + * USE WITH COUTION AS THIS COULD INTODRUCE BUGS + * + * This option is mainly to allow debuging to exclude some possible problem surfaces at encoding + * @default false + */ + encodeLB?: boolean; + /** + * Disable starttls + */ + noStartTLS?: boolean; + }; + connection: { + hostname: string; + /** + * For TLS the default port is 465 else 25. + * @default 25 or 465 + */ + port?: number; + /** + * authentication data + */ + auth?: { + username: string; + password: string; + }; + /** + * Set this to `true` to connect via SSL / TLS if set to `false` STARTTLS is used. + * Only if `allowUnsecure` is used userdata or mail content could be exposed! + * + * @default false + */ + tls?: boolean; + }; + /** + * Create multiple connections so you can send emails faster! + */ + pool?: { + /** + * Number of Workers + * @default 2 + */ + size?: number; + /** + * Time the connection has to be idle to be closed. (in ms) + * If a value > 1h is set it will be set to 1h + * @default 60000 + */ + timeout?: number; + } | boolean; + client?: { + /** + * There are some cases where warnings are created. These are loged by default but you can 'ignore' them or all warnings should be considered 'error'. + * + * @default log + */ + warning?: "ignore" | "log" | "error"; + /** + * List of preproccessors to + * + * - Filter mail + * - BCC all mails to someone + * - ... + */ + preprocessors?: Preprocessor[]; + }; +} +``` -### Sending multiple mails +#### connection -Note that for race-condition reasons we can't send multiple mails at once. -Because of that if send is already called and still processing a mail -`client.send` will queue that sending. +You have to set the hostname and in most cases you need the auth object as +(near) all SMTP-Server will require a login. -### Example +> The only usecase where you might not need it is if you connect to a server +> with IP protection so for example only local IP are allowed so a server +> application can send mails without login. -```ts -import { SmtpClient, quotedPrintableEncode } from "https://deno.land/x/denomailer/mod.ts"; +Denomailer supports 3 security modes: -const client = new SmtpClient(); +1. TLS +2. STARTTLS +3. unsecure -await client.connect({ - hostname: "smtp.163.com", - port: 25, - username: "username", - password: "password", -}); +You have to specify wich to use. As unsecure you have to set extra config +options in `debug` as it is not rcomended! -await client.send({ - from: "mailaddress@163.com", - to: "Me ", - cc: [ - "name@example.de", - "", - "NAME ", - {mail: "name@example.de"}, - {mail: "name@example.de", name: "NAME"} - ], - bcc: { - "Me": "to-address@xx.com" - }, - subject: "Mail Title", - content: "Mail Content", - html: "Github", - date: "12 Mar 2022 10:38:05 GMT", - priority: "high", - replyTo: 'mailaddress@163.com', - attachments: [ - { encoding: "text"; content: 'Hi', contentType: 'text/plain', filename: 'text.txt' }, - { encoding: "base64"; content: '45dasjZ==', contentType: 'image/png', filename: 'img.png' }, - { - content: new Uint8Array([0,244,123]), - encoding: "binary", - contentType: 'image/jpeg', - filename: 'bin.png' - } - ], - mimeContent: [ - { - mimeType: 'application/markdown', - content: quotedPrintableEncode('# Title\n\nHello World!'), - transferEncoding: 'quoted-printable' - } - ] - -}); +For TLS set `tls: true` for startTLS set `tls: false` (or don't set it). -await client.close(); -``` +#### client -#### TLS connection +There are some "problems" that can be warnings or errors. With the `warning` +option you censpecify if they should `'error'` or you can `'ignore'` them or +`'log'` them. Default is `'log'`. This includes filtering invalid emails and +custom preprocessors + +With the `preprocessors` option you can add handlers that modify each +mail-config. For example you can add a filter so you don't send E-Mails to +burner mails etc. + +A preprocessor is of the type: ```ts -await client.connectTLS({ - hostname: "smtp.163.com", - port: 465, - username: "username", - password: "password", -}); +type Preprocessor = ( + mail: ResolvedSendConfig, + client: ResolvedClientOptions, +) => ResolvedSendConfig; ``` -#### Use in Gmail +It gets a preproccesed E-Mail config (ResolvedSendConfig) and the preprocessed +client options (ResolvedClientOptions) and has to return a (maybe modified) +E-Mail config. + +#### pool + +With a normal SMTP-Client the E-Mails are send one after the other so if you +have a heavy load you might want to use more Clients at once for that we have a +pool option. + +You can set the amount of clients used (`size`) and a timeout (in ms) after that +the connection is closed (`timeout`). + +Note that for each connection we create a new Worker and the used worker syntax +requires (as of deno 1.21) to use the `--unstable` flag. Because of that this +API has to be considered unstable but we don't expect a change! (At least for +the public denomailer api internaly there might be some small changes that +require specific deno versions) + +When you close the connection all worker are just killed. + +#### debug + +Sometimes you have specific needs for example we require encrypted connections +to send E-Mails and authentication. To enable unsecure connections set the +`allowUnsecure` option to `true` depending on your needs you have to disable +startTLS to get an unsecure connection use `noStartTLS` for this. + +Note that we only use this in tests where the SMTP-Server is local and doesn't +support TLS. + +If you want to get a full connection log use the `log` option. If you create an +issue for a bug please add the full log (but remove your authentication data +wich is encoded in base64). + +In some cases you might get problems with Linebreaks in emails before creating +an issue please try `encodeLB: true` that changes the encoding a little and this +might solve your problem. Please create an issue if you need this option! + +### Examples ```ts -await client.connectTLS({ - hostname: "smtp.gmail.com", - port: 465, - username: "your username", - password: "your password", +const client = new SMTPClient({ + connection: { + hostname: "smtp.example.com", + port: 465, + tls: true, + auth: { + username: "example", + password: "password", + }, + }, + pool: { + size: 2, + timeout: 60000, + }, + client: { + warning: "log", + preprocessors: [filterBurnerMails], + }, + debug: { + log: false, + allowUnsecure: false, + encodeLB: false, + noStartTLS: false, + }, }); +``` -await client.send({ - from: "someone@163.com", // Your Email address - to: "someone@xx.com", // Email address of the destination - subject: "Mail Title", - content: "Mail Content,maybe HTML", -}); +## Sending Mails -await client.close(); +Just call `client.send(/* mail config */)` + +### Config + +The config you can set: + +```ts +export interface SendConfig { + to: mailList; + cc?: mailList; + bcc?: mailList; + from: string; + date?: string; + subject: string; + content?: string; + mimeContent?: Content[]; + html?: string; + inReplyTo?: string; + replyTo?: string; + references?: string; + priority?: "high" | "normal" | "low"; + attachments?: Attachment[]; + /** + * type of mail for example `registration` or `newsletter` etc. + * allowes preprocessors to hande different email types + */ + internalTag?: string | symbol; +} ``` -### Filter E-Mails -If you want a custom E-Mail validator and filter some E-Mails (because they are burner mails or the domain is on a blacklist or only allow specific domains etc.) you can add the `mailFilter` option to the smtp-client constructor options. `mailFilter` takes a function that gets 3 Arguments the "mailbox" (all that is before @ in the mail), the "domain" (what is after the @) and `internalTag` that is a new option that can be set in the mailConfig so you can set a type for that mail for example type `newsletter` etc. `internalTag` can be a `string` or a `symbol`. +All of it should be clear by name except: -The filter function returns a boolean or a Promise that resolves to a boolean. There are 3 things you can do when this function is called: +#### mimeContent -1. return `true` the E-Mail is keept in the list -2. return `false` the E-Mail is removed from the list -3. throw an Error the E-Mail is aborted and never send +There are use cases where you want to do encoding etc. on your own. This option +allowes you to specify the content of the mail. -So you can decide if a single mail error results in a complete mail abort or it only get removed from the list. +#### content & html -You can for example validate against this list: https://github.com/wesbos/burner-email-providers. +The content should be a plain-text version of the html content you can set +`content` to `'auto'` to generate that by denomailer but in most cases you +should do it on your own! +#### attachments -### Configuring your client +A array of attachments you can encode it as base64, text, binary. Note that +base64 is converted to binary and only there for a better API. So don't encode +your binary files as base64 so denomailer can convert it back to binary. -You can pass options to your client through the `SmtpClient` constructor. +#### internalTag -```ts -import { SmtpClient } from "https://deno.land/x/denomailer/mod.ts"; +This can be used with preprocessors so you can give a mail a type for example +`'registration'`, `'newsletter'` etc. supports symbols and strings. + +### Allowed Mail Formats + +A single Mail `mail@example.de` with a name `NAME` can be encoded in the +following ways: + +1. `"name@example.de"` +2. `""` +3. `"NAME "` +4. `{mail: "name@example.de"}` +5. `{mail: "name@example.de", name: "NAME"}` + +Multiple Mails can be an Array of the above OR a object that maps names to mails +for example: + +`{"P1": "p1@example.de", "P2": "p2@example.de"}` we call this a MailObject. + +For the fields -//Defaults -const client = new SmtpClient({ - console_debug: true, // enable debugging this is good while developing should be false in production as Authentication IS LOGGED TO CONSOLE! - unsecure: true, // allow unsecure connection to send authentication IN PLAIN TEXT and also mail content! +1. `from`, `replyTo` we only allow a single mail string. +2. `to`, `cc`, `bcc` we allow a MailObject a Array of single Mails (you can mix + formats) or a single Mail. + +### Examples + +Example with near all options: + +```ts +client.send({ + to: "abc@example.com", + cc: [ + "abc@example.com", + "abc ", + { + name: "abc", + mail: "abc@example.com", + }, + ], + bcc: { + abc: "abc@example.com", + other: "abc@example.com", + }, + from: "me ", + replyTo: "", + subject: "example", + content: "auto", + html: "

Hello World

", + internalTag: "newsletter", + priority: "low", }); ``` -## Pool, Worker -> This is unstable API may change! This requires deno to run in unstable mode. +## Other exports -Adds 2 new classes `SMTPWorker` and `SMTPWorkerPool` (for constructor options see code for now). This creates a SMTP client (or multiple) that get automaticly killed if the connection is not used for around 60s. +We export our implementation of an quotedPrintable encoder. There might be some +use cases where you need it. The api of the function is considered stable! -## TLS issues -When getting TLS errors make shure: -1. you use the correct port (mostly 25, 587, 465) -2. the server supports STARTTLS when using `client.connect` -3. the server supports TLS when using `client.connectTLS` -4. Use the command `openssl s_client -debug -starttls smtp -crlf -connect your-host.de:587` or `openssl s_client -debug -crlf -connect your-host.de:587` and get the used cipher this should be a cipher with "forward secrecy". Check the status of the cipher on https://ciphersuite.info/cs/ . If the cipher is not STRONG this is an issue with your mail provider so you have to contact them to fix it. -5. Feel free to create issues if you are ok with that share the port and host so a proper debug can be done. -6. We can only support TLS where Deno supports it and Deno uses rustls wich explicitly not implemented some "weak" ciphers. +## Stable api +All api exported by `/mod.ts` is considered stable. But as the `pool` need the +`--unstable` flag by Deno this has to be considered unstable. But we don't +expect any breaking changes there - but if deno changes sytax etc. a new deno +release can break it! -### Non SpecCompliant SMTP-Server -There are some SMTP-Server that don't follow the spec to 100%. This can result in unexpected errors in denomailer. If this happens (for example in https://github.com/EC-Nordbund/denomailer/blob/03a66a6f9a4b5f349ea35856f5903fb45fd0cc5f/smtp.ts#L376 the server sends a 250) please create an issue. We will try and do the following: +Changes to them will only be made if a new major is released. -1. Check if it is not an error in denomailer -2. Try to fix it at the SMTP-Server side (create an issue if the server is an opensource project etc.) -3. We will add a ***temporary*** workaround by changing denomailer. This will include log messages telling the developer (if the workaround is used) that denomailer used the workaround wich can be removed at any time. +## Contribute +Feel free to contribute by: +1. creating issues for bugs and feature requests (note that you have to use the + bug template to get support) +2. contribute code but keep in mind + - for small changes you can just create a PR + - for bigger changes please create an issue before! This will help to reduce + time creating PR that are not merged. + - if you fix a bug please add a test that fails before your fix +3. contribute tests, fix typos, ... +## TLS issues + +When getting TLS errors make sure: + +1. you use the correct port (mostly 25, 587, 465) +2. the server supports STARTTLS when using `connection.tls = false` +3. the server supports TLS when using `connection.tls = true` +4. Use the command + `openssl s_client -debug -starttls smtp -crlf -connect your-host.de:587` or + `openssl s_client -debug -crlf -connect your-host.de:587` and get the used + cipher this should be a cipher with "forward secrecy". Check the status of + the cipher on https://ciphersuite.info/cs/ . If the cipher is not STRONG this + is an issue with your mail provider so you have to contact them to fix it. +5. Feel free to create issues if you are ok with that share the port and host so + a proper debug can be done. +6. We can only support TLS where Deno supports it and Deno uses rustls wich + explicitly not implemented some "weak" ciphers. + +## Non SpecCompliant SMTP-Server + +There are some SMTP-Server that don't follow the spec to 100%. This can result +in unexpected errors in denomailer. If this happens (for example in +https://github.com/EC-Nordbund/denomailer/blob/03a66a6f9a4b5f349ea35856f5903fb45fd0cc5f/smtp.ts#L376 +the server sends a 250) please create an issue. We will try and do the +following: + +1. Check if it is not an error in denomailer +2. Try to fix it at the SMTP-Server side (create an issue if the server is an + opensource project etc.) +3. We will add a _**temporary**_ workaround by changing denomailer. This will + include log messages telling the developer (if the workaround is used) that + denomailer used the workaround wich can be removed at any time. + +## Breaking changes + +### v0.x -> v1.0 + +1. Import `SMTPClient` not `SmtpClient` +2. Change the constructor options (include the options used with + `client.connect` or `client.connectTLS` (add `tls = true` in the second + case)) +3. Remove `client.connect` and `client.connectTLS` calls +4. filterMail option was removed in favor of new preprocessor option +5. The `client.send` method did not have any breaking changes. +6. Some internal fields where removed from `SMTPClient` only use `send` and + `close`! But in 99% of project these where not used diff --git a/SECURITY.md b/SECURITY.md index 60bcebc..1a96881 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,12 +6,17 @@ We provide security updates currently for these version. | Version | Supported | | ------- | ------------------ | -| > 0.10.x | :white_check_mark: | +| 1.x | :white_check_mark: | +| 0.x | :white_check_mark: | -We will provide security updates after 1.0 only for the latest version of that major. -We will deprecate major versions so you should always upgrade! Note that major versions will contains breaking changes so we will add security updates for at least 1 older major so you have time to upgrade. But note that we will deprecate these versions too! +We will provide security updates after 1.0 only for the latest version of that +major. We will deprecate major versions so you should always upgrade! Note that +major versions will contains breaking changes so we will add security updates +for at least 1 older major so you have time to upgrade. But note that we will +deprecate these versions too! ## Reporting a Vulnerability -If you find a Vulnerability or a potential Vulnerability please send a mail to `app@ec-nordbund.de` and explain the problem. -This don't has to be a full exploit! +If you find a Vulnerability or a potential Vulnerability please send a mail to +`app@ec-nordbund.de` and explain the problem. This don't has to be a full +exploit! diff --git a/client/basic/client.ts b/client/basic/client.ts new file mode 100644 index 0000000..fada1d0 --- /dev/null +++ b/client/basic/client.ts @@ -0,0 +1,379 @@ +import type { ResolvedSendConfig } from "../../config/mail/mod.ts"; +import { ResolvedClientOptions } from "../../config/client.ts"; +import { SMTPConnection } from "./connection.ts"; + +const CommandCode = { + READY: 220, + AUTHO_SUCCESS: 235, + OK: 250, + BEGIN_DATA: 354, + FAIL: 554, +}; + +class QUE { + running = false; + #que: (() => void)[] = []; + idle: Promise = Promise.resolve(); + #idbleCB?: () => void; + + que(): Promise { + if (!this.running) { + this.running = true; + this.idle = new Promise((res) => { + this.#idbleCB = res; + }); + return Promise.resolve(); + } + + return new Promise((res) => { + this.#que.push(res); + }); + } + + next() { + if (this.#que.length === 0) { + this.running = false; + if (this.#idbleCB) this.#idbleCB(); + return; + } + + this.#que[0](); + this.#que.splice(0, 1); + } +} + +export class SMTPClient { + #connection: SMTPConnection; + #que = new QUE(); + + constructor(private config: ResolvedClientOptions) { + const c = new SMTPConnection(config); + this.#connection = c; + + this.#ready = (async () => { + await c.ready; + await this.#prepareConnection(); + })(); + } + + #ready: Promise; + + close() { + return this.#connection.close(); + } + + get isSending() { + return this.#que.running; + } + + get idle() { + return this.#que.idle; + } + + async send(config: ResolvedSendConfig) { + await this.#ready; + try { + await this.#que.que(); + + await this.#connection.writeCmd("MAIL", "FROM:", `<${config.from.mail}>`); + this.#connection.assertCode( + await this.#connection.readCmd(), + CommandCode.OK, + ); + + for (let i = 0; i < config.to.length; i++) { + await this.#connection.writeCmd( + "RCPT", + "TO:", + `<${config.to[i].mail}>`, + ); + this.#connection.assertCode( + await this.#connection.readCmd(), + CommandCode.OK, + ); + } + + for (let i = 0; i < config.cc.length; i++) { + await this.#connection.writeCmd( + "RCPT", + "TO:", + `<${config.cc[i].mail}>`, + ); + this.#connection.assertCode( + await this.#connection.readCmd(), + CommandCode.OK, + ); + } + + for (let i = 0; i < config.bcc.length; i++) { + await this.#connection.writeCmd( + "RCPT", + "TO:", + `<${config.bcc[i].mail}>`, + ); + this.#connection.assertCode( + await this.#connection.readCmd(), + CommandCode.OK, + ); + } + + await this.#connection.writeCmd("DATA"); + this.#connection.assertCode( + await this.#connection.readCmd(), + CommandCode.BEGIN_DATA, + ); + + await this.#connection.writeCmd("Subject: ", config.subject); + await this.#connection.writeCmd( + "From: ", + `${config.from.name} <${config.from.mail}>`, + ); + if (config.to.length > 0) { + await this.#connection.writeCmd( + "To: ", + config.to.map((m) => `${m.name} <${m.mail}>`).join(";"), + ); + } + if (config.cc.length > 0) { + await this.#connection.writeCmd( + "Cc: ", + config.cc.map((m) => `${m.name} <${m.mail}>`).join(";"), + ); + } + + await this.#connection.writeCmd("Date: ", config.date); + + if (config.inReplyTo) { + await this.#connection.writeCmd("InReplyTo: ", config.inReplyTo); + } + + if (config.references) { + await this.#connection.writeCmd("Refrences: ", config.references); + } + + if (config.replyTo) { + await this.#connection.writeCmd( + "ReplyTo: ", + `${config.replyTo.name} <${config.replyTo.name}>`, + ); + } + + if (config.priority) { + await this.#connection.writeCmd("Priority:", config.priority); + } + + await this.#connection.writeCmd("MIME-Version: 1.0"); + + let boundaryAdditionAtt = 100; + // calc msg boundary + // TODO: replace this with a match or so. + config.mimeContent.map((v) => v.content).join("\n").replace( + new RegExp("--attachment([0-9]+)", "g"), + (_, numb) => { + boundaryAdditionAtt += parseInt(numb, 10); + + return ""; + }, + ); + + const dec = new TextDecoder(); + + config.attachments.map((v) => { + if (v.encoding === "text") return v.content; + + const arr = new Uint8Array(v.content); + + return dec.decode(arr); + }).join("\n").replace( + new RegExp("--attachment([0-9]+)", "g"), + (_, numb) => { + boundaryAdditionAtt += parseInt(numb, 10); + + return ""; + }, + ); + + const attachmentBoundary = `attachment${boundaryAdditionAtt}`; + + await this.#connection.writeCmd( + `Content-Type: multipart/mixed; boundary=${attachmentBoundary}`, + "\r\n", + ); + await this.#connection.writeCmd(`--${attachmentBoundary}`); + + let boundaryAddition = 100; + // calc msg boundary + // TODO: replace this with a match or so. + config.mimeContent.map((v) => v.content).join("\n").replace( + new RegExp("--message([0-9]+)", "g"), + (_, numb) => { + boundaryAddition += parseInt(numb, 10); + + return ""; + }, + ); + + const messageBoundary = `message${boundaryAddition}`; + + await this.#connection.writeCmd( + `Content-Type: multipart/alternative; boundary=${messageBoundary}`, + "\r\n", + ); + + for (let i = 0; i < config.mimeContent.length; i++) { + await this.#connection.writeCmd(`--${messageBoundary}`); + await this.#connection.writeCmd( + "Content-Type: " + config.mimeContent[i].mimeType, + ); + if (config.mimeContent[i].transferEncoding) { + await this.#connection.writeCmd( + `Content-Transfer-Encoding: ${ + config.mimeContent[i].transferEncoding + }` + "\r\n", + ); + } else { + // Send new line + await this.#connection.writeCmd(""); + } + + await this.#connection.writeCmd(config.mimeContent[i].content, "\r\n"); + } + + await this.#connection.writeCmd(`--${messageBoundary}--\r\n`); + + for (let i = 0; i < config.attachments.length; i++) { + const attachment = config.attachments[i]; + + await this.#connection.writeCmd(`--${attachmentBoundary}`); + await this.#connection.writeCmd( + "Content-Type:", + attachment.contentType + ";", + "name=" + attachment.filename, + ); + + await this.#connection.writeCmd( + "Content-Disposition: attachment; filename=" + attachment.filename, + "\r\n", + ); + + if (attachment.encoding === "binary") { + await this.#connection.writeCmd("Content-Transfer-Encoding: binary"); + + if ( + attachment.content instanceof ArrayBuffer || + attachment.content instanceof SharedArrayBuffer + ) { + await this.#connection.writeCmdBinary( + new Uint8Array(attachment.content), + ); + } else { + await this.#connection.writeCmdBinary(attachment.content); + } + + await this.#connection.writeCmd("\r\n"); + } else if (attachment.encoding === "text") { + await this.#connection.writeCmd( + "Content-Transfer-Encoding: quoted-printable", + ); + + await this.#connection.writeCmd(attachment.content, "\r\n"); + } + } + + await this.#connection.writeCmd(`--${attachmentBoundary}--\r\n`); + + await this.#connection.writeCmd(".\r\n"); + + this.#connection.assertCode( + await this.#connection.readCmd(), + CommandCode.OK, + ); + await this.#cleanup(); + this.#que.next(); + } catch (ex) { + await this.#cleanup(); + this.#que.next(); + throw ex; + } + } + + async #prepareConnection() { + this.#connection.assertCode( + await this.#connection.readCmd(), + CommandCode.READY, + ); + + await this.#connection.writeCmd("EHLO", this.config.connection.hostname); + + const cmd = await this.#connection.readCmd(); + + if (!cmd) throw new Error("Unexpected empty response"); + + if (typeof cmd.args === "string") { + this.#supportedFeatures.add(cmd.args); + } else { + cmd.args.forEach((cmd) => { + this.#supportedFeatures.add(cmd); + }); + } + + if ( + this.#supportedFeatures.has("STARTTLS") && !this.config.debug.noStartTLS + ) { + await this.#connection.writeCmd("STARTTLS"); + this.#connection.assertCode( + await this.#connection.readCmd(), + CommandCode.READY, + ); + + const conn = await Deno.startTls(this.#connection.conn!, { + hostname: this.config.connection.hostname, + }); + this.#connection.setupConnection(conn); + this.#connection.secure = true; + + await this.#connection.writeCmd("EHLO", this.config.connection.hostname); + + await this.#connection.readCmd(); + } + + if (!this.config.debug.allowUnsecure && !this.#connection.secure) { + this.#connection.close(); + this.#connection = null as unknown as SMTPConnection; + throw new Error( + "Connection is not secure! Don't send authentication over non secure connection!", + ); + } + + if (this.config.connection.auth) { + await this.#connection.writeCmd("AUTH", "LOGIN"); + this.#connection.assertCode(await this.#connection.readCmd(), 334); + + await this.#connection.writeCmd( + btoa(this.config.connection.auth.username), + ); + this.#connection.assertCode(await this.#connection.readCmd(), 334); + + await this.#connection.writeCmd( + btoa(this.config.connection.auth.password), + ); + this.#connection.assertCode( + await this.#connection.readCmd(), + CommandCode.AUTHO_SUCCESS, + ); + } + + await this.#cleanup(); + } + + #supportedFeatures = new Set(); + + async #cleanup() { + this.#connection.writeCmd("NOOP"); + + while (true) { + const cmd = await this.#connection.readCmd(); + if (cmd && cmd.code === 250) return; + } + } +} diff --git a/client/basic/connection.ts b/client/basic/connection.ts new file mode 100644 index 0000000..12306f6 --- /dev/null +++ b/client/basic/connection.ts @@ -0,0 +1,125 @@ +import { BufReader, BufWriter, TextProtoReader } from "../../deps.ts"; +import { ResolvedClientOptions } from "../../config/client.ts"; + +const encoder = new TextEncoder(); + +interface Command { + code: number; + args: string | (string[]); +} + +export class SMTPConnection { + secure = false; + + conn: Deno.Conn | null = null; + #reader: TextProtoReader | null = null; + #writer: BufWriter | null = null; + + constructor(private config: ResolvedClientOptions) { + this.ready = this.#connect(); + } + + ready: Promise; + + async close() { + if (!this.conn) { + return; + } + await this.conn.close(); + } + + setupConnection(conn: Deno.Conn) { + this.conn = conn; + + const reader = new BufReader(this.conn); + this.#writer = new BufWriter(this.conn); + this.#reader = new TextProtoReader(reader); + } + + async #connect() { + if (this.config.connection.tls) { + this.conn = await Deno.connectTls({ + hostname: this.config.connection.hostname, + port: this.config.connection.port, + }); + this.secure = true; + } else { + this.conn = await Deno.connect({ + hostname: this.config.connection.hostname, + port: this.config.connection.port, + }); + } + + this.setupConnection(this.conn); + } + + public assertCode(cmd: Command | null, code: number, msg?: string) { + if (!cmd) { + throw new Error(`invalid cmd`); + } + if (cmd.code !== code) { + throw new Error(msg || cmd.code + ": " + cmd.args); + } + } + + public async readCmd(): Promise { + if (!this.#reader) { + return null; + } + + const result: (string | null)[] = []; + + while ( + result.length === 0 || (result.at(-1) && result.at(-1)!.at(3) === "-") + ) { + result.push(await this.#reader.readLine()); + } + + const nonNullResult: string[] = + (result.at(-1) === null ? result.slice(0, result.length - 1) : // deno-lint-ignore no-explicit-any + result) as any; + + if (nonNullResult.length === 0) return null; + + const code = parseInt(nonNullResult[0].slice(0, 3)); + const data = nonNullResult.map((v) => v.slice(4).trim()); + + if (this.config.debug.log) { + nonNullResult.forEach((v) => console.log(v)); + } + + return { + code, + args: data, + }; + } + + public async writeCmd(...args: string[]) { + if (!this.#writer) { + return null; + } + + if (this.config.debug.log) { + console.table(args); + } + + const data = encoder.encode([...args].join(" ") + "\r\n"); + await this.#writer.write(data); + await this.#writer.flush(); + } + + public async writeCmdBinary(...args: Uint8Array[]) { + if (!this.#writer) { + return null; + } + + if (this.config.debug.log) { + console.table(args.map(() => "Uint8Attay")); + } + + for (let i = 0; i < args.length; i++) { + await this.#writer.write(args[i]); + } + await this.#writer.flush(); + } +} diff --git a/client/mod.ts b/client/mod.ts new file mode 100644 index 0000000..add86ed --- /dev/null +++ b/client/mod.ts @@ -0,0 +1,155 @@ +import { SMTPWorkerPool } from "./pool.ts"; +import { SMTPWorker } from "./worker/worker.ts"; +import { SMTPClient } from "./basic/client.ts"; +import { + ClientOptions, + resolveClientOptions, + ResolvedClientOptions, +} from "../config/client.ts"; +import { + resolveSendConfig, + SendConfig, + validateConfig, +} from "../config/mail/mod.ts"; + +/** + * SMTP-Client with support for pool, etc. + * ```ts + * const client = new SMTPClient({ + * connection: { + * hostname: "smtp.example.com", + * port: 465, + * tls: true, + * auth: { + * username: "example", + * password: "password", + * }, + * }, + * }); + * + * await client.send({ + * from: "me@example.com", + * to: "you@example.com", + * subject: "example", + * content: "...", + * html: "

...

", + * }); + * + * await client.close(); + * ``` + */ +export class SMTPHandler { + #internalClient: SMTPWorker | SMTPWorkerPool | SMTPClient; + #clientConfig: ResolvedClientOptions; + + /** + * create a new SMTPClient + * + * ```ts + * const client = new SMTPClient({ + * connection: { + * hostname: "smtp.example.com", + * port: 465, + * tls: true, + * auth: { + * username: "example", + * password: "password", + * }, + * }, + * pool: { + * size: 2, + * timeout: 60000 + * }, + * client: { + * warning: 'log', + * preprocessors: [filterBurnerMails] + * }, + * debug: { + * log: false, + * allowUnsecure: false, + * encodeLB: false, + * noStartTLS: false + * } + * }); + * + * ``` + * + * @param config client config + */ + constructor(config: ClientOptions) { + const resolvedConfig = resolveClientOptions(config); + + resolvedConfig.client.preprocessors.push(validateConfig); + + this.#clientConfig = resolvedConfig; + + if (resolvedConfig.debug.log) { + console.log("used resolved config"); + console.log(".debug"); + console.table(resolvedConfig.debug); + console.log(".connection"); + console.table({ + ...resolvedConfig.connection, + ...resolvedConfig.connection.auth + ? { auth: JSON.stringify(resolvedConfig.connection.auth) } + : {}, + }); + console.log(".pool"); + console.table(resolvedConfig.pool); + } + + const Client = resolvedConfig.pool + ? (resolvedConfig.pool.size > 1 ? SMTPWorkerPool : SMTPWorker) + : SMTPClient; + + this.#internalClient = new Client(resolvedConfig); + } + + /** + * Sends a E-Mail with the correspondig config. + * ```ts + * client.send({ + * to: 'abc@example.com', + * cc: [ + * 'abc@example.com', + * 'abc ', + * { + * name: 'abc', + * mail: 'abc@example.com' + * } + * ], + * bcc: { + * abc: 'abc@example.com', + * other: 'abc@example.com' + * }, + * from: 'me ', + * replyTo: '', + * subject: 'example', + * content: 'auto', + * html: '

Hello World

', + * internalTag: 'newsletter', + * priority: 'low' + * }) + * ``` + * @param config Email config + * @returns nothing (for now as this might change in the future!) + */ + send(config: SendConfig): Promise { + let resolvedConfig = resolveSendConfig(config); + + for (let i = 0; i < this.#clientConfig.client.preprocessors.length; i++) { + const cb = this.#clientConfig.client.preprocessors[i]; + + resolvedConfig = cb(resolvedConfig, this.#clientConfig); + } + + return this.#internalClient.send(resolvedConfig); + } + + /** + * Closes the connection (kills all Worker / closes connection) + */ + close(): void | Promise { + return this.#internalClient.close(); + } +} diff --git a/client/pool.ts b/client/pool.ts new file mode 100644 index 0000000..b64aa5a --- /dev/null +++ b/client/pool.ts @@ -0,0 +1,27 @@ +import { ResolvedSendConfig } from "../config/mail/mod.ts"; +import { ResolvedClientOptions } from "../config/client.ts"; +import { SMTPWorker } from "./worker/worker.ts"; + +export class SMTPWorkerPool { + pool: SMTPWorker[] = []; + + constructor( + config: ResolvedClientOptions, + ) { + for (let i = 0; i < config.pool!.size; i++) { + this.pool.push(new SMTPWorker(config)); + } + } + + #lastUsed = -1; + + send(mail: ResolvedSendConfig) { + this.#lastUsed = (this.#lastUsed + 1) % this.pool.length; + + return this.pool[this.#lastUsed].send(mail); + } + + close() { + this.pool.forEach((v) => v.close()); + } +} diff --git a/worker.ts b/client/worker/worker-file.ts similarity index 52% rename from worker.ts rename to client/worker/worker-file.ts index 00abb78..1ec749e 100644 --- a/worker.ts +++ b/client/worker/worker-file.ts @@ -2,10 +2,10 @@ /// /// -import { SmtpClient } from "./smtp.ts"; -import { SendConfig } from "./config.ts"; +import { SMTPClient } from "../basic/client.ts"; +import { ResolvedSendConfig } from "../../config/mail/mod.ts"; -const client = new SmtpClient({ console_debug: true }); +let client: SMTPClient; let cb: () => void; const readyPromise = new Promise((res) => { @@ -14,7 +14,7 @@ const readyPromise = new Promise((res) => { let hasIdlePromise = false; -async function send(config: SendConfig) { +async function send(config: ResolvedSendConfig) { client.send(config); if (!hasIdlePromise) { @@ -27,7 +27,7 @@ async function send(config: SendConfig) { addEventListener("message", async (ev: MessageEvent) => { if (ev.data.__setup) { - await client.connectTLS(ev.data.__setup); + client = new SMTPClient(ev.data.__setup); cb(); return; } @@ -35,6 +35,20 @@ addEventListener("message", async (ev: MessageEvent) => { postMessage(client.isSending); return; } - await readyPromise; - send(ev.data); + + if (ev.data.__mail) { + await readyPromise; + try { + const data = await send(ev.data.mail); + postMessage({ + __ret: ev.data.__mail, + res: data, + }); + } catch (ex) { + postMessage({ + __ret: ev.data.__mail, + err: ex, + }); + } + } }); diff --git a/client/worker/worker.ts b/client/worker/worker.ts new file mode 100644 index 0000000..73cd544 --- /dev/null +++ b/client/worker/worker.ts @@ -0,0 +1,134 @@ +import { ResolvedSendConfig } from "../../config/mail/mod.ts"; +import { ResolvedClientOptions } from "../../config/client.ts"; + +export class SMTPWorker { + id = 1; + #timeout: number; + + constructor( + config: ResolvedClientOptions, + ) { + this.#config = config; + this.#timeout = config.pool!.timeout; + } + #w!: Worker; + #idleTO: number | null = null; + #idleMode2 = false; + #noCon = true; + #config: ResolvedClientOptions; + + #resolver = new Map< + number, + // deno-lint-ignore no-explicit-any + { res: (res: any) => void; rej: (err: Error) => void } + >(); + + #startup() { + this.#w = new Worker(new URL("./worker-file.ts", import.meta.url), { + type: "module", + deno: { + permissions: { + net: "inherit", + // ts files + read: true, + }, + namespace: true, + }, + // This allowes the deno option so only for pool and worker we need --unstable + // deno-lint-ignore no-explicit-any + } as any); + + this.#w.addEventListener( + "message", + // deno-lint-ignore no-explicit-any + (ev: MessageEvent) => { + if (typeof ev.data === "object") { + if ("err" in ev.data) { + this.#resolver.get(ev.data.__ret)?.rej(ev.data.err); + } + + if ("res" in ev.data) { + this.#resolver.get(ev.data.__ret)?.res(ev.data.res); + } + + this.#resolver.delete(ev.data.__ret); + return; + } + + if (ev.data) { + this.#stopIdle(); + } else { + if (this.#idleMode2) { + this.#cleanup(); + } else { + this.#startIdle(); + } + } + }, + ); + + this.#w.postMessage({ + __setup: { + ...this.#config, + client: { + ...this.#config.client, + preprocessors: [], + }, + }, + }); + + this.#noCon = false; + } + + #startIdle() { + console.log("started idle"); + if (this.#idleTO) { + return; + } + + this.#idleTO = setTimeout(() => { + console.log("idle mod 2"); + this.#idleMode2 = true; + this.#w.postMessage({ __check_idle: true }); + }, this.#timeout); + } + + #stopIdle() { + if (this.#idleTO) { + clearTimeout(this.#idleTO); + } + + this.#idleMode2 = false; + this.#idleTO = null; + } + + #cleanup() { + console.log("killed"); + this.#w.terminate(); + this.#stopIdle(); + } + + public send(mail: ResolvedSendConfig) { + const myID = this.id; + this.id++; + this.#stopIdle(); + if (this.#noCon) { + this.#startup(); + } + this.#w.postMessage({ + __mail: myID, + mail, + }); + + return new Promise((res, rej) => { + this.#resolver.set(myID, { res, rej }); + }); + } + + close() { + if (this.#w) this.#w.terminate(); + if (this.#idleTO) { + clearTimeout(this.#idleTO); + } + } +} diff --git a/code.ts b/code.ts deleted file mode 100644 index 0407b5f..0000000 --- a/code.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const CommandCode = { - READY: 220, - AUTHO_SUCCESS: 235, - OK: 250, - BEGIN_DATA: 354, - FAIL: 554, -}; diff --git a/config.ts b/config.ts deleted file mode 100644 index b34dff0..0000000 --- a/config.ts +++ /dev/null @@ -1,161 +0,0 @@ -interface ConnectConfig { - hostname: string; - port?: number; -} - -interface ConnectConfigWithAuthentication extends ConnectConfig { - username: string; - password: string; -} - -interface SendConfig { - to: mailList; - cc?: mailList; - bcc?: mailList; - from: string; - date?: string; - subject: string; - content?: string; - mimeContent?: Content[]; - html?: string; - inReplyTo?: string; - replyTo?: string; - references?: string; - priority?: "high" | "normal" | "low"; - attachments?: attachment[]; - internalTag?: string | symbol -} - -interface baseAttachment { - contentType: string; - filename: string; -} - -type attachment = - & ( - | textAttachment - | base64Attachment - | arrayBufferLikeAttachment - ) - & baseAttachment; - -type textAttachment = { encoding: "text"; content: string }; -type base64Attachment = { encoding: "base64"; content: string }; -type arrayBufferLikeAttachment = { - content: ArrayBufferLike | Uint8Array; - encoding: "binary"; -}; - -interface Content { - mimeType: string; - content: string; - transferEncoding?: string; -} - -export type email = string; -export type emailWithName = string; -export type wrapedMail = string; - -export interface mailObject { - mail: string; - name?: string; -} - -export type mail = string | mailObject; -export type mailListObject = Omit, "name" | "mail">; -export type mailList = mailListObject | mail[] | mail; - -export type { ConnectConfig, ConnectConfigWithAuthentication, SendConfig }; - -function isMail(mail: string) { - return /[^<>()\[\]\\,;:\s@"]+@[a-zA-Z0-9]+\.([a-zA-Z0-9\-]+\.)*[a-zA-Z]{2,}$/ - .test(mail); -} - -function isSingleMail(mail: string) { - return /^(([^<>()\[\]\\,;:\s@"]+@[a-zA-Z0-9\-]+\.([a-zA-Z0-9\-]+\.)*[a-zA-Z]{2,})|(<[^<>()\[\]\\,;:\s@"]+@[a-zA-Z0-9]+\.([a-zA-Z0-9\-]+\.)*[a-zA-Z]{2,}>)|([^<>]+ <[^<>()\[\]\\,;:\s@"]+@[a-zA-Z0-9]+\.([a-zA-Z0-9\-]+\.)*[a-zA-Z]{2,}>))$/ - .test(mail); -} - -export function validateConfig(config: SendConfig) { - if (config.from) { - if (!isSingleMail(config.from)) { - throw new Error("Mail From is not a valid E-Mail."); - } - } - - if (!validateMailList(config.to)) { - throw new Error("Mail TO is not a valid E-Mail."); - } - - if (config.cc && !validateMailList(config.cc)) { - throw new Error("Mail CC is not a valid E-Mail."); - } - if (config.bcc && !validateMailList(config.bcc)) { - throw new Error("Mail BCC is not a valid E-Mail."); - } - - if (config.replyTo && !isSingleMail(config.replyTo)) { - throw new Error("Mail ReplyTo is not a valid E-Mail."); - } - - return true; -} - -function validateMailList(mailList: mailList) { - if (typeof mailList === "string") return isSingleMail(mailList); - - if (Array.isArray(mailList)) { - return !mailList.some((m) => { - if (typeof m === "string") return !isSingleMail(m); - return !isMail(m.mail); - }); - } - - if ( - (Object.keys(mailList).length === 1 && mailList.mail) || - (Object.keys(mailList).length === 2 && mailList.mail && mailList.name) - ) { - return isMail(mailList.mail); - } - - return !Object.values(mailList).some((m) => !isSingleMail(m)); -} - -export function normaliceMailString(mail: string) { - if (mail.includes("<")) { - return mail; - } else { - return `<${mail}>`; - } -} - -export function normaliceMailList( - mails?: mailList | null, -): string[] { - if (!mails) return []; - - if (typeof mails === "string") { - return [normaliceMailString(mails)]; - } else if (Array.isArray(mails)) { - return mails.map((m) => { - if (typeof m === "string") { - if (m.includes("<")) { - return m; - } else { - return `<${m}>`; - } - } else { - return m.name ? (`${m.name} <${m.mail}>`) : (`<${m.mail}>`); - } - }); - } else if (mails.mail) { - return [ - mails.name ? (`${mails.name} <${mails.mail}>`) : (`<${mails.mail}>`), - ]; - } else { - return Object.entries(mails as mailListObject).map( - ([name, mail]: [string, string]) => `${name} <${mail}>`, - ); - } -} diff --git a/config/client.ts b/config/client.ts new file mode 100644 index 0000000..6d5143d --- /dev/null +++ b/config/client.ts @@ -0,0 +1,149 @@ +import { ResolvedSendConfig } from "./mail/mod.ts"; + +export interface ResolvedClientOptions { + debug: { + log: boolean; + allowUnsecure: boolean; + encodeLB: boolean; + noStartTLS: boolean; + }; + connection: { + hostname: string; + port: number; + auth?: { + username: string; + password: string; + }; + tls: boolean; + }; + pool?: { + size: number; + timeout: number; + }; + client: { + warning: "ignore" | "log" | "error"; + preprocessors: Preprocessor[]; + }; +} + +/** + * Options to create a new SMTP CLient + */ +export interface ClientOptions { + debug?: { + /** + * USE WITH COUTION AS THIS WILL LOG YOUR USERDATA AND ALL MAIL CONTENT TO STDOUT! + * @default false + */ + log?: boolean; + /** + * USE WITH COUTION AS THIS WILL POSIBLY EXPOSE YOUR USERDATA AND ALL MAIL CONTENT TO ATTACKERS! + * @default false + */ + allowUnsecure?: boolean; + /** + * USE WITH COUTION AS THIS COULD INTODRUCE BUGS + * + * This option is mainly to allow debuging to exclude some possible problem surfaces at encoding + * @default false + */ + encodeLB?: boolean; + /** + * Disable starttls + */ + noStartTLS?: boolean; + }; + connection: { + hostname: string; + /** + * For TLS the default port is 465 else 25. + * @default 25 or 465 + */ + port?: number; + /** + * authentication data + */ + auth?: { + username: string; + password: string; + }; + /** + * Set this to `true` to connect via SSL / TLS if set to `false` STARTTLS is used. + * Only if `allowUnsecure` is used userdata or mail content could be exposed! + * + * @default false + */ + tls?: boolean; + }; + /** + * Create multiple connections so you can send emails faster! + */ + pool?: { + /** + * Number of Workers + * @default 2 + */ + size?: number; + /** + * Time the connection has to be idle to be closed. (in ms) + * If a value > 1h is set it will be set to 1h + * @default 60000 + */ + timeout?: number; + } | boolean; + client?: { + /** + * There are some cases where warnings are created. These are loged by default but you can 'ignore' them or all warnings should be considered 'error'. + * + * @default log + */ + warning?: "ignore" | "log" | "error"; + /** + * List of preproccessors to + * + * - Filter mail + * - BCC all mails to someone + * - ... + */ + preprocessors?: Preprocessor[]; + }; +} + +export type Preprocessor = ( + mail: ResolvedSendConfig, + client: ResolvedClientOptions, +) => ResolvedSendConfig; + +export function resolveClientOptions( + config: ClientOptions, +): ResolvedClientOptions { + return { + debug: { + log: config.debug?.log ?? false, + allowUnsecure: config.debug?.allowUnsecure ?? false, + encodeLB: config.debug?.encodeLB ?? false, + noStartTLS: config.debug?.noStartTLS ?? false, + }, + connection: { + hostname: config.connection.hostname, + port: config.connection.port ?? (config.connection.tls ? 465 : 25), + tls: config.connection.tls ?? false, + auth: config.connection.auth, + }, + pool: (config.pool + ? (config.pool === true + ? { + size: 2, + timeout: 60000, + } + : { + size: config.pool.size ?? 2, + timeout: config.pool.timeout ?? 60000, + }) + : undefined), + client: { + warning: config.client?.warning ?? "log", + preprocessors: config.client?.preprocessors ?? [], + }, + }; +} diff --git a/config/mail/attachments.ts b/config/mail/attachments.ts new file mode 100644 index 0000000..3fd43fa --- /dev/null +++ b/config/mail/attachments.ts @@ -0,0 +1,41 @@ +import { base64Decode } from "./encoding.ts"; + +interface baseAttachment { + contentType: string; + filename: string; +} + +export type Attachment = + & ( + | textAttachment + | base64Attachment + | arrayBufferLikeAttachment + ) + & baseAttachment; + +export type ResolvedAttachment = + & ( + | textAttachment + | arrayBufferLikeAttachment + ) + & baseAttachment; + +type textAttachment = { encoding: "text"; content: string }; +type base64Attachment = { encoding: "base64"; content: string }; +type arrayBufferLikeAttachment = { + content: ArrayBufferLike | Uint8Array; + encoding: "binary"; +}; + +export function resolveAttachment(attachment: Attachment): ResolvedAttachment { + if (attachment.encoding === "base64") { + return { + filename: attachment.filename, + contentType: attachment.contentType, + encoding: "binary", + content: base64Decode(attachment.content), + }; + } else { + return attachment; + } +} diff --git a/config/mail/content.ts b/config/mail/content.ts new file mode 100644 index 0000000..a83cf47 --- /dev/null +++ b/config/mail/content.ts @@ -0,0 +1,44 @@ +import { quotedPrintableEncode } from "./encoding.ts"; + +export interface Content { + mimeType: string; + content: string; + transferEncoding?: string; +} + +export function resolveContent({ + text, + html, + mimeContent, +}: { + text?: string; + html?: string; + mimeContent?: Content[]; +}): Content[] { + const newContent = [...mimeContent ?? []]; + + if (text === "auto" && html) { + text = html + .replace(//g, "") + .replace(//g, "") + .replace(/<[^>]+>/g, ""); + } + + if (text) { + newContent.push({ + mimeType: 'text/plain; charset="utf-8"', + content: quotedPrintableEncode(text), + transferEncoding: "quoted-printable", + }); + } + + if (html) { + newContent.push({ + mimeType: 'text/html; charset="utf-8"', + content: quotedPrintableEncode(html), + transferEncoding: "quoted-printable", + }); + } + + return newContent; +} diff --git a/config/mail/email.ts b/config/mail/email.ts new file mode 100644 index 0000000..e1dfc6a --- /dev/null +++ b/config/mail/email.ts @@ -0,0 +1,81 @@ +export interface mailObject { + mail: string; + name?: string; +} +export interface saveMailObject { + mail: string; + name: string; +} +type singleMail = string | mailObject; +type mailListObject = Omit, "name" | "mail">; +export type mailList = + | mailListObject + | singleMail + | singleMail[] + | mailObject[]; + +export function isSingleMail(mail: string) { + return /^(([^<>()\[\]\\,;:\s@"]+@[a-zA-Z0-9\-]+\.([a-zA-Z0-9\-]+\.)*[a-zA-Z]{2,})|(<[^<>()\[\]\\,;:\s@"]+@[a-zA-Z0-9]+\.([a-zA-Z0-9\-]+\.)*[a-zA-Z]{2,}>)|([^<>]+ <[^<>()\[\]\\,;:\s@"]+@[a-zA-Z0-9]+\.([a-zA-Z0-9\-]+\.)*[a-zA-Z]{2,}>))$/ + .test(mail); +} + +export function parseSingleEmail(mail: singleMail): saveMailObject { + if (typeof mail !== "string") { + return { + mail: mail.mail, + name: mail.name ?? "", + }; + } + + const mailSplitRe = /^([^<]*)<([^>]+)>\s*$/; + + const res = mailSplitRe.exec(mail); + + if (!res) { + return { + mail, + name: "", + }; + } + + const [_, name, email] = res; + + return { + name: name.trim(), + mail: email.trim(), + }; +} + +export function parseMailList(list: mailList): saveMailObject[] { + if (typeof list === "string") return [parseSingleEmail(list)]; + if (Array.isArray(list)) return list.map((v) => parseSingleEmail(v)); + + if ("mail" in list) { + return [{ + mail: list.mail, + name: list.name ?? "", + }]; + } + + return Object.entries(list as mailListObject).map(([name, mail]) => ({ + name, + mail, + })); +} + +export function validateEmailList( + list: saveMailObject[], +): { ok: saveMailObject[]; bad: saveMailObject[] } { + const ok: saveMailObject[] = []; + const bad: saveMailObject[] = []; + + list.forEach((mail) => { + if (isSingleMail(mail.mail)) { + ok.push(mail); + } else { + bad.push(mail); + } + }); + + return { ok, bad }; +} diff --git a/encoding.ts b/config/mail/encoding.ts similarity index 65% rename from encoding.ts rename to config/mail/encoding.ts index 1174666..240af8f 100644 --- a/encoding.ts +++ b/config/mail/encoding.ts @@ -1,9 +1,16 @@ -export { base64Decode } from "./deps.ts"; +export { base64Decode } from "../../deps.ts"; const encoder = new TextEncoder(); +/** + * Encodes a string as quotedPrintable + * + * @param data string that needs encoding + * @param encLB EXPERIMENTAL setting this to `true` will encode \r and \n. This might leed to problems + * @returns encoded string + */ export function quotedPrintableEncode(data: string, encLB = false) { - data.replaceAll('=', '=3D') + data.replaceAll("=", "=3D"); if (!encLB) { data = data.replaceAll(" \r\n", "=20\r\n").replaceAll(" \n", "=20\n"); @@ -23,8 +30,8 @@ export function quotedPrintableEncode(data: string, encLB = false) { let enc = ""; encodedChar.forEach((i) => { - let c = i.toString(16) - if(c.length === 1) c = '0' + c + let c = i.toString(16); + if (c.length === 1) c = "0" + c; enc += `=${c}`; }); @@ -34,19 +41,19 @@ export function quotedPrintableEncode(data: string, encLB = false) { let ret = ""; const lines = Math.ceil(encodedData.length / 74) - 1; - let offset = 0 + let offset = 0; for (let i = 0; i < lines; i++) { let old = encodedData.slice(i * 74 + offset, (i + 1) * 74); - offset = 0 + offset = 0; - if(old.at(-1) === '=') { - old = old.slice(0, old.length - 1) - offset = -1 + if (old.at(-1) === "=") { + old = old.slice(0, old.length - 1); + offset = -1; } - if(old.at(-2) === '=') { - old = old.slice(0, old.length - 2) - offset = -2 + if (old.at(-2) === "=") { + old = old.slice(0, old.length - 2); + offset = -2; } if (old.endsWith("\r") || old.endsWith("\n")) { diff --git a/config/mail/mod.ts b/config/mail/mod.ts new file mode 100644 index 0000000..02e1a3e --- /dev/null +++ b/config/mail/mod.ts @@ -0,0 +1,176 @@ +import { + Attachment, + resolveAttachment, + ResolvedAttachment, +} from "./attachments.ts"; +import { Content, resolveContent } from "./content.ts"; +import { + isSingleMail, + mailList, + parseMailList, + parseSingleEmail, + saveMailObject, + validateEmailList, +} from "./email.ts"; +import { ResolvedClientOptions } from "../client.ts"; + +/** + * Config for a mail + */ +export interface SendConfig { + to: mailList; + cc?: mailList; + bcc?: mailList; + from: string; + date?: string; + subject: string; + content?: string; + mimeContent?: Content[]; + html?: string; + inReplyTo?: string; + replyTo?: string; + references?: string; + priority?: "high" | "normal" | "low"; + attachments?: Attachment[]; + /** + * type of mail for example `registration` or `newsletter` etc. + * allowes preprocessors to hande different email types + */ + internalTag?: string | symbol; +} + +export interface ResolvedSendConfig { + to: saveMailObject[]; + cc: saveMailObject[]; + bcc: saveMailObject[]; + from: saveMailObject; + date: string; + subject: string; + mimeContent: Content[]; + inReplyTo?: string; + replyTo?: saveMailObject; + references?: string; + priority?: "high" | "normal" | "low"; + attachments: ResolvedAttachment[]; + internalTag?: string | symbol; +} + +export function resolveSendConfig(config: SendConfig): ResolvedSendConfig { + const { + to, + cc = [], + bcc = [], + from, + date = new Date().toUTCString().split(",")[1].slice(1), + subject, + content, + mimeContent, + html, + inReplyTo, + replyTo, + references, + priority, + attachments, + internalTag, + } = config; + + return { + to: parseMailList(to), + cc: parseMailList(cc), + bcc: parseMailList(bcc), + from: parseSingleEmail(from), + date, + mimeContent: resolveContent({ + mimeContent, + html, + text: content, + }), + replyTo: replyTo ? parseSingleEmail(replyTo) : undefined, + inReplyTo, + subject, + attachments: attachments + ? attachments.map((attachment) => resolveAttachment(attachment)) + : [], + references, + priority, + internalTag, + }; +} + +export function validateConfig( + config: ResolvedSendConfig, + client: ResolvedClientOptions, +): ResolvedSendConfig { + const errors: string[] = []; + const warn: string[] = []; + + if (!isSingleMail(config.from.mail)) { + errors.push(`The specified from adress is not a valid email adress.`); + } + + if (config.replyTo && !isSingleMail(config.replyTo.mail)) { + errors.push(`The specified replyTo adress is not a valid email adress.`); + } + + const valTo = validateEmailList(config.to); + + if (valTo.bad.length > 0) { + config.to = valTo.ok; + + valTo.bad.forEach((m) => { + warn.push(`TO Email ${m.mail} is not valid!`); + }); + } + + const valCc = validateEmailList(config.cc); + + if (valCc.bad.length > 0) { + config.to = valCc.ok; + + valCc.bad.forEach((m) => { + warn.push(`CC Email ${m.mail} is not valid!`); + }); + } + + const valBcc = validateEmailList(config.bcc); + + if (valBcc.bad.length > 0) { + config.to = valBcc.ok; + + valBcc.bad.forEach((m) => { + warn.push(`BCC Email ${m.mail} is not valid!`); + }); + } + + if (config.to.length + config.cc.length + config.bcc.length === 0) { + errors.push(`No valid emails provided!`); + } + + if (config.mimeContent.length === 0) { + errors.push(`No content provided!`); + } + + if ( + !config.mimeContent.some(( + v, + ) => (v.mimeType.includes("text/html") || + (v.mimeType.includes("text/plain"))) + ) + ) { + warn.push("You should provide at least html or text content!"); + } + + if (client.client.warning === "log" && warn.length > 0) { + console.warn(warn.join("\n")); + } + + if (client.client.warning === "error") { + errors.push(...warn); + } + + if (errors.length > 0) { + throw new Error(errors.join("\n")); + } + + return config; +} diff --git a/enc.test.ts b/enc.test.ts deleted file mode 100644 index c592440..0000000 --- a/enc.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { quotedPrintableEncode } from "./encoding.ts"; - -// console.log(quotedPrintableEncode('abc')) -// console.log(quotedPrintableEncode('abcß2`öäü dsd sd 😉')) - -// console.log(quotedPrintableEncode( -// `Hätten Hüte ein ß im Namen, wären sie möglicherweise keine Hüte mehr, -// sondern Hüße.` -// )) - -// console.log(quotedPrintableEncode('abc', true)) -// console.log(quotedPrintableEncode('abcß2`öäü dsd sd 😉', true)) - -// console.log(quotedPrintableEncode( -// `Hätten Hüte ein ß im Namen, wären sie möglicherweise keine Hüte mehr, -// sondern Hüße.`, true -// )) - -// console.log(quotedPrintableEncode(`J'interdis aux marchands de vanter trop leurs marchandises. Car ils se font vite pédagogues et t'enseignent comme but ce qui n'est par essence qu'un moyen, et te trompant ainsi sur la route à suivre les voilà bientôt qui te dégradent, car si leur musique est vulgaire ils te fabriquent pour te la vendre une âme vulgaire.`)) - -const strings = [ - `Hätten Hüte ein ß im Namen, wären sie möglicherweise keine Hüte mehr, -sondern Hüße.`, - "abc", - "abcß2`öäü dsd sd 😉", - `J'interdis aux marchands de vanter trop leurs marchandises. Car ils se font vite pédagogues et t'enseignent comme but ce qui n'est par essence qu'un moyen, et te trompant ainsi sur la route à suivre les voilà bientôt qui te dégradent, car si leur musique est vulgaire ils te fabriquent pour te la vendre une âme vulgaire.`, - "😉", -]; - -strings.forEach((s) => { - console.log(s); - console.log(quotedPrintableEncode(s)); - console.log(quotedPrintableDecode(quotedPrintableEncode(s))); - console.log(quotedPrintableDecode(quotedPrintableEncode(s)) == s); -}); diff --git a/mod.ts b/mod.ts index 4e6d8c9..09c86c4 100644 --- a/mod.ts +++ b/mod.ts @@ -1,8 +1,4 @@ -export type { - ConnectConfig, - ConnectConfigWithAuthentication, - SendConfig, -} from "./config.ts"; -export { SmtpClient } from "./smtp.ts"; -export { quotedPrintableEncode } from "./encoding.ts"; -export { SMTPWorker, SMTPWorkerPool } from './pool.ts' \ No newline at end of file +export type { SendConfig } from "./config/mail/mod.ts"; +export type { ClientOptions, Preprocessor } from "./config/client.ts"; +export { SMTPHandler as SMTPClient } from "./client/mod.ts"; +export { quotedPrintableEncode } from "./config/mail/encoding.ts"; diff --git a/pool.ts b/pool.ts deleted file mode 100644 index e8abaa2..0000000 --- a/pool.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { ConnectConfigWithAuthentication, SendConfig } from "./config.ts"; - -export class SMTPWorker { - #timeout: number; - - constructor( - config: ConnectConfigWithAuthentication, - { timeout = 60000, autoconnect = true } = {}, - ) { - this.#config = config; - this.#timeout = timeout; - if(autoconnect) { - this.#startup(); - } - } - #w!: Worker; - #idleTO: number | null = null; - #idleMode2 = false; - #noCon = true; - #config: ConnectConfigWithAuthentication; - - #startup() { - this.#w = new Worker(new URL("./worker.ts", import.meta.url), { - type: "module", - deno: { - permissions: { - net: "inherit", - // ts files - read: true, - }, - namespace: true, - }, - // This allowes the deno option so only for pool and worker we need --unstable - } as any); - - this.#w.addEventListener("message", (ev: MessageEvent) => { - if (ev.data) { - this.#stopIdle(); - } else { - if (this.#idleMode2) { - this.#cleanup(); - } else { - this.#startIdle(); - } - } - }); - - this.#w.postMessage({ - __setup: this.#config, - }); - - this.#noCon = false; - } - - #startIdle() { - console.log("started idle"); - if (this.#idleTO) return; - - this.#idleTO = setTimeout(() => { - console.log("idle mod 2"); - this.#idleMode2 = true; - this.#w.postMessage({ __check_idle: true }); - }, this.#timeout); - } - - #stopIdle() { - if (this.#idleTO) clearTimeout(this.#idleTO); - - this.#idleMode2 = false; - this.#idleTO = null; - } - - #cleanup() { - console.log("killed"); - this.#w.terminate(); - this.#stopIdle(); - } - - public send(mail: SendConfig) { - this.#stopIdle(); - if (this.#noCon) { - this.#startup(); - } - this.#w.postMessage(mail); - } -} - -export class SMTPWorkerPool { - pool: SMTPWorker[] = [] - - constructor( - config: ConnectConfigWithAuthentication, - { timeout = 60000, size = 2 } = {} - ) { - for (let i = 0; i < size; i++) { - this.pool.push(new SMTPWorker(config, {timeout, autoconnect: i === 0})) - } - } - - #lastUsed = -1 - - send(mail: SendConfig) { - this.#lastUsed = (this.#lastUsed + 1) % this.pool.length - - this.pool[this.#lastUsed].send(mail) - } -} - diff --git a/smtp.ts b/smtp.ts deleted file mode 100644 index 2234428..0000000 --- a/smtp.ts +++ /dev/null @@ -1,560 +0,0 @@ -import { CommandCode } from "./code.ts"; -import type { - ConnectConfig, - ConnectConfigWithAuthentication, - SendConfig, -} from "./config.ts"; -import { - normaliceMailList, - normaliceMailString, - validateConfig, -} from "./config.ts"; -import { BufReader, BufWriter, TextProtoReader } from "./deps.ts"; -import { base64Decode, quotedPrintableEncode } from "./encoding.ts"; - -const encoder = new TextEncoder(); - -interface Command { - code: number; - args: string; -} - -interface SmtpClientOptions { - console_debug?: boolean; - unsecure?: boolean; - mailFilter?: (box: string, domain: string, internalTag?: string | symbol | undefined) => (Promise | boolean) - /** @deprecated use ONLY for debugging! enable this to make shure that \n and \r are differently encoded. But this will tripple their size!*/ - encodeLB?: boolean -} - -export class SmtpClient { - #secure = false; - - #conn: Deno.Conn | null = null; - #reader: TextProtoReader | null = null; - #writer: BufWriter | null = null; - - #console_debug = false; - #allowUnsecure = false; - #mailFilter: SmtpClientOptions['mailFilter'] - - constructor({ - console_debug = false, - unsecure = false, - mailFilter, - encodeLB = false - }: SmtpClientOptions = {}) { - this.#console_debug = console_debug; - this.#allowUnsecure = unsecure; - this.#mailFilter = mailFilter - this.#encodeLB = encodeLB - } - - async connect(config: ConnectConfig | ConnectConfigWithAuthentication) { - const conn = await Deno.connect({ - hostname: config.hostname, - port: config.port || 25, - }); - await this.#connect(conn, config); - } - - async connectTLS(config: ConnectConfig | ConnectConfigWithAuthentication) { - const conn = await Deno.connectTls({ - hostname: config.hostname, - port: config.port || 465, - }); - this.#secure = true; - await this.#connect(conn, config); - } - - async close() { - if (!this.#conn) { - return; - } - await this.#conn.close(); - } - - get isSending() { - return this.#currentlySending - } - - get idle() { - return this.#idlePromise - } - - #idlePromise = Promise.resolve() - #idleCB = () => {} - - #encodeLB = false - - #currentlySending = false; - #sending: (() => void)[] = []; - - #cueSending() { - if (!this.#currentlySending) { - this.#idlePromise = new Promise((res) => { - this.#idleCB = res - }) - this.#currentlySending = true; - return; - } - - return new Promise((res) => { - this.#sending.push(() => { - this.#currentlySending = true; - res(); - }); - }); - } - - #queNextSending() { - if (this.#sending.length === 0) { - this.#currentlySending = false; - this.#idleCB() - return; - } - - const run = this.#sending[0]; - - this.#sending.splice(0, 1); - - queueMicrotask(run); - } - - async send(config: SendConfig) { - - try { - await this.#cueSending(); - - validateConfig(config); - - const [from, fromData] = this.parseAddress(config.from); - - const to = config.to ? await this.filterMails(normaliceMailList(config.to).map((m) => this.parseAddress(m)), config.internalTag) : false; - - const cc = config.cc - ? await this.filterMails(normaliceMailList(config.cc).map((v) => this.parseAddress(v)), config.internalTag) - : false; - - const bcc =config.bcc ? await this.filterMails(normaliceMailList(config.bcc).map((v) => - this.parseAddress(v) - ), config.internalTag) : false; - - if (config.replyTo) { - config.replyTo = normaliceMailString(config.replyTo); - - const res = await this.filterMail([config.replyTo, config.replyTo], config.internalTag) - - if(!res) { - throw new Error("ReplyTo Email is not vaild as the filter returned false") - } - } - - const date = config.date ?? - new Date().toUTCString().split(",")[1].slice(1); - - if (config.mimeContent && (config.html || config.content)) { - throw new Error( - "You should not use mimeContent together with html or content option!", - ); - } - - if (!config.mimeContent) { - config.mimeContent = []; - - // Allows to auto - if (config.content === "auto" && config.html) { - config.content = config.html - .replace(//g, "") - .replace(//g, "") - .replace(/<[^>]+>/g, ""); - } - - if (config.content) { - config.mimeContent.push({ - mimeType: 'text/plain; charset="utf-8"', - content: quotedPrintableEncode(config.content, this.#encodeLB), - transferEncoding: "quoted-printable", - }); - } - - if (config.html) { - if (!config.content) { - console.warn( - "[SMTP] We highly recomand adding a plain text content in addition to your html content! You can set content to 'auto' to do this automaticly!", - ); - } - - config.mimeContent.push({ - mimeType: 'text/html; charset="utf-8"', - content: quotedPrintableEncode(config.html), - transferEncoding: "quoted-printable", - }); - - if(this.#console_debug) { - console.log(config.mimeContent.at(-1)?.content, this.#encodeLB) - } - } - } - - if (config.mimeContent.length === 0) { - throw new Error("No Content provided!"); - } - - await this.writeCmd("MAIL", "FROM:", from); - this.assertCode(await this.readCmd(), CommandCode.OK); - - if(to) { - for (let i = 0; i < to.length; i++) { - await this.writeCmd("RCPT", "TO:", to[i][0]); - this.assertCode(await this.readCmd(), CommandCode.OK); - } - } - - if (cc) { - for (let i = 0; i < cc.length; i++) { - await this.writeCmd("RCPT", "TO:", cc[i][0]); - this.assertCode(await this.readCmd(), CommandCode.OK); - } - } - - - if (bcc) { - for (let i = 0; i < bcc.length; i++) { - await this.writeCmd("RCPT", "TO:", bcc[i][0]); - this.assertCode(await this.readCmd(), CommandCode.OK); - } - } - - await this.writeCmd("DATA"); - this.assertCode(await this.readCmd(), CommandCode.BEGIN_DATA); - - await this.writeCmd("Subject: ", config.subject); - await this.writeCmd("From: ", fromData); - if(to) { - await this.writeCmd("To: ", to.map((v) => v[1]).join(";")); - } - if (cc) { - await this.writeCmd("Cc: ", cc.map((v) => v[1]).join(";")); - } - await this.writeCmd("Date: ", date); - - if (config.inReplyTo) { - await this.writeCmd("InReplyTo: ", config.inReplyTo); - } - - if (config.references) { - await this.writeCmd("Refrences: ", config.references); - } - - if (config.replyTo) { - await this.writeCmd("ReplyTo: ", config.replyTo); - } - - if (config.priority) { - await this.writeCmd("Priority:", config.priority); - } - - await this.writeCmd("MIME-Version: 1.0"); - - let boundaryAdditionAtt = 100; - // calc msg boundary - // TODO: replace this with a match or so. - config.mimeContent.map((v) => v.content).join("\n").replace( - new RegExp("--attachment([0-9]+)", "g"), - (_, numb) => { - boundaryAdditionAtt += parseInt(numb, 10); - - return ""; - }, - ); - - const dec = new TextDecoder(); - - if(!config.attachments) config.attachments = [] - - config.attachments.map((v) => { - if (v.encoding === "text") return v.content; - - const arr = new Uint8Array(v.encoding === 'base64' ? base64Decode(v.content) : v.content); - - return dec.decode(arr); - }).join("\n").replace(new RegExp("--attachment([0-9]+)", "g"), (_, numb) => { - boundaryAdditionAtt += parseInt(numb, 10); - - return ""; - }); - - const attachmentBoundary = `attachment${boundaryAdditionAtt}`; - - let boundaryAddition = 100; - // calc msg boundary - // TODO: replace this with a match or so. - config.mimeContent.map((v) => v.content).join("\n").replace( - new RegExp("--message([0-9]+)", "g"), - (_, numb) => { - boundaryAddition += parseInt(numb, 10); - - return ""; - }, - ); - - const messageBoundary = `message${boundaryAddition}`; - - - await this.writeCmd( - `Content-Type: multipart/mixed; boundary=${attachmentBoundary}`, - "\r\n", - ); - await this.writeCmd(`--${attachmentBoundary}`); - - await this.writeCmd( - `Content-Type: multipart/alternative; boundary=${messageBoundary}`, - "\r\n", - ); - - for (let i = 0; i < config.mimeContent.length; i++) { - await this.writeCmd(`--${messageBoundary}`); - await this.writeCmd( - "Content-Type: " + config.mimeContent[i].mimeType, - ); - if (config.mimeContent[i].transferEncoding) { - await this.writeCmd( - `Content-Transfer-Encoding: ${ - config.mimeContent[i].transferEncoding - }` + "\r\n", - ); - } else { - // Send new line - await this.writeCmd(""); - } - - await this.writeCmd(config.mimeContent[i].content, "\r\n"); - } - - await this.writeCmd(`--${messageBoundary}--\r\n`); - - if (config.attachments) { - // Setup attachments - for (let i = 0; i < config.attachments.length; i++) { - const attachment = config.attachments[i]; - - await this.writeCmd(`--${attachmentBoundary}`); - await this.writeCmd( - "Content-Type:", - attachment.contentType + ";", - "name=" + attachment.filename, - ); - - await this.writeCmd( - "Content-Disposition: attachment; filename=" + attachment.filename, - "\r\n", - ); - - if (attachment.encoding === "binary") { - await this.writeCmd("Content-Transfer-Encoding: binary"); - - if ( - attachment.content instanceof ArrayBuffer || - attachment.content instanceof SharedArrayBuffer - ) { - await this.writeCmdBinary(new Uint8Array(attachment.content)); - } else { - await this.writeCmdBinary(attachment.content); - } - - await this.writeCmd("\r\n"); - } else if (attachment.encoding === "text") { - await this.writeCmd("Content-Transfer-Encoding: quoted-printable"); - - await this.writeCmd(attachment.content, "\r\n"); - } else if (attachment.encoding === "base64") { - await this.writeCmd("Content-Transfer-Encoding: binary"); - await this.writeCmdBinary(base64Decode(attachment.content)); - await this.writeCmd(); - } - } - } - - await this.writeCmd(`--${attachmentBoundary}--\r\n`); - - await this.writeCmd(".\r\n"); - - this.assertCode(await this.readCmd(), CommandCode.OK); - } catch (ex) { - this.#queNextSending(); - throw ex; - } - await this.#cleanup() - this.#queNextSending(); - } - - #supportedFeatures = new Set() - #_reader?: BufReader - - async #cleanup() { - this.writeCmd('NOOP') - - while (true) { - const cmd = await this.readCmd() - if(cmd && cmd.code === 250) return - } - } - - async #connect(conn: Deno.Conn, config: ConnectConfig) { - this.#conn = conn; - this.#_reader = new BufReader(this.#conn); - this.#writer = new BufWriter(this.#conn); - this.#reader = new TextProtoReader(this.#_reader); - - this.assertCode(await this.readCmd(), CommandCode.READY); - - await this.writeCmd("EHLO", config.hostname); - - while (true) { - const cmd = await this.readCmd(); - - if(!cmd) break - - // Trim args - const cleanCMD = cmd.args[0] === '-' ? cmd.args.slice(1) : cmd.args - - this.#supportedFeatures.add(cleanCMD) - - if(cmd.args[0] !== '-') break - } - - if (this.#supportedFeatures.has('STARTTLS')) { - await this.writeCmd("STARTTLS"); - this.assertCode(await this.readCmd(), CommandCode.READY); - - this.#conn = await Deno.startTls(this.#conn, { - hostname: config.hostname, - }); - - this.#secure = true; - - this.#_reader = new BufReader(this.#conn); - this.#writer = new BufWriter(this.#conn); - this.#reader = new TextProtoReader(this.#_reader); - - await this.writeCmd("EHLO", config.hostname); - - while (true) { - const cmd = await this.readCmd(); - if (!cmd || !cmd.args.startsWith("-")) break; - } - } - - if (!this.#allowUnsecure && !this.#secure) { - throw new Error( - "Connection is not secure! Don't send authentication over non secure connection!", - ); - } - - if (this.useAuthentication(config)) { - await this.writeCmd("AUTH", "LOGIN"); - this.assertCode(await this.readCmd(), 334); - - await this.writeCmd(btoa(config.username)); - this.assertCode(await this.readCmd(), 334); - - await this.writeCmd(btoa(config.password)); - this.assertCode(await this.readCmd(), CommandCode.AUTHO_SUCCESS); - } - - await this.#cleanup() - } - - private assertCode(cmd: Command | null, code: number, msg?: string) { - if (!cmd) { - throw new Error(`invalid cmd`); - } - if (cmd.code !== code) { - throw new Error(msg || cmd.code + ": " + cmd.args); - } - } - - private async readCmd(): Promise { - if (!this.#reader) { - return null; - } - const result = await this.#reader.readLine(); - - if (this.#console_debug) { - console.log(result); - } - - if (result === null) return null; - const cmdCode = parseInt(result.slice(0, 3).trim()); - const cmdArgs = result.slice(3).trim(); - return { - code: cmdCode, - args: cmdArgs, - }; - } - - private async writeCmd(...args: string[]) { - if (!this.#writer) { - return null; - } - - if (this.#console_debug) { - console.table(args); - } - - const data = encoder.encode([...args].join(" ") + "\r\n"); - await this.#writer.write(data); - await this.#writer.flush(); - } - - private async writeCmdBinary(...args: Uint8Array[]) { - if (!this.#writer) { - return null; - } - - if (this.#console_debug) { - console.table(args.map(() => "Uint8Attay")); - } - - for (let i = 0; i < args.length; i++) { - await this.#writer.write(args[i]); - } - await this.#writer.flush(); - } - - private useAuthentication( - config: ConnectConfig | ConnectConfigWithAuthentication, - ): config is ConnectConfigWithAuthentication { - return (config as ConnectConfigWithAuthentication).username !== undefined; - } - - private parseAddress( - email: string, - ): [string, string] { - if (email.includes("<")) { - const m = email.split("<")[1].split(">")[0]; - return [`<${m}>`, email]; - } else { - return [`<${email}>`, `<${email}>`]; - } - } - - private async filterMail([raw, _withName]: [string, string], internalTag: string | symbol | undefined): Promise { - if(!this.#mailFilter) return true - - const [box, domain] = raw.slice(1, raw.length - 1).split('@') - - const res = await this.#mailFilter!(box, domain, internalTag) - - return res - } - - private async filterMails(mails: [string, string][], internalTag: string | symbol | undefined): Promise<[string, string][]> { - if(!this.#mailFilter) return mails - - const keep = await Promise.all(mails.map(m => this.filterMail(m, internalTag))) - - return mails.filter((_, i) => keep[i]) - } -} diff --git a/test.ts b/test.ts deleted file mode 100644 index cef9c44..0000000 --- a/test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { SmtpClient } from "./smtp.ts"; -import "https://deno.land/std@0.130.0/dotenv/load.ts"; - -const { TLS, PORT, HOSTNAME, MAIL_USER, MAIL_TO_USER, MAIL_PASS } = Deno.env - .toObject(); - -const client = new SmtpClient(); -const config = { - hostname: HOSTNAME, - port: PORT ? parseInt(PORT) : undefined, - username: MAIL_USER, - password: MAIL_PASS, -}; -if (TLS) { - await client.connectTLS(config); -} else { - await client.connect(config); -} - -await client.send({ - from: MAIL_USER, - to: MAIL_TO_USER, - subject: "Deno Smtp build Success" + Math.random() * 1000, - content: "plain text email", - html: ` - - - - - - - Deno Smtp build Success - - -

Success

-

Build succeed!

-

${new Date()}

- - - `, -}); - -await client.close(); diff --git a/test/e2e/basic.test.ts b/test/e2e/basic.test.ts new file mode 100644 index 0000000..19e9787 --- /dev/null +++ b/test/e2e/basic.test.ts @@ -0,0 +1,139 @@ +import { clearEmails, getEmails } from "../fake-smtp.ts"; +import { assertEquals } from "https://deno.land/std@0.136.0/testing/asserts.ts"; +import { SMTPClient } from "../../mod.ts"; + +function wait(to = 10000) { + return new Promise((res) => setTimeout(res, to)); +} + +Deno.test("test simplest mail", async () => { + await clearEmails(); + + const client = new SMTPClient({ + debug: { + allowUnsecure: true, + // log: true, + noStartTLS: true, + }, + connection: { + hostname: "localhost", + port: 1025, + tls: false, + }, + }); + + await client.send({ + from: "me@denomailer.example", + to: "you@denomailer.example", + subject: "testing", + content: "test", + }); + + const mails = await getEmails(); + assertEquals(mails.length, 1); + await client.close(); +}); + +Deno.test("test simplest mail with pool", async () => { + await clearEmails(); + + const client = new SMTPClient({ + debug: { + allowUnsecure: true, + // log: true, + noStartTLS: true, + }, + connection: { + hostname: "localhost", + port: 1025, + tls: false, + }, + pool: true, + }); + + await client.send({ + from: "me@denomailer.example", + to: "you@denomailer.example", + subject: "testing", + content: "test", + }); + + await wait(2000); + + const mails = await getEmails(); + assertEquals(mails.length, 1); + await client.close(); +}); + +Deno.test("test subject", async () => { + await clearEmails(); + + const client = new SMTPClient({ + debug: { + allowUnsecure: true, + // log: true, + noStartTLS: true, + }, + connection: { + hostname: "localhost", + port: 1025, + tls: false, + }, + }); + + const subject = Math.random().toString(); + + await client.send({ + from: "me@denomailer.example", + to: "you@denomailer.example", + subject, + content: "test", + }); + + await wait(2000); + + const mails = await getEmails(); + assertEquals(mails.length, 1); + assertEquals(mails[0].subject, subject); + await client.close(); +}); + +Deno.test("test html", async () => { + await clearEmails(); + + const client = new SMTPClient({ + debug: { + allowUnsecure: true, + // log: true, + noStartTLS: true, + }, + connection: { + hostname: "localhost", + port: 1025, + tls: false, + }, + }); + + const testSet = [ + "

asdjhhj

", + "

kljfskjlsfs", + "

dkjasjd

", + // TODO add some long testsets with linebreaks etc. + ]; + + for (const html of testSet) { + await clearEmails(); + await client.send({ + from: "me@denomailer.example", + to: "you@denomailer.example", + subject: "testing", + content: "test", + html, + }); + + const mails = await getEmails(); + assertEquals(mails.length, 1); + assertEquals(mails[0].html.toString().trim(), html); + } + await client.close(); +}); diff --git a/test/fake-smtp.ts b/test/fake-smtp.ts new file mode 100644 index 0000000..2b83ac2 --- /dev/null +++ b/test/fake-smtp.ts @@ -0,0 +1,30 @@ +interface MailAdresses { + value: { address: string; name: string }[]; + html: string; + text: string; +} + +export function getEmails(): Promise< + { + // deno-lint-ignore no-explicit-any + attachments: any[]; + headerLines: { key: string; line: string }[]; + text: string; + textAsHtml: string; + subject: string; + date: string; + to: MailAdresses; + from: MailAdresses; + html: false | string; + }[] +> { + return fetch("http://localhost:1080/api/emails", { + method: "get", + }).then((v) => v.json()); +} + +export function clearEmails() { + return fetch("http://localhost:1080/api/emails", { method: "delete" }).then( + (v) => v.arrayBuffer(), + ); +} diff --git a/test_deps.ts b/test_deps.ts deleted file mode 100644 index 08a217d..0000000 --- a/test_deps.ts +++ /dev/null @@ -1 +0,0 @@ -export { config as configEnv } from "https://deno.land/std@0.130.0/dotenv/mod.ts"; diff --git a/tests/test_parse.ts b/tests/test_parse.ts deleted file mode 100644 index 42cc1ff..0000000 --- a/tests/test_parse.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { assertEquals } from "https://deno.land/std@0.130.0/testing/asserts.ts"; - -function parseAddress(email: string): [string, string] { - const m = email.match(/(.*)\s<(.*)>/); - return m?.length === 3 ? [`<${m[2]}>`, email] : [`<${email}>`, `<${email}>`]; -} - -Deno.test("parse adresses (MAIL FROM, RCPT TO and DATA commands)", () => { - const [e1, e2] = parseAddress("Deno Land "); - assertEquals([e1, e2], ["", "Deno Land "]); - - const [e3, e4] = parseAddress("root@deno.land"); - assertEquals([e3, e4], ["", ""]); -});