Compare commits

...

14 commits
v0.1.3 ... main

Author SHA1 Message Date
98e21e4335
Fix badge links in README.md for consistency and clarity 2025-05-19 14:13:13 +02:00
fb2bd6655e
Add badges for Forgejo Actions and Release to README.md 2025-05-19 14:11:44 +02:00
4d7aa5f91a
Bump package version to 0.1.5
All checks were successful
/ serve (push) Successful in 38s
2025-05-19 13:58:24 +02:00
50e1a798f2
Set default value for headers parameter in MicromailClient.headers method
Some checks failed
/ serve (push) Has been cancelled
2025-05-19 13:57:43 +02:00
02e6f3fbcc
Update README.md to enhance MicromailClient documentation and add exceptions section; modify error handling in write method to raise NoSocketError 2025-05-19 13:55:34 +02:00
3795f8d8c0
Add LICENSE file for typestubs, including Apache and MIT licenses 2025-05-19 13:47:29 +02:00
0a87f72b10
Fix pre-commit installation step in release workflow
All checks were successful
/ serve (push) Successful in 43s
2025-05-19 13:42:31 +02:00
7cf349f2d4
Fix pre-commit command in release workflow and update package version to 0.1.4
Some checks failed
/ serve (push) Failing after 8s
2025-05-19 13:41:21 +02:00
4c31b90e84
Add pre-commit check step to release workflow for improved code quality
Some checks failed
/ serve (push) Failing after 12s
2025-05-19 13:40:09 +02:00
ddbf910f34
Add type stubs for socket and ssl modules; update MicromailClient to remove type ignores 2025-05-19 13:39:16 +02:00
77d4da5900
Enhance type annotations and improve method signatures in MicromailClient for better clarity and type safety 2025-05-19 13:11:49 +02:00
4c9ff09ea9
Set default port values in MicromailClient constructor based on SSL and STARTTLS settings 2025-05-19 12:34:55 +02:00
36dc32fef3
Refactor error handling in MicromailClient to raise exceptions instead of exiting; improve connection logic and command responses 2025-05-19 12:33:37 +02:00
f592335c8f
Remove prerelease flag from release workflow configuration 2025-05-18 14:44:27 +02:00
8 changed files with 695 additions and 137 deletions

View file

@ -9,6 +9,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Pre-commit check
run: |
pip install pre-commit
pre-commit run --show-diff-on-failure --color=always --all-files
- name: Prepare files
run: |
mkdir -p /tmp/release
@ -20,4 +24,3 @@ jobs:
direction: upload
release-dir: /tmp/release
token: ${{ secrets.FORGEJO_TOKEN }}
prerelease: true

2
.mypy.ini Normal file
View file

@ -0,0 +1,2 @@
[mypy]
mypy_path = $MYPY_CONFIG_FILE_DIR/.typestubs/

239
.typestubs/LICENSE Normal file
View file

@ -0,0 +1,239 @@
The typestubs were created using source code
from https://github.com/python/typeshed, licensed
under the terms of the Apache license, as
reproduced below.
= = = = =
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright {yyyy} {name of copyright owner}
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
= = = = =
Parts of typeshed are licensed under different licenses (like the MIT
license), reproduced below.
= = = = =
The MIT License
Copyright (c) 2015 Jukka Lehtosalo and contributors
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
= = = = =

234
.typestubs/socket.pyi Normal file
View file

@ -0,0 +1,234 @@
import sys
from enum import IntEnum
import _socket
from _socket import CAPI as CAPI
from _socket import EAI_AGAIN as EAI_AGAIN
from _socket import EAI_BADFLAGS as EAI_BADFLAGS
from _socket import EAI_FAIL as EAI_FAIL
from _socket import EAI_FAMILY as EAI_FAMILY
from _socket import EAI_MEMORY as EAI_MEMORY
from _socket import EAI_NODATA as EAI_NODATA
from _socket import EAI_NONAME as EAI_NONAME
from _socket import EAI_SERVICE as EAI_SERVICE
from _socket import EAI_SOCKTYPE as EAI_SOCKTYPE
from _socket import INADDR_ALLHOSTS_GROUP as INADDR_ALLHOSTS_GROUP
from _socket import INADDR_ANY as INADDR_ANY
from _socket import INADDR_BROADCAST as INADDR_BROADCAST
from _socket import INADDR_LOOPBACK as INADDR_LOOPBACK
from _socket import INADDR_MAX_LOCAL_GROUP as INADDR_MAX_LOCAL_GROUP
from _socket import INADDR_NONE as INADDR_NONE
from _socket import INADDR_UNSPEC_GROUP as INADDR_UNSPEC_GROUP
from _socket import IP_ADD_MEMBERSHIP as IP_ADD_MEMBERSHIP
from _socket import IP_DROP_MEMBERSHIP as IP_DROP_MEMBERSHIP
from _socket import IP_HDRINCL as IP_HDRINCL
from _socket import IP_MULTICAST_IF as IP_MULTICAST_IF
from _socket import IP_MULTICAST_LOOP as IP_MULTICAST_LOOP
from _socket import IP_MULTICAST_TTL as IP_MULTICAST_TTL
from _socket import IP_OPTIONS as IP_OPTIONS
from _socket import IP_TOS as IP_TOS
from _socket import IP_TTL as IP_TTL
from _socket import IPPORT_RESERVED as IPPORT_RESERVED
from _socket import IPPORT_USERRESERVED as IPPORT_USERRESERVED
from _socket import IPPROTO_AH as IPPROTO_AH
from _socket import IPPROTO_DSTOPTS as IPPROTO_DSTOPTS
from _socket import IPPROTO_EGP as IPPROTO_EGP
from _socket import IPPROTO_ESP as IPPROTO_ESP
from _socket import IPPROTO_FRAGMENT as IPPROTO_FRAGMENT
from _socket import IPPROTO_HOPOPTS as IPPROTO_HOPOPTS
from _socket import IPPROTO_ICMP as IPPROTO_ICMP
from _socket import IPPROTO_ICMPV6 as IPPROTO_ICMPV6
from _socket import IPPROTO_IDP as IPPROTO_IDP
from _socket import IPPROTO_IGMP as IPPROTO_IGMP
from _socket import IPPROTO_IP as IPPROTO_IP
from _socket import IPPROTO_IPV6 as IPPROTO_IPV6
from _socket import IPPROTO_NONE as IPPROTO_NONE
from _socket import IPPROTO_PIM as IPPROTO_PIM
from _socket import IPPROTO_PUP as IPPROTO_PUP
from _socket import IPPROTO_RAW as IPPROTO_RAW
from _socket import IPPROTO_ROUTING as IPPROTO_ROUTING
from _socket import IPPROTO_SCTP as IPPROTO_SCTP
from _socket import IPPROTO_TCP as IPPROTO_TCP
from _socket import IPPROTO_UDP as IPPROTO_UDP
from _socket import IPV6_CHECKSUM as IPV6_CHECKSUM
from _socket import IPV6_DONTFRAG as IPV6_DONTFRAG
from _socket import IPV6_HOPLIMIT as IPV6_HOPLIMIT
from _socket import IPV6_HOPOPTS as IPV6_HOPOPTS
from _socket import IPV6_JOIN_GROUP as IPV6_JOIN_GROUP
from _socket import IPV6_LEAVE_GROUP as IPV6_LEAVE_GROUP
from _socket import IPV6_MULTICAST_HOPS as IPV6_MULTICAST_HOPS
from _socket import IPV6_MULTICAST_IF as IPV6_MULTICAST_IF
from _socket import IPV6_MULTICAST_LOOP as IPV6_MULTICAST_LOOP
from _socket import IPV6_PKTINFO as IPV6_PKTINFO
from _socket import IPV6_RECVRTHDR as IPV6_RECVRTHDR
from _socket import IPV6_RECVTCLASS as IPV6_RECVTCLASS
from _socket import IPV6_RTHDR as IPV6_RTHDR
from _socket import IPV6_TCLASS as IPV6_TCLASS
from _socket import IPV6_UNICAST_HOPS as IPV6_UNICAST_HOPS
from _socket import IPV6_V6ONLY as IPV6_V6ONLY
from _socket import NI_DGRAM as NI_DGRAM
from _socket import NI_MAXHOST as NI_MAXHOST
from _socket import NI_MAXSERV as NI_MAXSERV
from _socket import NI_NAMEREQD as NI_NAMEREQD
from _socket import NI_NOFQDN as NI_NOFQDN
from _socket import NI_NUMERICHOST as NI_NUMERICHOST
from _socket import NI_NUMERICSERV as NI_NUMERICSERV
from _socket import SHUT_RD as SHUT_RD
from _socket import SHUT_RDWR as SHUT_RDWR
from _socket import SHUT_WR as SHUT_WR
from _socket import SO_ACCEPTCONN as SO_ACCEPTCONN
from _socket import SO_BROADCAST as SO_BROADCAST
from _socket import SO_DEBUG as SO_DEBUG
from _socket import SO_DONTROUTE as SO_DONTROUTE
from _socket import SO_ERROR as SO_ERROR
from _socket import SO_KEEPALIVE as SO_KEEPALIVE
from _socket import SO_LINGER as SO_LINGER
from _socket import SO_OOBINLINE as SO_OOBINLINE
from _socket import SO_RCVBUF as SO_RCVBUF
from _socket import SO_RCVLOWAT as SO_RCVLOWAT
from _socket import SO_RCVTIMEO as SO_RCVTIMEO
from _socket import SO_REUSEADDR as SO_REUSEADDR
from _socket import SO_SNDBUF as SO_SNDBUF
from _socket import SO_SNDLOWAT as SO_SNDLOWAT
from _socket import SO_SNDTIMEO as SO_SNDTIMEO
from _socket import SO_TYPE as SO_TYPE
from _socket import SOL_IP as SOL_IP
from _socket import SOL_SOCKET as SOL_SOCKET
from _socket import SOL_TCP as SOL_TCP
from _socket import SOL_UDP as SOL_UDP
from _socket import SOMAXCONN as SOMAXCONN
from _socket import TCP_FASTOPEN as TCP_FASTOPEN
from _socket import TCP_KEEPCNT as TCP_KEEPCNT
from _socket import TCP_KEEPINTVL as TCP_KEEPINTVL
from _socket import TCP_MAXSEG as TCP_MAXSEG
from _socket import TCP_NODELAY as TCP_NODELAY
from _socket import SocketType as SocketType
from _socket import _Address as _Address
from _socket import _RetAddress as _RetAddress
from _socket import close as close
from _socket import dup as dup
from _socket import getdefaulttimeout as getdefaulttimeout
from _socket import gethostbyaddr as gethostbyaddr
from _socket import gethostbyname as gethostbyname
from _socket import gethostbyname_ex as gethostbyname_ex
from _socket import gethostname as gethostname
from _socket import getnameinfo as getnameinfo
from _socket import getprotobyname as getprotobyname
from _socket import getservbyname as getservbyname
from _socket import getservbyport as getservbyport
from _socket import has_ipv6 as has_ipv6
from _socket import htonl as htonl
from _socket import htons as htons
from _socket import if_indextoname as if_indextoname
from _socket import if_nameindex as if_nameindex
from _socket import if_nametoindex as if_nametoindex
from _socket import inet_aton as inet_aton
from _socket import inet_ntoa as inet_ntoa
from _socket import inet_ntop as inet_ntop
from _socket import inet_pton as inet_pton
from _socket import ntohl as ntohl
from _socket import ntohs as ntohs
from _socket import setdefaulttimeout as setdefaulttimeout
from _typeshed import ReadableBuffer
class AddressFamily(IntEnum):
AF_INET = 2
AF_INET6 = 10
AF_APPLETALK = 5
AF_IPX = 4
AF_SNA = 22
AF_UNSPEC = 0
if sys.platform != "darwin":
AF_IRDA = 23
if sys.platform != "win32":
AF_ROUTE = 16
AF_UNIX = 1
if sys.platform == "darwin":
AF_SYSTEM = 32
if sys.platform != "win32" and sys.platform != "darwin":
AF_ASH = 18
AF_ATMPVC = 8
AF_ATMSVC = 20
AF_AX25 = 3
AF_BRIDGE = 7
AF_ECONET = 19
AF_KEY = 15
AF_LLC = 26
AF_NETBEUI = 13
AF_NETROM = 6
AF_PPPOX = 24
AF_ROSE = 11
AF_SECURITY = 14
AF_WANPIPE = 25
AF_X25 = 9
if sys.platform == "linux":
AF_CAN = 29
AF_PACKET = 17
AF_RDS = 21
AF_TIPC = 30
AF_ALG = 38
AF_NETLINK = 16
AF_VSOCK = 40
AF_QIPCRTR = 42
if sys.platform != "linux":
AF_LINK = 33
if sys.platform != "darwin" and sys.platform != "linux":
AF_BLUETOOTH = 32
if sys.platform == "win32" and sys.version_info >= (3, 12):
AF_HYPERV = 34
if (
sys.platform != "linux"
and sys.platform != "win32"
and sys.platform != "darwin"
and sys.version_info >= (3, 12)
):
# FreeBSD >= 14.0
AF_DIVERT = 44
class SocketKind(IntEnum):
SOCK_STREAM = 1
SOCK_DGRAM = 2
SOCK_RAW = 3
SOCK_RDM = 4
SOCK_SEQPACKET = 5
if sys.platform == "linux":
SOCK_CLOEXEC = 524288
SOCK_NONBLOCK = 2048
SOCK_STREAM = SocketKind.SOCK_STREAM
SOCK_DGRAM = SocketKind.SOCK_DGRAM
SOCK_RAW = SocketKind.SOCK_RAW
SOCK_RDM = SocketKind.SOCK_RDM
SOCK_SEQPACKET = SocketKind.SOCK_SEQPACKET
if sys.platform == "linux":
SOCK_CLOEXEC = SocketKind.SOCK_CLOEXEC
SOCK_NONBLOCK = SocketKind.SOCK_NONBLOCK
class socket(_socket.socket):
def __init__(
self,
family: AddressFamily | int = -1,
type: SocketKind | int = -1,
proto: int = -1,
/,
) -> None: ...
def write(self, b: ReadableBuffer) -> int | None: ...
def read(self, size: int, /) -> bytes: ...
def readline(self, /) -> bytes: ...
def getaddrinfo(
host: bytes | str | None,
port: bytes | str | int | None,
family: int = 0,
type: int = 0,
proto: int = 0,
flags: int = 0,
) -> list[
tuple[
AddressFamily,
SocketKind,
int,
str,
tuple[str, int] | tuple[str, int, int, int] | tuple[int, bytes],
]
]: ...

18
.typestubs/ssl.pyi Normal file
View file

@ -0,0 +1,18 @@
import socket
from _typeshed import StrOrBytesPath
def wrap_socket(
sock: socket.socket,
keyfile: StrOrBytesPath | None = None,
certfile: StrOrBytesPath | None = None,
server_side: bool = False,
cert_reqs: int = ...,
ssl_version: int = ...,
ca_certs: str | None = None,
do_handshake_on_connect: bool = True,
suppress_ragged_eofs: bool = True,
ciphers: str | None = None,
) -> SSLSocket: ...
class SSLSocket(socket.socket): ...

View file

@ -1,5 +1,8 @@
# micromail
[![Forgejo Actions](https://code.edgarpierre.fr/edpibu/micromail/badges/workflows/release.yaml/badge.svg?&label=release&style=for-the-badge)](https://code.edgarpierre.fr/edpibu/micromail/actions?workflow=release.yaml)
[![Release](https://code.edgarpierre.fr/edpibu/micromail/badges/release.svg?logo=micropython&label=&style=for-the-badge)](https://code.edgarpierre.fr/edpibu/micromail/releases/latest)
![](./logo.svg)
Tiny SMTP client for Micropython. See [examples](examples) for usage.
@ -21,43 +24,75 @@ Copy the `micromail.py` file to your device. You will need to install the `datet
### Class MicromailClient
`class micromail.MicromailClient(host: str, port: int, ssl: bool=False, starttls: bool=False)`
: Create a new SMTP client.
- `class micromail.MicromailClient(host: str, port: int = 0, ssl: bool = False, starttls: bool = False)`
`MicromailClient.connect()`
: Establish a connection to the SMTP server. The connection is kept alive until `MicromailClient.quit()` is called.
Create a new SMTP client.
`MicromailClient.login(username: str, password: str)`
: Login to the SMTP server with the provided username and password. `PLAIN` and `LOGIN` authentication methods are currently supported.
- `MicromailClient.connect()`
`MicromailClient.write(data: bytearray)`
: Write `data` of type `bytearray` on the server.
`MicromailClient.send_command(*cmd: bytearray) -> code: bytearray, response: list[bytearray]`
: Send command to the SMTP server; returns status code and response.
Establish a connection to the SMTP server. The connection is kept alive until `MicromailClient.quit()` is called.
`MicromailClient.ehlo() -> res: list[bytearray]`
: Send SMTP EHLO command to the server and return response.
- `MicromailClient.login(username: str, password: str)`
`MicromailClient.starttls()`
: Send SMTP STARTTLS command and enable SSL on socket.
Login to the SMTP server with the provided username and password. `PLAIN` and `LOGIN` authentication methods are currently supported.
`MicromailClient.new_message(to: str|list[str], sender: None|str=None)`
: Create a new email; sender defaults to username.
- `MicromailClient.write(data: str | bytes)`
`MicromailClient.headers(headers:dict={})`
: Define email headers; supported headers are `date` (`str|datetime`, defaults to `datetime.now()`), `from` (`str`, defaults to `new_message` `sender`), `to` (`str|list[str]`, defaults to `new_message` `to`), `subject` (`str`).
Write `data` of type `bytearray` on the server.
`MicromailClient.write_line(content:str|bytearray)`
: Write message line; `\r\n` is appended to `content`.
- `MicromailClient.send_command(*cmd: str | bytes, code: int | list[int] = []) -> tuple[int, list[str]]`
`MicromailClient.send()`
: Send current email.
Send command to the SMTP server; returns status code and list of response lines.
`MicromailClient.quit()`
: Quit the SMTP server and disconnect from socket.
- `MicromailClient.read_res(cmd: str = "", code: int | list[int] = []) -> tuple[int, list[str]]`
Read response from the SMTP server; returns status code and list of response lines.
- `MicromailClient.ehlo() -> list[str]`
Send SMTP EHLO command to the server and return response.
- `MicromailClient.starttls()`
Send SMTP STARTTLS command and enable SSL on socket.
- `MicromailClient.new_message(to: str | list[str], sender: str | None = None)`
Create a new email; sender defaults to username.
- `MicromailClient.headers(headers: dict[str, str])`
Define email headers; supported headers are `date` (`str`, defaults to current date), `from` (defaults to `new_message` `sender`), `to` (defaults to `new_message` `to`), `subject` (`str`).
- `MicromailClient.write_line(content: str)`
Write message line; `\r\n` is appended to `content`.
- `MicromailClient.send()`
Send current email.
- `MicromailClient.quit()`
Quit the SMTP server and disconnect from socket.
### Functions
`format_date(date: datetime)`
: Return date formatted as email date string.
- `format_date(date: datetime) -> str`
Return date formatted as email date string.
### Exceptions
- `Error`
Standard module error.
- `NoSocketError`
Raised when an operation on the socket is asked but the client is not connected to any socket.
- `SMTPError(cmd: str, code: int, message: str)`
Raised when an unexpected SMTP response code is received.

View file

@ -1,42 +1,57 @@
import errno
import socket
import ssl
import sys
from binascii import b2a_base64
from datetime import datetime, timedelta, timezone
class MicromailClient:
def __init__(self, host, port, ssl=False, starttls=False):
def __init__(
self, host: str, port: int = 0, ssl: bool = False, starttls: bool = False
):
self.host = host
self.port = port
if ssl and starttls:
print("Cannot use both SSL and STARTTLS at the same time.")
sys.exit(1)
raise Error("Cannot use both SSL and STARTTLS at the same time.")
self.ssl = ssl
self.starttls = starttls
self.socket = None
self.username = None
if port == 0:
if ssl:
port = 465
elif starttls:
port = 587
else:
port = 25
self.port = port
def connect(self):
for addr_info in socket.getaddrinfo(
self.host, self.port, 0, socket.SOCK_STREAM, socket.IPPROTO_TCP
):
self.socket: None | socket.socket = None
self.username: str | None = None
def connect(self) -> None:
con_errors = []
try:
ai = socket.getaddrinfo(
self.host, self.port, 0, socket.SOCK_STREAM, socket.IPPROTO_TCP
)
except OSError as e:
ai = []
con_errors.append(e)
for addr_info in ai:
af, socktype, proto, _, sa = addr_info
print(f"Trying {sa}")
try:
self.socket = socket.socket(af, socktype, proto)
except OSError:
print(f"Could not create socket: {sa}")
except OSError as e:
con_errors.append(e)
self.socket = None
continue
try:
self.socket.connect(sa)
except OSError:
print(f"Could not connect to {sa}")
except OSError as e:
con_errors.append(e)
self.socket.close()
self.socket = None
continue
@ -44,98 +59,107 @@ class MicromailClient:
break
if self.socket is None:
print("Could not open socket.")
sys.exit(1)
err = ", ".join(
[errno.errorcode.get(e.errno or 0, str(e)) for e in con_errors]
)
raise Error(
f"Could not connect to server: {err}. Are you connected to the network?"
)
print("Connected to server.")
if self.ssl:
self.socket = ssl.wrap_socket(self.socket)
code = self.socket.read(3)
self.socket.readline()
if code != b"220":
print(f"Error: got code {code} on initial connection")
sys.exit(1)
self.read_res(code=220)
self.features = self.ehlo()
if self.starttls and b"STARTTLS" in self.features:
if self.starttls and "STARTTLS" in self.features:
self.start_starttls()
def login(self, username, password):
def login(self, username: str, password: str) -> None:
self.username = username
auth_methods = None
for feature in self.features:
if feature.startswith(b"AUTH"):
if feature.startswith("AUTH"):
auth_methods = feature[4:].upper().split()
break
if auth_methods is None:
print("No authentication methods available")
sys.exit(1)
raise Error(
f"No authentication methods available. Server features: {self.features}"
)
if b"PLAIN" in auth_methods:
encoded_auth = b2a_base64(f"\0{username}\0{password}", newline=False)
code, res = self.send_command(b"AUTH PLAIN ", encoded_auth)
elif b"LOGIN" in auth_methods:
encoded_username = b2a_base64(username, newline=False)
code, res = self.send_command(b"AUTH LOGIN ", encoded_username)
if code != b"334":
print(f"Error: got code {code} on AUTH LOGIN")
sys.exit(1)
encoded_password = b2a_base64(password, newline=False)
code, res = self.send_command(encoded_password)
if code != b"235":
print(f"Error: got code {code} on AUTH LOGIN")
sys.exit(1)
if "PLAIN" in auth_methods:
encoded_auth = b2a_base64(
f"\0{username}\0{password}".encode(), newline=False
)
self.send_command("AUTH PLAIN ", encoded_auth, code=235)
elif "LOGIN" in auth_methods:
encoded_username = b2a_base64(username.encode(), newline=False)
self.send_command("AUTH LOGIN ", encoded_username, code=334)
encoded_password = b2a_base64(password.encode(), newline=False)
self.send_command(encoded_password, code=235)
else:
print(f"Unsupported authentication method: {', '.join(auth_methods)}")
sys.exit(1)
raise Error(
f"Unsupported authentication methods: {', '.join(auth_methods)}. Micromail only supports PLAIN and LOGIN."
)
print(f"Authenticated as {username}")
print(f"Authenticated as {username}.")
def write(self, data):
def write(self, data: str | bytes) -> None:
if self.socket is None:
print("No socket to write to")
sys.exit(1)
raise NoSocketError
if isinstance(data, str):
data = data.encode()
n = self.socket.write(data)
if n != len(data):
print(f"Failure writing data <{data}>: not all bytes written")
print(f"Failure writing data <{data.decode()}>: not all bytes written")
def send_command(self, *cmd):
self.write(b" ".join(cmd) + b"\r\n")
def send_command(
self, *cmd: str | bytes, code: int | list[int] = []
) -> tuple[int, list[str]]:
cmd_ba = (
b" ".join([c if isinstance(c, bytes) else c.encode() for c in cmd])
+ b"\r\n"
)
self.write(cmd_ba)
return self.read_res(cmd_ba.decode(), code=code)
def read_res(
self, cmd: str = "", code: int | list[int] = []
) -> tuple[int, list[str]]:
if self.socket is None:
raise NoSocketError
response = []
next = True
while next:
code = self.socket.read(3)
r_code = int(self.socket.read(3))
next = self.socket.read(1) == b"-"
response.append(self.socket.readline().strip())
response.append(self.socket.readline().strip().decode())
return code, response
if isinstance(code, int):
code = [code]
if code != [] and r_code not in code:
raise SMTPError(cmd, r_code, " ".join(response))
return r_code, response
def ehlo(self):
code, res = self.send_command(b"EHLO _")
if code != b"250":
print(f"Error: got code {code} on EHLO")
sys.exit(1)
def ehlo(self) -> list[str]:
_, res = self.send_command("EHLO _", code=250)
return res
def start_starttls(self):
code, res = self.send_command(b"STARTTLS")
if code != b"220":
print(f"Error: got code {code} on STARTTLS")
sys.exit(1)
def start_starttls(self) -> None:
if self.socket is None:
raise NoSocketError
print(res)
self.send_command("STARTTLS", code=220)
self.socket = ssl.wrap_socket(self.socket)
def new_message(self, to, sender=None):
code, _ = self.send_command(b"RSET")
if code != b"250":
print(f"Error: got code {code} on RSET")
sys.exit(1)
def new_message(self, to: str | list[str], sender: str | None = None) -> None:
self.send_command("RSET", code=250)
if sender is None:
sender = self.username
@ -145,64 +169,67 @@ class MicromailClient:
self.sender = sender
self.to = to
code, _ = self.send_command(f"MAIL FROM:<{sender}>".encode())
if code != b"250":
print(f"Error: got code {code} on MAIL FROM:<{sender}>")
sys.exit(1)
self.send_command(f"MAIL FROM:<{sender}>", code=250)
for recipient in to:
code, _ = self.send_command(f"RCPT TO:<{recipient}>".encode())
if code not in [b"250", b"251"]:
print(f"Error: got code {code} on RCPT TO:<{recipient}>")
self.send_command(f"RCPT TO:<{recipient}>", code=[250, 251])
code, _ = self.send_command(b"DATA")
if code != b"354":
print(f"Error: got code {code} on DATA")
sys.exit(1)
self.send_command("DATA", code=354)
def headers(self, headers={}):
date = headers.get("date", datetime.now(tz=timezone(timedelta())))
if isinstance(date, datetime):
date = format_date(date)
self.write(f"Date: {date}\r\n".encode())
def headers(self, headers: dict[str, str] = {}) -> None:
date = headers.get("date", format_date(datetime.now(tz=timezone(timedelta()))))
self.write(f"Date: {date}\r\n")
sender = headers.get("from", self.sender)
self.write(f"From: {sender}\r\n".encode())
self.write(f"From: {sender}\r\n")
if "subject" in headers:
self.write(f"Subject: {headers['subject']}\r\n".encode())
self.write(f"Subject: {headers['subject']}\r\n")
to = headers.get("to", self.to)
if to is None:
to = self.to
if isinstance(to, str):
to = [to]
self.write(f"To: {', '.join(to)}\r\n".encode())
self.write(f"To: {', '.join(to)}\r\n")
self.write("\r\n".encode())
self.write("\r\n")
def write_line(self, content):
if isinstance(content, str):
content = content.encode()
if content.startswith(b"."):
content = b"." + content
def write_line(self, content: str) -> None:
if content.startswith("."):
content = "." + content
self.write(content + b"\r\n")
self.write(content + "\r\n")
def send(self):
code, res = self.send_command(b"\r\n.")
if code != b"250":
print(f"Error: got code {code} on send")
print(res)
sys.exit(1)
def send(self) -> None:
self.send_command("\r\n.", code=250)
print("Message sent successfully")
def quit(self):
code, _ = self.send_command(b"QUIT")
if code != b"221":
print(f"Error: got code {code} on QUIT")
def quit(self) -> None:
if self.socket is None:
raise NoSocketError
self.send_command(b"QUIT", code=221)
print("Disconnected from server")
self.socket.close()
class Error(Exception):
pass
class NoSocketError(Error):
pass
class SMTPError(Error):
def __init__(self, cmd: str, code: int, message: str):
super().__init__(f"{cmd} -> {code} {message}")
self.cmd = cmd
self.code = code
MONTHS = [
"Jan",
"Feb",
@ -219,7 +246,7 @@ MONTHS = [
]
def format_date(date):
def format_date(date: datetime) -> str:
date_str = f"{date.day} {MONTHS[date.month]} {str(date.year)[-2:]}"
time_str = f"{date.hour:02}:{date.minute:02}:{date.second:02} UT"
return f"{date_str} {time_str}"

View file

@ -5,5 +5,5 @@
"deps": [
["datetime", "latest"]
],
"version": "0.1.3"
"version": "0.1.5"
}