diff --git a/rosetta-test-py/sendmail-python-emails.py b/rosetta-test-py/sendmail-python-emails.py index 3b0421c..7719178 100644 --- a/rosetta-test-py/sendmail-python-emails.py +++ b/rosetta-test-py/sendmail-python-emails.py @@ -121,7 +121,7 @@ def sendmail_connected(env, backend): # @sendmail_suite.placeholder("sendmail-send-message-full") -def sendmail_send_message(env, sender:SMTPBackend, message, sender_address, recipient_addresses, cc_addresses, bcc_addresses, custom_headers, message_options, recipients_options): +def sendmail_send_message(env, sender:SMTPBackend, message, sender_address, recipient_addresses, cc_addresses, bcc_addresses, custom_headers, attachments, message_options, recipients_options): try: message = emails.Message(text=message, subject="Test", @@ -130,6 +130,23 @@ def sendmail_send_message(env, sender:SMTPBackend, message, sender_address, reci cc=cc_addresses, bcc=bcc_addresses, headers=custom_headers,) + for attachment in attachments: + message_properties = { + "content_disposition": attachment["content-disposition"], + } + + if "file-name" in attachment: + message_properties["filename"] = attachment["file-name"] + + read_mode = "r" + if attachment["data"].endswith(".png"): + read_mode = "rb" + message_properties["data"] = open(rosetta.fixture_path('sendmail-fixtures/' + attachment["data"]), read_mode) + + if "content-type" in attachment: + message_properties["content_type"] = attachment["content-type"] + + message.attach(**message_properties) return [message.send(smtp=sender,smtp_mail_options=message_options, smtp_rcpt_options=recipients_options)] except Exception as e: return [e] @@ -154,6 +171,9 @@ def sendmail_error(env, result: SMTPResponse): # python-emails does mitigation for addresses but detection for other fields sendmail_suite.run( + exclude=( + # python-emails does not support attachments without a name + "test_attachment_without_a_name",), exclude_capabilities=( "root.connection.lazy-connection", # TODO: python-emails does not handle failed auth correctly "root.connection.eager-connection", diff --git a/rosetta-test-py/sendmail-redmail.py b/rosetta-test-py/sendmail-redmail.py index 38917da..2aeb1a9 100644 --- a/rosetta-test-py/sendmail-redmail.py +++ b/rosetta-test-py/sendmail-redmail.py @@ -2,6 +2,7 @@ import socket as socketlib import ssl from redmail import EmailSender +import pathlib import smtplib sendmail_suite = rosetta.suite("rosetta-test-suites/sendmail.ros") @@ -131,15 +132,20 @@ def sendmail_connected(env, sender: EmailSender): # @sendmail_suite.placeholder("sendmail-send-message-full") -def sendmail_send_message(env, sender: EmailSender, message, sender_address, recipient_addresses, cc_addresses, bcc_addresses, custom_headers, message_options, recipients_options): +def sendmail_send_message(env, sender: EmailSender, message, sender_address, recipient_addresses, cc_addresses, bcc_addresses, custom_headers, attachments, message_options, recipients_options): try: + redmail_attachments = { + attachment["file-name"]: pathlib.Path(rosetta.fixture_path('sendmail-fixtures/' + attachment["data"])) + for attachment in attachments + if attachment["content-disposition"] == "attachment"} sender.send(sender=sender_address, receivers=recipient_addresses, cc=cc_addresses, bcc=bcc_addresses, headers=custom_headers, subject="test", - text=message) + text=message, + attachments=redmail_attachments) except Exception as e: return [e] return [True] # EmailSender.send does not return anything related to the sending @@ -162,8 +168,20 @@ def sendmail_error(env, result): # Running # +#sendmail_suite.run(only=("test_basic_image_attachment",)) + sendmail_suite.run( exclude=( + # redmail does not support inline disposition + "test_text_attachment_with_inline_disposition", + "test_image_attachment_with_inline_disposition", + + # redmail does not support attachments without a filename + "test_attachment_without_a_name", + + # redmail does not support multiple attachments with the same name + "test_multiple_attachments_with_same_name", + "test_CRLF_detection_in_send-message_recipient", "test_CRLF_mitigation_in_send-message_sender", "test_Connect_with_invalid_credentials"), # TODO redmail leaks sockets when credentials are invalid diff --git a/rosetta-test-suites/sendmail-fixtures/info.txt b/rosetta-test-suites/sendmail-fixtures/info.txt new file mode 100644 index 0000000..de24153 --- /dev/null +++ b/rosetta-test-suites/sendmail-fixtures/info.txt @@ -0,0 +1 @@ +content of info file \ No newline at end of file diff --git a/rosetta-test-suites/sendmail-fixtures/test.png b/rosetta-test-suites/sendmail-fixtures/test.png new file mode 100644 index 0000000..dec35f9 Binary files /dev/null and b/rosetta-test-suites/sendmail-fixtures/test.png differ diff --git a/rosetta-test-suites/sendmail.ros b/rosetta-test-suites/sendmail.ros index dfe39b1..bdda5e5 100644 --- a/rosetta-test-suites/sendmail.ros +++ b/rosetta-test-suites/sendmail.ros @@ -5,11 +5,10 @@ ; indepdenent of whether this is done with an API from the tested library directly or by using ; another library, e.g. a MIME library from the standard library. (sources - '("CPython SMTP tests" "Python Foundation" "https://github.com/python/cpython/blob/9ba2a4638d7b620c939face7642b2f53a9fadc4b/Lib/test/test_smtplib.py") - '("Ruby net-smtp tests" "MRI maintainers" "https://github.com/ruby/net-smtp/blob/master/test/net/smtp/test_smtp.rb") + '("Ruby mail gem spec" "Mikel Lindsaar" "https://github.com/mikel/mail") '("SMTP RFC 2821" "IETF" "https://tools.ietf.org/html/rfc2821") '("SMTP RFC 5321" "IETF" "https://tools.ietf.org/html/rfc5321") - '("SMTP RFC 4954" "IETF" "https://tools.ietf.org/html/rfc4954")) + '("RFC 2183 - Content-Disposition" "IETF" "https://tools.ietf.org/html/rfc2183")) (list ; Socket @@ -28,14 +27,12 @@ (placeholder '(sendmail-connected? connection) "Should return true if the connection is still open") ; Send Mail - (placeholder '(sendmail-send-message-full connection message-content from to-list cc-list bcc-list headers message-options to-list-options) "Send a message to the server. The cc-list and bcc-list are optional. The headers hash is optional. The message-options and the to-list-options are optional. The to-list-options is a list of option tuple lists, one for each receiver. The function should return a list corresponding to the responses from the server for each recipient in the to-list. If there is only a single response, a list with a single response should be returned.") + (placeholder '(sendmail-send-message-full connection message-content from to-list cc-list bcc-list headers attachments message-options to-list-options) "Send a message to the server. The cc-list and bcc-list are optional. The headers hash is optional. The message-options and the to-list-options are optional. The to-list-options is a list of option tuple lists, one for each receiver. The function should return a list corresponding to the responses from the server for each recipient in the to-list. If there is only a single response, a list with a single response should be returned.") (define (sendmail-send-message-with-options connection message-content from to-list message-options to-list-options) - (sendmail-send-message-full connection message-content from to-list '() '() (make-hash-table) message-options to-list-options)) + (sendmail-send-message-full connection message-content from to-list '() '() (make-hash-table) (make-hash-table) message-options to-list-options)) (define (sendmail-send-message connection content from to-list) (sendmail-send-message-with-options connection content from to-list '() '())) - (define (sendmail-send-message-with-headers connection content from to-list headers-hash-map) - (sendmail-send-message-full connection content from to-list '() '() headers-hash-map '() '())) ; Response accessors (placeholder '(send-success? response) "Return whether the sending was successful.") @@ -67,6 +64,19 @@ (define (server-message-contains? content) (string-contains? (server-message-data server) content)) + (define (assert-server-message-contains? content message-property-description) + (assert + (server-message-contains? content) + (string-append "Expected server to receive message with " message-property-description ", but received: " (server-message-data server)))) + + (define (assert-server-message-contains-ci? content message-property-description) + (assert + (server-message-contains-ci? content) + (string-append "Expected server to receive message with " message-property-description ", but received: " (server-message-data server)))) + + (define (server-message-contains-ci? content) + (string-contains-ci? (server-message-data server) content)) + (setup (lambda () (set! server '()))) @@ -84,7 +94,7 @@ (setup (lambda () (set! server (start-mock-server)) (server-set-extensions! server '("STARTTLS")) - (server-set-auths! server '(("LOGIN" ("account" "password")))) + (server-set-auths! server '(("LOGIN" ("account" "password")))) (define (login-auth-proc credentials connection success failure) (let ((login-user-response @@ -178,9 +188,9 @@ (test "Send a message to cc recipients" (lambda () (let - ((responses (sendmail-send-message-full smtp-connection "message content" "sender@sender.to" '() '("user1@recipient.to") '() (make-hash-table) '() '()))) + ((responses (sendmail-send-message-full smtp-connection "message content" "sender@sender.to" '() '("user1@recipient.to") '() (make-hash-table) (make-hash-table) '() '()))) (assert (all? send-success? responses)) - (assert (server-message-contains? "user1@recipient.to")) + (assert-server-message-contains? "user1@recipient.to" "cc recipient") (assert (any? (lambda (request) @@ -190,7 +200,7 @@ (test "Send a message to bcc recipients" (lambda () (let - ((responses (sendmail-send-message-full smtp-connection "message content" "sender@sender.to" '() '() '("user1@recipient.to") (make-hash-table) '() '()))) + ((responses (sendmail-send-message-full smtp-connection "message content" "sender@sender.to" '() '() '("user1@recipient.to") (make-hash-table) (make-hash-table) '() '()))) (assert (all? send-success? responses)) (assert (not (server-message-contains? "user1@recipient.to"))) (assert @@ -273,26 +283,22 @@ (sendmail-disconnect smtp-connection) (set! smtp-connection '()))) + (define (sendmail-send-with-headers headers) + (sendmail-send-message-full + smtp-connection "message content" "sender@sender.to" '("user@recipient.to") '() '() (alist->hash-table headers) (make-hash-table) '() '())) + (test "set basic header" (lambda () - (sendmail-send-message-with-headers smtp-connection "message content" "sender@sender.to" '("user@recipient.to") (alist->hash-table '(("x-my-header" "some value")))) - (assert (server-message-contains? "x-my-header: some value") (string-append "Expected x-my-header to be present, but instead got " (server-message-data server))))) + (sendmail-send-with-headers '(("x-my-header" "some value"))) + (assert-server-message-contains? "x-my-header" "x-my-header present"))) (test "set header with unicode value" (lambda () - (sendmail-send-message-with-headers smtp-connection "message content" "sender@sender.to" '("user@recipient.to") (alist->hash-table '(("x-my-header" "¡some value")))) - (assert - (or - (server-message-contains? "x-my-header: =?utf-8?q?=C2=A1some?= value") - (server-message-contains? "x-my-header: =?utf-8?b?wqFzb21lIHZhbHVl?=") - (server-message-contains? "x-my-header: =?UTF-8?Q?=C2=A1some?= value") - (server-message-contains? "x-my-header: =?UTF-8?B?wqFzb21lIHZhbHVl?=")) - (string-append "Expected x-my-header to contain encoded ¡, but instead got " (server-message-data server))) - (assert - (server-message-contains? "message content") - (string-append "Expected message content to be unaltered by unicode header value, but instead got" (server-message-data server))))) + (sendmail-send-with-headers '(("x-my-header" "¡some value"))) + (assert-server-message-contains? "x-my-header: =?utf-8?q?=C2=A1some?= value" "x-my-header containing encoded ¡") + (assert-server-message-contains? "message content" "unaltered message content despite unicode header value"))) (test "set override standard header" (lambda () - (sendmail-send-message-with-headers smtp-connection "message content" "sender@sender.to" '("user@recipient.to") (alist->hash-table '(("To" "another-user@recipient.to")))) - (assert (server-message-contains? "To: another-user@recipient.to") (string-append "Expected To header field to include another-user..., but instead got " (server-message-data server))))) + (sendmail-send-with-headers '(("To" "another-user@recipient.to"))) + (assert-server-message-contains? "To: another-user@recipient.to" "header field that includes another-user@recipient.to"))) (capability 'crlf-injection (list @@ -302,14 +308,14 @@ '(("1some\rvalue") ("2some\nvalue") ("3some\r\nvalue")) (lambda (header-value) (let - ((responses (sendmail-send-message-with-headers smtp-connection "message content" "sender@sender.to" '("user@recipient.to") (alist->hash-table (list (list "x-my-header" header-value)))))) + ((responses (sendmail-send-with-headers (list (list "x-my-header" header-value))))) (assert (all? send-error? responses) (string-append "The header value: " header-value " should have resulted in error but did not:" responses))))) (data-test "CRLF detection in unicode header value" '(("1¡some\rvalue") ("2¡some\nvalue") ("3¡some\r\nvalue")) (lambda (header-value) (let - ((responses (sendmail-send-message-with-headers smtp-connection "message content" "sender@sender.to" '("user@recipient.to") (alist->hash-table (list (list "x-my-header" header-value)))))) + ((responses (sendmail-send-with-headers (list (list "x-my-header" header-value))))) (assert (all? send-error? responses) (string-append "The header value: " header-value " should have resulted in error but did not:" responses))))) )) @@ -320,7 +326,7 @@ '(("1some\rvalue") ("2some\nvalue") ("3some\r\nvalue")) (lambda (header-value) (let - ((responses (sendmail-send-message-with-headers smtp-connection "message content" "sender@sender.to" '("user@recipient.to") (alist->hash-table (list (list "x-my-header" header-value)))))) + ((responses (sendmail-send-with-headers (list (list "x-my-header" header-value))))) (assert (all? send-success? responses) (string-append "Message was not sent successfully, instead got: " responses)) (assert (not (server-message-contains? header-value)) (string-append "The header value: " header-value " should have been stripped of CRLF but did not:" (server-message-data server)))))) @@ -328,7 +334,7 @@ '(("1¡some \rvalue" "\rvalue") ("2¡some \nvalue" "\nvalue") ("3¡some \r\nvalue" "\r\nvalue")) (lambda (header-value fragment) (let - ((responses (sendmail-send-message-with-headers smtp-connection "message content" "sender@sender.to" '("user@recipient.to") (alist->hash-table (list (list "x-my-header" header-value)))))) + ((responses (sendmail-send-with-headers (list (list "x-my-header" header-value))))) (assert (all? send-success? responses) (string-append "Message was not sent successfully, instead got: " responses)) (assert (not (server-message-contains? fragment)) (string-append "The header value: " header-value " should have been stripped of CRLF but did not: " (server-message-data server)))))) @@ -337,6 +343,141 @@ )) + (capability 'attachments (list + + (define smtp-connection '()) + (define (connect-smtp-server) + (set! smtp-connection (sendmail-connect "localhost" (server-port server)))) + + (setup (lambda () + (set! server (start-mock-server)) + (connect-smtp-server))) + + (tearDown (lambda () + (sendmail-disconnect smtp-connection) + (set! smtp-connection '()))) + + ; attachments-properties is a list of alists with the following keys: + ; "data", "file-name", "content-type", or "content-disposition" + (define sendmail-send-with-attachments (lambda attachments-properties + (let + ((properties-hashs (map alist->hash-table attachments-properties))) + (for-each + (lambda (property-hash) + (if (not (hash-table-exists? property-hash "content-disposition")) + (hash-table-set! property-hash "content-disposition" "attachment"))) + properties-hashs) + (sendmail-send-message-full + smtp-connection + "message content" "sender@sender.to" '("recipient@recipient.to") + '() '() + (make-hash-table) + properties-hashs + '() '())))) + + (test "basic single text attachment" (lambda () + (sendmail-send-with-attachments + '(("data" "info.txt") + ("file-name" "info.txt") + ("content-type" "text/plain"))) + (assert + (or + (server-message-contains? "content of info file") + (server-message-contains? "Y29udGVudCBvZiBpbmZvIGZpbGU=")) + (string-append "Expected server to receive message with text attachment, but received: " (server-message-data server))) + (assert + (server-message-contains-ci? "content-disposition: attachment") + (string-append "Expected server to receive message with content-disposition: attachment, but received: " (server-message-data server))))) + + (test "basic multiple text attachments" (lambda () + (sendmail-send-with-attachments + '(("data" "info.txt") + ("file-name" "info.txt") + ("content-type" "text/plain")) + '(("data" "info.txt") + ("file-name" "second-info.txt") + ("content-type" "text/plain"))) + (assert + (and (server-message-contains-ci? "info.txt") (server-message-contains-ci? "second-info.txt")) + (string-append "Expected server to receive message with two attachments, but received: " (server-message-data server))))) + + (test "basic image attachment" (lambda () + (sendmail-send-with-attachments + '(("data" "test.png") + ("file-name" "test.png") + ("content-type" "image/png"))) + (assert + ; only part of base64 encoded image + (server-message-contains? "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAAXNSR0IArs4c6QAAAARnQU1BAACx") + (string-append "Expected server to receive message with image attachment, but received: " (server-message-data server))) + (assert + (server-message-contains-ci? "content-disposition: attachment") + (string-append "Expected server to receive message with content-disposition: attachment, but received: " (server-message-data server))))) + + (test "multiple attachments with same name" (lambda () + (sendmail-send-with-attachments + '(("data" "info.txt") + ("file-name" "info.txt") + ("content-type" "text/plain")) + + '(("data" "info.txt") + ("file-name" "info.txt") + ("content-type" "text/plain"))) + (assert + (= 2 (length (string-contains-every? (server-message-data server) "info.txt"))) + (string-append "Expected server to receive message with two text attachments with the same name, but received: " (server-message-data server))))) + + ; Filename property is optional: https://datatracker.ietf.org/doc/html/rfc2183#section-1 + (test "attachment without a name" (lambda () + (sendmail-send-with-attachments + '(("data" "info.txt") + ("content-type" "text/plain"))) + (assert-server-message-contains-ci? "content-disposition: attachment" "an attachment") + (assert + (not (server-message-contains-ci? "info.txt")) + (string-append "Expected server to receive a message with an attachment without a name, but received: " (server-message-data server))))) + + (test "image attachment with inline disposition" (lambda () + (sendmail-send-with-attachments + '(("data" "test.png") + ("file-name" "test.png") + ("content-type" "image/png") + ("content-disposition" "inline"))) + (assert-server-message-contains-ci? "content-disposition: inline" "inline attachment"))) + + (test "text attachment with inline disposition" (lambda () + (sendmail-send-with-attachments + '(("data" "info.txt") + ("file-name" "info.txt") + ("content-type" "text/plain") + ("content-disposition" "inline"))) + (assert-server-message-contains-ci? "content-disposition: inline" "inline attachment"))) + + ; - inline attachment with cid? https://www.rfc-editor.org/rfc/rfc2392 + ; => non-trivial as we either need to do it completely manually or use a template engine. A template engine has + ; custom syntax, so we can not create a general scenario for it + + (capability 'automatic-mime-detection (list + + ; This is difficult to test, as we would need to find the attachment MIME part and check + ; the MIME type of the attachment. + + )) + + (capability 'unicode-file-name (list + + (test "basic single text attachment with unicode file name" (lambda () + (sendmail-send-with-attachments + '(("data" "info.txt") + ("file-name" "info¡.txt") + ("content-type" "text/plain"))) + (assert-server-message-contains-ci? "utf-8''info%C2%A1.txt" "unicode file name"))) + + + )) + + )) + (capability 'general-crlf-injection (list (define smtp-connection '()) @@ -462,10 +603,7 @@ smtp-connection "¡a test message containing unicode!" "sender@sender.com" '("user@recipient.com") '("BODY=8BITMIME") '()))) (assert (all? send-success? send-message-responses)) (assert-any-request server "MAIL FROM: BODY=8BITMIME") - (assert - (server-message-contains? "¡a test message containing unicode!") - (string-append - "Expected server to receive message with unicode content directly, but received: " (server-message-data server)))))) + (assert-server-message-contains? "¡a test message containing unicode!" "unicode content")))) )) @@ -489,10 +627,7 @@ smtp-connection "¡a test message containing unicode!" "sender@sender.com" '("user@recipient.com") '() '()))) (assert (all? send-success? send-message-responses)) (assert-any-request server "MAIL FROM: BODY=8BITMIME") - (assert - (server-message-contains? "¡a test message containing unicode!") - (string-append - "Expected server to receive message with unicode content directly, but received: " (server-message-data server)))))) + (assert-server-message-contains? "¡a test message containing unicode!" "unicode content directly")))) )) )) diff --git a/rosetta-test/interpreter-tests.json b/rosetta-test/interpreter-tests.json index 0daa483..87e7f9f 100644 --- a/rosetta-test/interpreter-tests.json +++ b/rosetta-test/interpreter-tests.json @@ -116,5 +116,13 @@ {"input": "(begin (hash-table-ref! ht 'c 3) (hash-table-ref ht 'c))", "expected": 3}, {"input": "(hash-table->alist ht)", "expected": [["b", 2], ["c", 3]]}, {"input": "(hash-table->alist (make-hash-table))", "expected": []}, - {"input": "(hash-table-ref (alist->hash-table '((a 10) (b 20))) 'b)", "expected": 20} + {"input": "(hash-table-ref (alist->hash-table '((a 10) (b 20))) 'b)", "expected": 20}, + {"input": "(substring \"abc\" 0 1)", "expected": "a"}, + {"input": "(substring \"abc\" 0 2)", "expected": "ab"}, + {"input": "(substring \"abc\" 0 3)", "expected": "abc"}, + {"input": "(substring \"abc\" 1 3)", "expected": "bc"}, + {"input": "(substring \"abc\" 0 0)", "expected": ""}, + {"input": "(substring \"abc\" 2 2)", "expected": ""}, + {"input": "(substring \"abc\" 3 4)", "expected": {"type": "Error"}}, + {"input": "(string-contains-every? \"abvabab\" \"ab\")", "expected": [0,3,5]} ] \ No newline at end of file diff --git a/rosetta-test/stdlib.scm b/rosetta-test/stdlib.scm index 924eef1..ab5ff17 100644 --- a/rosetta-test/stdlib.scm +++ b/rosetta-test/stdlib.scm @@ -285,9 +285,42 @@ (string-index-help (cdr str) substr (+ index 1))))) (string-index-help str substr 0)) + (define (substring str start end) + (if (or (< start 0) (< end 0) (> start (length str)) (> end (length str))) + (throw (error "substring: out of bounds"))) + (if (= start end) + "" + (begin + (define (substring-help-to-end str pos end res) + (if (or (empty? str) (= pos end)) + res + (substring-help-to-end (cdr str) (+ pos 1) end (string-append res (car str))))) + (define (substring-help-from-start str start pos) + (if (= start pos) + str + (substring-help-from-start (cdr str) start (+ pos 1)))) + (substring-help-to-end + (substring-help-from-start str start 0) + 0 end "")))) + (define (string-contains? str substr) (if (string-index str substr) #t #f)) (define (string-contains-ci? str substr) - (if (string-index-ci (string-downcase str) (string-downcase substr)) #t #f)) + (if (string-index (string-downcase str) (string-downcase substr)) #t #f)) + + (define (string-contains-every? str substr) + (define result '()) + (define (string-index-help str substr index) + (if (empty? str) + result + (begin + (if (string-prefix? substr str) + (set! result (append result (list index)))) + (string-index-help (cdr str) substr (+ index 1))))) + (string-index-help str substr 0)) + + (define (string-contains-every-ci? str substr) + (string-contains-every? (string-downcase str) (string-downcase substr))) + ) \ No newline at end of file