Repository: sabuhish/fastapi-mail

Issue:
Allow creating custom headers for attachments
Use case - attaching html images with Content-ID header to reference them in HTML content of email based on [RFC2392](https://datatracker.ietf.org/doc/html/rfc2392)

How to do it:
- Modify MessageSchema to allow setting custom headers, like this
```python

message = MessageSchema(
        subject=subject,
        recipients=recipients,
        html=html,
        subtype="html",
        attachments=[{"file": "/path/to/file", "headers":{"Content-ID": "<image1>"}, "mime_type" : "image", "mime_subtype": "png"}]
    )
```
`file` parameter can be as now, UploadFile and path

As it's hard to automagically import mime class  as they dont' have the same API, it's possible to do it through MIMEBase

So it's possible to add custom headers and override `mime_type` used in `MIMEBase` with `type` and `subtype` attributes

and in `MailMsg`, in `attach_file` method, unpack the structure and add headers.

I can code it if this is fine @sabuhish 

Discussion:
wjurkowlaniec: @marktennyson done
sabuhish: @wjurkowlaniec Thank you for your work! I have also added tests for custom headers and refactored a bit of codebase. Now we can merge to the master.

Patch:
diff --git a/Makefile b/Makefile
index 4d0b190..6cc23ea 100644
--- a/Makefile
+++ b/Makefile
@@ -15,7 +15,7 @@ format_code:
 
 
 test_only:
-	pytest --cov-report term-missing --cov-report html --cov-branch \
+	pytest -v --cov-report term-missing --cov-report html --cov-branch \
 			--cov fastapi_mail/
 
 test: lint test_only
diff --git a/docs/example.md b/docs/example.md
index 590759c..401b057 100644
--- a/docs/example.md
+++ b/docs/example.md
@@ -188,6 +188,29 @@ Jinja behind the scenes. In these versions, you can then access your dict in you
 As you can see our keys in our dict are no longer the top level, they are part of the `body` variable. Nesting works 
 as per normal below this level also. 
 
+### Customizing attachments by headers and MIME type
+
+Used for example for referencing Content-ID images in html of email
+
+```python
+message = MessageSchema(
+    subject='Fastapi-Mail module',
+    recipients=recipients,
+    html="<img src='cid:logo_image'>",
+    subtype='html',
+    attachments=[
+            {
+                "file": "/path/to/file.png"),
+                "headers": {"Content-ID": "<logo_image>"},
+                "mime_type": "image",
+                "mime_subtype": "png",
+            }
+        ],
+)
+
+fm = FastMail(conf)
+await fm.send_message(message)
+```
 ##  Guide for email utils
 
 The utility allows you to check temporary email addresses, you can block any email or domain. 
diff --git a/fastapi_mail/msg.py b/fastapi_mail/msg.py
index cc92b34..183148d 100644
--- a/fastapi_mail/msg.py
+++ b/fastapi_mail/msg.py
@@ -41,12 +41,14 @@ class MailMsg:
         return MIMEText(text, _subtype=subtype, _charset=self.charset)
 
     async def attach_file(self, message, attachment):
-
-        print(attachment)
-
-        for file in attachment:
-
-            part = MIMEBase(_maintype='application', _subtype='octet-stream')
+        """Creates a MIMEBase object"""
+        for file, file_meta in attachment:
+            if file_meta and 'mime_type' in file_meta and 'mime_subtype' in file_meta:
+                part = MIMEBase(
+                    _maintype=file_meta['mime_type'], _subtype=file_meta['mime_subtype']
+                )
+            else:
+                part = MIMEBase(_maintype='application', _subtype='octet-stream')
 
             part.set_payload(await file.read())
             encode_base64(part)
@@ -62,7 +64,9 @@ class MailMsg:
             filename = ('UTF8', '', filename)
 
             part.add_header('Content-Disposition', 'attachment', filename=filename)
-
+            if file_meta and 'headers' in file_meta:
+                for header in file_meta['headers'].keys():
+                    part.add_header(header, file_meta['headers'][header])
             self.message.attach(part)
 
     async def _message(self, sender):
diff --git a/fastapi_mail/schemas.py b/fastapi_mail/schemas.py
index 18ff8f9..1fcdde2 100644
--- a/fastapi_mail/schemas.py
+++ b/fastapi_mail/schemas.py
@@ -1,7 +1,7 @@
 import os
 from enum import Enum
 from mimetypes import MimeTypes
-from typing import Any, Dict, List, Optional, Union
+from typing import Dict, List, Optional, Union
 
 from pydantic import BaseModel, EmailStr, validator
 from starlette.datastructures import UploadFile
@@ -29,7 +29,7 @@ class MultipartSubtypeEnum(Enum):
 
 class MessageSchema(BaseModel):
     recipients: List[EmailStr]
-    attachments: List[Any] = []
+    attachments: List[Union[UploadFile, Dict, str]] = []
     subject: str = ''
     body: Optional[Union[str, list]] = None
     template_body: Optional[Union[list, dict]] = None
@@ -47,17 +47,25 @@ class MessageSchema(BaseModel):
         mime = MimeTypes()
 
         for file in v:
+            file_meta = None
+            if isinstance(file, dict):
+                keys = file.keys()
+                if 'file' not in keys:
+                    raise WrongFile('missing "file" key')
+                file_meta = dict.copy(file)
+                del file_meta['file']
+                file = file['file']
             if isinstance(file, str):
                 if os.path.isfile(file) and os.access(file, os.R_OK) and validate_path(file):
                     mime_type = mime.guess_type(file)
                     f = open(file, mode='rb')
                     _, file_name = os.path.split(f.name)
                     u = UploadFile(file_name, f, content_type=mime_type[0])
-                    temp.append(u)
+                    temp.append((u, file_meta))
                 else:
                     raise WrongFile('incorrect file path for attachment or not readable')
             elif isinstance(file, UploadFile):
-                temp.append(file)
+                temp.append((file, file_meta))
             else:
                 raise WrongFile('attachments field type incorrect, must be UploadFile or path')
         return temp
@@ -69,6 +77,9 @@ class MessageSchema(BaseModel):
             return 'html'
         return value
 
+    class Config:
+        arbitrary_types_allowed = True
+
 
 def validate_path(path):
     cur_dir = os.path.abspath(os.curdir)
diff --git a/files/attachement_2.txt b/files/attachement_2.txt
new file mode 100644
index 0000000..1797b61
--- /dev/null
+++ b/files/attachement_2.txt
@@ -0,0 +1,1 @@
+file test content
\ No newline at end of file


Test patch:
diff --git a/.github/workflows/test-package.yml b/.github/workflows/test-package.yml
index a428294..3822461 100644
--- a/.github/workflows/test-package.yml
+++ b/.github/workflows/test-package.yml
@@ -1,5 +1,7 @@
 name: CI
-on: [push]
+on: 
+  push:
+    branches: '**'
 
 jobs:
   ci:
diff --git a/tests/test_connection.py b/tests/test_connection.py
index 0f6c47e..2a93cb1 100644
--- a/tests/test_connection.py
+++ b/tests/test_connection.py
@@ -1,7 +1,11 @@
+import os
+
 import pytest
 
 from fastapi_mail import ConnectionConfig, FastMail, MessageSchema
 
+CONTENT = 'file test content'
+
 
 @pytest.mark.asyncio
 async def test_connection(mail_config):
@@ -44,6 +48,76 @@ async def test_html_message(mail_config):
     assert msg.html == 'html test'
 
 
+@pytest.mark.asyncio
+async def test_attachement_message(mail_config):
+    directory = os.getcwd()
+    attachement = directory + '/files/attachement.txt'
+
+    with open(attachement, 'w') as file:
+        file.write(CONTENT)
+
+    subject = 'testing'
+    to = 'to@example.com'
+    msg = MessageSchema(
+        subject=subject,
+        recipients=[to],
+        html='html test',
+        subtype='html',
+        attachments=[attachement],
+    )
+    conf = ConnectionConfig(**mail_config)
+    fm = FastMail(conf)
+
+    with fm.record_messages() as outbox:
+        await fm.send_message(message=msg)
+        mail = outbox[0]
+
+        assert len(outbox) == 1
+        assert mail._payload[1].get_content_maintype() == 'application'
+        assert mail._payload[1].__dict__.get('_headers')[0][1] == 'application/octet-stream'
+
+
+@pytest.mark.asyncio
+async def test_attachement_message_with_headers(mail_config):
+    directory = os.getcwd()
+    attachement = directory + '/files/attachement.txt'
+
+    with open(attachement, 'w') as file:
+        file.write(CONTENT)
+
+    subject = 'testing'
+    to = 'to@example.com'
+    msg = MessageSchema(
+        subject=subject,
+        recipients=[to],
+        html='html test',
+        subtype='html',
+        attachments=[
+            {
+                'file': attachement,
+                'headers': {'Content-ID': 'test ID'},
+                'mime_type': 'image',
+                'mime_subtype': 'png',
+            }
+        ],
+    )
+    conf = ConnectionConfig(**mail_config)
+    fm = FastMail(conf)
+
+    with fm.record_messages() as outbox:
+        await fm.send_message(message=msg)
+
+        assert len(outbox) == 1
+        mail = outbox[0]
+        assert mail._payload[1].get_content_maintype() == msg.attachments[0][1].get('mime_type')
+        assert mail._payload[1].get_content_subtype() == msg.attachments[0][1].get('mime_subtype')
+
+        assert mail._payload[1].__dict__.get('_headers')[0][1] == 'image/png'
+        assert mail._payload[1].__dict__.get('_headers')[4][1] == msg.attachments[0][1].get(
+            'headers'
+        ).get('Content-ID')
+
+
 @pytest.mark.asyncio
 async def test_jinja_message_list(mail_config):
     sender = f"{mail_config['MAIL_FROM_NAME']} <{mail_config['MAIL_FROM']}>"