Coverage for modules\email\models.py: 94%

114 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-10-17 16:50 +0800

1from django.db import models 

2from core.models import TenantScopedModel, UUIDModel, TimeStampedModel 

3from modules.authentication.models import User 

4 

5 

6class EmailAccount(TenantScopedModel): 

7 """Connected email account (IMAP/SMTP or OAuth).""" 

8 

9 PROVIDER_CHOICES = [ 

10 ('gmail', 'Gmail'), 

11 ('outlook', 'Outlook'), 

12 ('yahoo', 'Yahoo'), 

13 ('other', 'Other'), 

14 ] 

15 

16 CONNECTION_CHOICES = [ 

17 ('imap', 'IMAP/SMTP'), 

18 ('oauth', 'OAuth2'), 

19 ] 

20 

21 user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='email_accounts') 

22 provider = models.CharField(max_length=32, choices=PROVIDER_CHOICES, default='other') 

23 connection_type = models.CharField(max_length=16, choices=CONNECTION_CHOICES, default='imap') 

24 

25 # Identity 

26 email_address = models.EmailField() 

27 display_name = models.CharField(max_length=128, blank=True) 

28 

29 # IMAP/SMTP settings 

30 imap_host = models.CharField(max_length=256, blank=True, null=True) 

31 imap_port = models.IntegerField(default=993) 

32 smtp_host = models.CharField(max_length=256, blank=True, null=True) 

33 smtp_port = models.IntegerField(default=587) 

34 encrypted_password = models.CharField(max_length=512, blank=True, null=True) 

35 

36 # OAuth tokens 

37 oauth_token = models.JSONField(blank=True, null=True) 

38 oauth_refresh_token = models.CharField(max_length=512, blank=True, null=True) 

39 

40 # Settings 

41 is_primary = models.BooleanField(default=False) 

42 sync_enabled = models.BooleanField(default=True) 

43 last_sync_at = models.DateTimeField(blank=True, null=True) 

44 sync_interval = models.IntegerField(default=5) # minutes 

45 

46 # Security 

47 use_ssl = models.BooleanField(default=True) 

48 is_verified = models.BooleanField(default=False) 

49 

50 class Meta: 

51 ordering = ['-is_primary', '-created_at'] 

52 

53 def __str__(self): 

54 return f"{self.email_address} ({self.get_provider_display()})" 

55 

56 

57class EmailFolder(TenantScopedModel): 

58 """Email folder (INBOX, SENT, DRAFTS, etc.).""" 

59 

60 FOLDER_TYPE_CHOICES = [ 

61 ('inbox', 'Inbox'), 

62 ('sent', 'Sent'), 

63 ('draft', 'Drafts'), 

64 ('trash', 'Trash'), 

65 ('spam', 'Spam'), 

66 ('outbox', 'Outbox'), 

67 ('custom', 'Custom'), 

68 ] 

69 

70 account = models.ForeignKey(EmailAccount, on_delete=models.CASCADE, related_name='folders') 

71 name = models.CharField(max_length=128) 

72 remote_id = models.CharField(max_length=256, blank=True, null=True) 

73 folder_type = models.CharField(max_length=16, choices=FOLDER_TYPE_CHOICES, default='custom') 

74 unread_count = models.IntegerField(default=0) 

75 

76 class Meta: 

77 ordering = ['folder_type', 'name'] 

78 unique_together = ['account', 'remote_id'] 

79 

80 def __str__(self): 

81 return f"{self.account.email_address}: {self.name}" 

82 

83 

84class EmailFilter(UUIDModel, TimeStampedModel): 

85 """Email filter/rule for automatic folder sorting.""" 

86 

87 tenant_id = models.UUIDField(db_index=True) 

88 account = models.ForeignKey( 

89 EmailAccount, 

90 on_delete=models.CASCADE, 

91 related_name='filters' 

92 ) 

93 name = models.CharField(max_length=128) 

94 

95 # Conditions (JSON field for flexibility) 

96 # Example: {'field': 'from', 'operator': 'contains', 'value': 'newsletter'} 

97 conditions = models.JSONField(default=dict) 

98 

99 # Actions 

100 action_type = models.CharField(max_length=32) # 'move_to_folder', 'mark_read', 'star', 'delete' 

101 action_folder = models.ForeignKey( 

102 EmailFolder, 

103 on_delete=models.SET_NULL, 

104 null=True, 

105 blank=True, 

106 related_name='filter_actions' 

107 ) 

108 

109 # Rule settings 

110 is_active = models.BooleanField(default=True) 

111 priority = models.IntegerField(default=0) # Lower = higher priority 

112 

113 class Meta: 

114 ordering = ['priority', 'name'] 

115 verbose_name_plural = 'Email filters' 

116 

117 def __str__(self): 

118 return f"{self.name} ({self.account.email_address})" 

119 

120 

121class EmailMessage(UUIDModel, TimeStampedModel): 

122 """Email message.""" 

123 

124 tenant_id = models.UUIDField(db_index=True) 

125 account = models.ForeignKey(EmailAccount, on_delete=models.CASCADE, related_name='messages', null=True, blank=True) 

126 folder = models.ForeignKey(EmailFolder, on_delete=models.SET_NULL, null=True, blank=True, related_name='messages') 

127 

128 # Identity 

129 message_id = models.CharField(max_length=512) # RFC 822 Message-ID 

130 uid = models.CharField(max_length=256, blank=True, null=True) # IMAP UID 

131 

132 # Addresses 

133 from_address = models.CharField(max_length=256) 

134 from_name = models.CharField(max_length=128, blank=True, null=True) 

135 to_addresses = models.JSONField(default=list) # [{email, name}] 

136 cc_addresses = models.JSONField(blank=True, null=True) 

137 bcc_addresses = models.JSONField(blank=True, null=True) 

138 

139 # Content 

140 subject = models.CharField(max_length=512) 

141 snippet = models.CharField(max_length=256, blank=True) 

142 body_text = models.TextField(blank=True, null=True) 

143 body_html = models.TextField(blank=True, null=True) 

144 

145 # Metadata 

146 is_read = models.BooleanField(default=False) 

147 is_starred = models.BooleanField(default=False) 

148 is_draft = models.BooleanField(default=False) 

149 is_sent = models.BooleanField(default=False) 

150 is_internal = models.BooleanField(default=False) 

151 is_flagged = models.BooleanField(default=False) 

152 

153 # Dates 

154 sent_at = models.DateTimeField(blank=True, null=True) 

155 received_at = models.DateTimeField(blank=True, null=True) 

156 

157 # Size 

158 size_bytes = models.IntegerField(default=0) 

159 

160 class Meta: 

161 ordering = ['-received_at', '-created_at'] 

162 indexes = [ 

163 models.Index(fields=['tenant_id', 'folder', '-received_at']), 

164 models.Index(fields=['tenant_id', 'account', '-received_at']), 

165 ] 

166 

167 def __str__(self): 

168 return f"{self.subject[:50]} - {self.from_address}" 

169 

170 

171class EmailAttachment(UUIDModel, TimeStampedModel): 

172 """Email attachment.""" 

173 

174 tenant_id = models.UUIDField(db_index=True) 

175 message = models.ForeignKey(EmailMessage, on_delete=models.CASCADE, related_name='attachments') 

176 file_name = models.CharField(max_length=256) 

177 content_type = models.CharField(max_length=128) 

178 size_bytes = models.IntegerField(default=0) 

179 remote_id = models.CharField(max_length=256, blank=True, null=True) 

180 file_path = models.CharField(max_length=512, blank=True, null=True) # Local storage path 

181 

182 def __str__(self): 

183 return self.file_name 

184 

185 

186class InternalEmail(UUIDModel, TimeStampedModel): 

187 """Internal ERP email between users.""" 

188 

189 tenant_id = models.UUIDField(db_index=True) 

190 sender = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sent_internal_emails') 

191 recipients = models.ManyToManyField(User, related_name='received_internal_emails', through='InternalEmailRecipient') 

192 subject = models.CharField(max_length=512) 

193 body = models.TextField() 

194 sent_at = models.DateTimeField(auto_now_add=True) 

195 

196 class Meta: 

197 ordering = ['-sent_at'] 

198 

199 def __str__(self): 

200 return f"Internal: {self.subject[:50]}" 

201 

202 

203class InternalEmailRecipient(UUIDModel, TimeStampedModel): 

204 """Recipient of internal email with read status.""" 

205 

206 tenant_id = models.UUIDField(db_index=True) 

207 email = models.ForeignKey(InternalEmail, on_delete=models.CASCADE, related_name='email_recipients') 

208 recipient = models.ForeignKey(User, on_delete=models.CASCADE, related_name='email_recipients') 

209 is_read = models.BooleanField(default=False) 

210 read_at = models.DateTimeField(blank=True, null=True) 

211 

212 class Meta: 

213 unique_together = ['email', 'recipient'] 

214 

215 def __str__(self): 

216 return f"To: {self.recipient.email} - {self.email.subject[:30]}"